Kaynağa Gözat

feat(api-v13): 新增 GET /v3/tipitaka-discussions/{channel},按章 / 段落区间列讨论

channel 必填;book + chapter 或 para/to 收窄范围,type 逗号多选(默认只列话题),
status / severity 过滤;每条带所在句子坐标,按经文顺序排;可见性与墓碑同 /v3/discussions。
chapterRange 改为 public 与 tipitaka-reading 共用。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 13 saat önce
ebeveyn
işleme
7bf1d21d4c

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

@@ -43,7 +43,8 @@ class DiscussionController extends Controller
      * 只列话题(不含回复),删除了但还有回复的话题以墓碑出现(deleted_at 有值、内容为空)。
      * 未登录看不到不公开的话题(discussion);basic 角色在自己无权管理的对象上只看得到
      * 自己发起的不公开话题。各状态的数量与「能发起哪些类型」见 `GET /v3/discussions/summary`。
-     * 不传 type 时只列评论线程(discussion / qa / help / issue),批注要显式 type=note / commentary。
+     * 不传 type 时只列评论线程(discussion / qa / help / issue / glossary),批注要显式 type=note / commentary。
+     * 要一段经文(一章、一段段落区间)里的全部话题,用 `GET /v3/tipitaka-discussions/{channel}`。
      *
      * @unauthenticated
      *

+ 61 - 0
api-v13/app/Http/Controllers/V3/TipitakaDiscussionController.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Concerns\ResolvesCurrentUser;
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\IndexTipitakaDiscussionRequest;
+use App\Http\Resources\V3\DiscussionResource;
+use App\Services\V3\DiscussionService;
+
+/**
+ * 按经文范围列讨论:某个 channel 的一段经文(整个 channel / 一本书 / 一章 / 段落区间)里,
+ * 挂在句子上的全部话题。给前端的章节讨论面板、agent 的「这一章还有哪些未关闭的 issue」用。
+ *
+ * 与 `GET /v3/discussions` 是两个契约:那边按单个对象查(target_type + target_id 必填),
+ * 这边按 channel + 经文范围查,必填参数不同,所以分成两个端点(v3 规范第 1 条)。
+ * 没有对应的 v2 端点;最接近的是 `POST /v2/sent-discussion-tree`(调用方逐句列出句子坐标,
+ * 只回标题与回复数),v2 那份原样保留。
+ */
+class TipitakaDiscussionController extends Controller
+{
+    use ResolvesCurrentUser;
+
+    public function __construct(private readonly DiscussionService $discussions) {}
+
+    /**
+     * 列出一段经文里的话题
+     *
+     * 只列挂在该 channel 句子上的话题(不含回复),按经文顺序(书、段、句)排列,同一句里按时间;
+     * 每条带 `sentence`(所在句子的坐标)。删除了但还有回复的话题以墓碑出现。
+     * 不传 type 时只列评论线程(discussion / qa / help / issue / glossary),批注要显式
+     * type=note / commentary。可见性同 `GET /v3/discussions`:未登录看不到 discussion;
+     * basic 角色在自己不能编辑的 channel 上只看得到自己发起的 discussion。
+     * channel 不存在或范围内没有讨论都返回空列表;chapter 不存在 404。
+     *
+     * @unauthenticated
+     *
+     * @responseStatus 404 chapter 不是本书存在的段落
+     *
+     * @urlParam channel string required 句子所在 channel 的 uuid
+     *
+     * @queryParam book integer 书号;与 chapter / para / to 一起用时必填,不传则是整个 channel。Example: 169
+     * @queryParam chapter integer 章节起始段落号,服务端按章节长度展开成段落区间;不能与 para / to 同用。Example: 893
+     * @queryParam para integer 起始段落号。Example: 895
+     * @queryParam to integer 结束段落号(含),缺省同 para。Example: 896
+     * @queryParam type string 类型,逗号分隔多选;不传则只列评论线程。Example: issue,glossary
+     * @queryParam status string 话题状态,不传则两种都要。Enum: active,close
+     * @queryParam severity string 严重程度,逗号分隔多选。Example: error,warning
+     * @queryParam page integer 页码。Default: 1
+     * @queryParam per_page integer 每页数量,最大 200。Default: 15
+     */
+    public function __invoke(IndexTipitakaDiscussionRequest $request, string $channel)
+    {
+        return DiscussionResource::collection($this->discussions->listInChannel(
+            $channel,
+            $request->validated(),
+            $this->currentUserUid($request),
+            $request->integer('per_page', 15),
+        ));
+    }
+}

+ 48 - 0
api-v13/app/Http/Requests/V3/IndexTipitakaDiscussionRequest.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use App\Models\Discussion;
+use Closure;
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
+
+/**
+ * `GET /v3/tipitaka-discussions/{channel}` 的入参。
+ *
+ * **只有 channel 是必填的,而且它在路径上。** 这里全是可选的范围、过滤与分页参数:
+ * 不带 book 就是整个 channel;`book` 只在跟 `chapter` / `para` 一起用时才必填
+ * (与 `tipitaka-reading` 同一口径)。
+ */
+class IndexTipitakaDiscussionRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'book' => ['integer', 'min:1', 'required_with:chapter,para,to'],
+            // 章节起始段落号;服务端按 pali_texts 的 chapter_len 展开成段落区间
+            'chapter' => ['integer', 'min:1', 'prohibits:para,to'],
+            'para' => ['integer', 'min:1'],
+            'to' => ['integer', 'min:1'],
+            // 逗号分隔多选,如 issue,glossary;不传只列话题(评论线程)
+            'type' => ['sometimes', 'string', 'regex:/^[a-z]+(,[a-z]+)*$/', function (string $attribute, mixed $value, Closure $fail) {
+                if (array_diff(explode(',', (string) $value), Discussion::COMMENT_TYPES) !== []) {
+                    $fail(__('validation.in', ['attribute' => $attribute]));
+                }
+            }],
+            'status' => ['sometimes', 'string', Rule::in(Discussion::STATUSES)],
+            // 逗号分隔多选,如 error,warning
+            'severity' => ['sometimes', 'string', 'regex:/^[a-z]+(,[a-z]+)*$/'],
+            'page' => ['integer', 'min:1'],
+            'per_page' => ['integer', 'min:1', 'max:200'],
+        ];
+    }
+}

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

@@ -22,6 +22,9 @@ use Illuminate\Http\Request;
  * **本类不查库。** 编辑者从预加载的 `user` / `aiModel` / `coEditor` 关系上取;权限与正文渲染
  * 由 {@see DiscussionService} 算好挂在模型的 `abilities` / `rendered` 属性上。
  *
+ * `sentence` 只在按经文范围列出时有(`GET /v3/tipitaka-discussions/{channel}`):所在句子的坐标,
+ * 由查询顺带取出(`sentence_*` 属性),本类不另查。
+ *
  * 字段名改了两处(v2 → v3):res_type / res_id → target_type / target_id,
  * parent → parent_id;children_count → reply_count(只有话题有)。
  *
@@ -53,6 +56,7 @@ class DiscussionResource extends BaseResource
      *     quote_prefix: string|null,
      *     quote_suffix: string|null,
      *     reply_count?: int,
+     *     sentence?: array{book: int, paragraph: int, word_start: int, word_end: 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},
@@ -85,6 +89,12 @@ class DiscussionResource extends BaseResource
             '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),
+            'sentence' => $this->when(isset($this->sentence_book), fn () => [
+                'book' => (int) $this->sentence_book,
+                'paragraph' => (int) $this->sentence_paragraph,
+                'word_start' => (int) $this->sentence_word_start,
+                'word_end' => (int) $this->sentence_word_end,
+            ]),
             'editor' => $this->actor($this->user, $this->aiModel),
             // AI 代写时的模型;人类自己写的、存量数据都为 null
             'co_editor' => $this->coEditor ? $this->actor(null, $this->coEditor) : null,

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

@@ -84,6 +84,7 @@ class DiscussionService
     public function __construct(
         private readonly UserService $users,
         private readonly AgentGrantService $grants,
+        private readonly TipitakaReadingService $reading,
     ) {}
 
     /**
@@ -113,6 +114,86 @@ class DiscussionService
         return $page;
     }
 
+    /**
+     * 某个 channel 的一段经文(整个 channel / 一本书 / 一章 / 一段段落区间)里,挂在句子上的话题。
+     *
+     * 与 {@see self::listTopics()} 同一套口径(类型过滤、墓碑、可见性),区别只在范围:那边是
+     * 一个对象,这边是一批句子,所以每条多带所在句子的坐标(`sentence_*` 属性),按经文顺序
+     * (书、段、句起始词)排列,同一句里按时间。
+     *
+     * `discussions.res_id` 是 uuid、`sentences.uid` 是 varchar,pgsql 不会隐式转换,所以关联时
+     * 把 res_id 转成文本;不转 sentences 那边,免得遇到不是 uuid 格式的旧句子 uid 整条查询报错。
+     *
+     * @param  array{book?: int, chapter?: int, para?: int, to?: int, type?: string, status?: string, severity?: string}  $filters
+     * @return LengthAwarePaginator<int, Discussion>
+     */
+    public function listInChannel(string $channel, array $filters, ?string $viewerUid, int $perPage): LengthAwarePaginator
+    {
+        $this->resetCache();
+        $query = Discussion::withTrashed()
+            ->select('discussions.*')
+            ->addSelect([
+                's.book_id as sentence_book',
+                's.paragraph as sentence_paragraph',
+                's.word_start as sentence_word_start',
+                's.word_end as sentence_word_end',
+            ])
+            // 用 join 不用 belongsTo + whereHas:要按句子的 channel / 范围过滤、按经文顺序排序,
+            // 关联做不到排序;而且 whereHas 拼出的 `sentences.uid = discussions.res_id` 没处插
+            // ::text,pgsql 直接报 varchar = uuid。坐标顺带从这里取,不必再预加载一次
+            ->join('sentences as s', fn ($join) => $join->on(DB::raw('discussions.res_id::text'), '=', 's.uid'))
+            ->where('discussions.res_type', 'sentence')
+            ->where('s.channel_uid', $channel)
+            ->whereNull('s.deleted_at')
+            ->whereNull('discussions.parent')
+            // 删了的话题只有还挂着回复时才留下(墓碑),同 topicQuery()
+            ->where(fn ($q) => $q->whereNull('discussions.deleted_at')
+                ->orWhereExists(fn ($sub) => $sub->select(DB::raw(1))
+                    ->from('discussions as r')
+                    ->whereColumn('r.parent', 'discussions.id')
+                    ->whereNull('r.deleted_at')))
+            ->when($filters['status'] ?? null, fn ($q, $value) => $q->where('discussions.status', $value))
+            ->when($filters['severity'] ?? null, fn ($q, $value) => $q->whereIn('discussions.severity', explode(',', $value)))
+            ->with(self::relations())
+            ->withCount('replies as reply_count');
+
+        $types = isset($filters['type']) ? explode(',', $filters['type']) : Discussion::THREAD_TYPES;
+        $query->whereIn('discussions.type', $types);
+
+        if (isset($filters['book'])) {
+            $book = (int) $filters['book'];
+            $query->where('s.book_id', $book);
+            if (isset($filters['chapter'])) {
+                $query->whereBetween('s.paragraph', $this->reading->chapterRange($book, (int) $filters['chapter']));
+            } elseif (isset($filters['para'])) {
+                $from = (int) $filters['para'];
+                $to = (int) ($filters['to'] ?? $from);
+                if ($to < $from) {
+                    throw ValidationException::withMessages(['to' => __('site.invalid_parameter')]);
+                }
+                $query->whereBetween('s.paragraph', [$from, $to]);
+            }
+        }
+
+        // 可见性同 applyVisibility():一个 channel 里的句子管理者是同一批人,按 channel 判一次
+        if ($viewerUid === null) {
+            $query->whereNotIn('discussions.type', self::PRIVATE_TYPES);
+        } elseif ($this->isBasic($viewerUid) && ! in_array($channel, $this->editableChannels($viewerUid), true)) {
+            $query->where(fn ($q) => $q->whereNotIn('discussions.type', self::PRIVATE_TYPES)
+                ->orWhere('discussions.editor_uid', $viewerUid));
+        }
+
+        $page = $query
+            ->orderBy('s.book_id')
+            ->orderBy('s.paragraph')
+            ->orderBy('s.word_start')
+            ->orderBy('discussions.created_at')
+            ->paginate($perPage);
+        $this->decorate($page->getCollection(), $viewerUid);
+
+        return $page;
+    }
+
     /**
      * 某个对象上的话题概况:各状态的话题数(只数没删的、当前用户看得见的),
      * 以及当前用户能在这里发起哪些类型的话题。

+ 7 - 5
api-v13/app/Services/V3/TipitakaReadingService.php

@@ -57,14 +57,14 @@ class TipitakaReadingService
             : min($pageSize, self::MAX_PARAGRAPHS_PER_PAGE);
 
         $candidates = (clone $scope)
-            ->when($filters['after'] ?? null, fn(Builder $q, string $after) => $this->applyCursor($q, $after))
+            ->when($filters['after'] ?? null, fn (Builder $q, string $after) => $this->applyCursor($q, $after))
             ->distinct()
             ->select(['book_id', 'paragraph'])
             ->orderBy('book_id')
             ->orderBy('paragraph')
             ->limit($want + 1)
             ->get()
-            ->map(fn($row) => ['book' => (int) $row->book_id, 'para' => (int) $row->paragraph])
+            ->map(fn ($row) => ['book' => (int) $row->book_id, 'para' => (int) $row->paragraph])
             ->all();
 
         $hasMoreBeyondWant = count($candidates) > $want;
@@ -143,11 +143,13 @@ class TipitakaReadingService
     }
 
     /**
-     * 章节起始段落号 → 段落闭区间,长度取 pali_texts 那一行的 chapter_len。
+     * 章节起始段落号 → 段落闭区间,长度取 pali_texts 那一行的 chapter_len。章节不存在 404。
+     *
+     * 公开给按章节取别的东西的 v3 端点共用(`tipitaka-discussions`),口径一致。
      *
      * @return array{int, int}
      */
-    private function chapterRange(int $book, int $chapter): array
+    public function chapterRange(int $book, int $chapter): array
     {
         $head = PaliText::where('book', $book)->where('paragraph', $chapter)->first();
         if (! $head) {
@@ -240,7 +242,7 @@ class TipitakaReadingService
             return [];
         }
 
-        $countIn = fn(Builder $q): int => (int) (
+        $countIn = fn (Builder $q): int => (int) (
             $q->selectRaw('count(distinct (book_id, paragraph)) as aggregate')->first()?->aggregate ?? 0
         );
 

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

@@ -18,6 +18,7 @@
 |
 */
 
+use App\Http\Controllers\V3\TipitakaDiscussionController;
 use App\Http\Controllers\V3\TipitakaReadingController;
 use App\Http\Controllers\V3\TipitakaRelatedParagraphAggregateController;
 use App\Http\Controllers\V3\TipitakaRelatedParagraphController;
@@ -28,6 +29,11 @@ use Illuminate\Support\Facades\Route;
 Route::get('tipitaka-reading/{channel}', TipitakaReadingController::class)
     ->whereUuid('channel');
 
+// 一段经文里的讨论:同样只有 channel 必填;book / chapter / para / to 与 type 等都是 filter。
+// 与 /v3/discussions(按单个对象查)必填参数不同,是另一个契约。
+Route::get('tipitaka-discussions/{channel}', TipitakaDiscussionController::class)
+    ->whereUuid('channel');
+
 // 段落关联关系:查询(原 v2 related-paragraph)+ 聚合(book_name 列表 / 某 book_name 的 cs_para)。
 Route::get('tipitaka-related-paragraphs/aggregate', [TipitakaRelatedParagraphAggregateController::class, 'index'])
     ->name('tipitaka-related-paragraphs.aggregate');