DiscussionService.php 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977
  1. <?php
  2. namespace App\Services\V3;
  3. use App\Exceptions\BusinessException;
  4. use App\Http\Api\ChannelApi;
  5. use App\Http\Api\MdRender;
  6. use App\Http\Api\Mq;
  7. use App\Http\Api\ShareApi;
  8. use App\Http\Resources\DiscussionResource as MqDiscussionPayload;
  9. use App\Models\Article;
  10. use App\Models\ArticleCollection;
  11. use App\Models\Collection as ArticleCollectionOwner;
  12. use App\Models\DhammaTerm;
  13. use App\Models\Discussion;
  14. use App\Models\Sentence;
  15. use App\Models\Task;
  16. use App\Models\Wbw;
  17. use App\Models\WbwBlock;
  18. use App\Services\UserService;
  19. use Illuminate\Contracts\Pagination\LengthAwarePaginator;
  20. use Illuminate\Database\Eloquent\Builder;
  21. use Illuminate\Support\Collection;
  22. use Illuminate\Support\Facades\DB;
  23. use Illuminate\Validation\ValidationException;
  24. /**
  25. * 评论线程(`/v3/discussions`)的业务逻辑:查询、可见性、权限、写入。
  26. *
  27. * 底层是 `discussions` 表,v2 的 `DiscussionController` 另有一套实现,两边不共用——
  28. * 见 CLAUDE.md 铁律 2。
  29. *
  30. * 线程模型固定两层:话题(parent 为空)+ 一层回复。回复的类型、挂靠对象一律跟随话题,
  31. * 不单独存取;状态、标题、severity 只对话题有意义。
  32. *
  33. * 批注(note / commentary,{@see Discussion::INJECTED_TYPES})也走这里,但它不是评论:
  34. * 锚定在句子的某个位置、注入该句所在 channel 的阅读页(所有读者可见),所以
  35. * - 只能挂在句子上,只有该句的管理者(channel 编辑者)能新建;改、删同评论(作者或管理者);
  36. * - 其他用户可以回复;没有开关状态,不能与评论线程互转;新建批注不发通知;
  37. * - 列表与统计不带 type 时只算评论线程,要批注须显式 type=note / commentary。
  38. *
  39. * glossary(外挂术语)是评论线程(有回复和开关),但像批注一样只能挂在句子上、只有该句的管理者能发起,
  40. * 对所有人公开可见。锚点除批注外,issue 与 glossary 也收({@see Discussion::ANCHORED_TYPES})。
  41. *
  42. * assessment(译文评估)像批注一样:只挂句子、只有该句的管理者能建(AI 凭授权代写)、公开、
  43. * 可回复、没有开关、不发通知、列表不带 type 时不列。一句只留一条,再评估就覆盖
  44. * ({@see self::saveAssessment()});分数在 score,评估方法在 severity。
  45. *
  46. * 写入方(`$actor`)由 {@see AgentGrantService::actor()} 给出:人类直接写,或 AI 模型凭人类
  47. * 授权代写。代写时 editor 是授权人、co_editor 是模型,权限一律按 editor 判,
  48. * 而且只能写授权覆盖的 channel / book 里的东西。
  49. *
  50. * 可见性沿用 v2 `view=question` 的口径,但这里对列表、单条、回复一视同仁(v2 只在
  51. * 列表上做了这个判断):
  52. * - 未登录看不到不公开的话题(discussion);
  53. * - basic 角色在自己无权管理的对象上,只能看到自己发起的不公开话题。
  54. * issue 2026-10-21 起公开:它会注入阅读页(高亮 + 点击看内容),阅读页是公开且共享缓存的。
  55. *
  56. * TODO: 以下两项需要加列,2026-10-15 用户决定暂缓:
  57. * - qa 的「已回答」现在用 status=close 表示;社区通行做法是把某条回复标为答案
  58. * (GitHub Discussions 的 isAnswer),需要加一列(如 answer_id)。
  59. * - 关闭话题不留痕,不知道是谁、什么时候关的。可以学 Google Drive 用带 action 的
  60. * 回复记录,也可以加 closed_by / closed_at 两列。
  61. */
  62. class DiscussionService
  63. {
  64. /** 登录即可发起的话题类型 */
  65. private const COLLABORATION_TYPES = ['discussion', 'issue'];
  66. /** 不公开的话题类型:未登录用户看不到,basic 角色在无权管理的对象上只能看自己的 */
  67. private const PRIVATE_TYPES = ['discussion'];
  68. /** 批注的锚点字段:定位到句子里的哪一段 */
  69. private const ANCHOR_FIELDS = ['pos_start', 'pos_end', 'quote_exact', 'quote_prefix', 'quote_suffix'];
  70. /**
  71. * 一次调用内的权限缓存。每个公开方法进来先清空:Service 随控制器实例存活,
  72. * 控制器实例又会被路由缓存(测试里、常驻进程里都会跨请求复用),不清空就会把
  73. * 上一个请求的权限带进下一个请求。
  74. *
  75. * @var array<string, array<int, string>> 用户 uid → 可编辑的 channel uid 列表
  76. */
  77. private array $editableChannels = [];
  78. /** @var array<string, bool> "用户 uid|对象类型|对象 id" → 是否能管理该对象 */
  79. private array $managers = [];
  80. public function __construct(
  81. private readonly UserService $users,
  82. private readonly AgentGrantService $grants,
  83. private readonly TipitakaReadingService $reading,
  84. ) {}
  85. /**
  86. * 某个对象上的话题列表。删除了但还有回复的话题以墓碑形式出现。
  87. *
  88. * @param array{target_type: string, target_id: string, type?: string, status?: string, severity?: string, sort?: string} $filters
  89. * @return LengthAwarePaginator<int, Discussion>
  90. */
  91. public function listTopics(array $filters, ?string $viewerUid, int $perPage): LengthAwarePaginator
  92. {
  93. $this->resetCache();
  94. $query = $this->topicQuery()
  95. ->where('res_type', $filters['target_type'])
  96. ->where('res_id', $filters['target_id'])
  97. ->tap(fn ($q) => $this->applyTypeFilter($q, $filters['type'] ?? null))
  98. ->when($filters['status'] ?? null, fn ($q, $value) => $q->where('status', $value))
  99. ->when($filters['severity'] ?? null, fn ($q, $value) => $q->whereIn('severity', explode(',', $value)))
  100. ->withCount('replies as reply_count');
  101. $this->applyVisibility($query, $viewerUid, $filters['target_type'], $filters['target_id']);
  102. $sort = $filters['sort'] ?? '-created_at';
  103. $query->orderBy(ltrim($sort, '-'), str_starts_with($sort, '-') ? 'desc' : 'asc');
  104. $page = $query->paginate($perPage);
  105. $this->decorate($page->getCollection(), $viewerUid);
  106. return $page;
  107. }
  108. /**
  109. * 某个 channel 的一段经文(整个 channel / 一本书 / 一章 / 一段段落区间)里,挂在句子上的话题。
  110. *
  111. * 与 {@see self::listTopics()} 同一套口径(类型过滤、墓碑、可见性),区别只在范围:那边是
  112. * 一个对象,这边是一批句子,所以每条多带所在句子的坐标(`sentence_*` 属性),按经文顺序
  113. * (书、段、句起始词)排列,同一句里按时间。
  114. *
  115. * `discussions.res_id` 是 uuid、`sentences.uid` 是 varchar,pgsql 不会隐式转换,所以关联时
  116. * 把 res_id 转成文本;不转 sentences 那边,免得遇到不是 uuid 格式的旧句子 uid 整条查询报错。
  117. *
  118. * @param array{book?: int, chapter?: int, para?: int, to?: int, type?: string, status?: string, severity?: string} $filters
  119. * @return LengthAwarePaginator<int, Discussion>
  120. */
  121. public function listInChannel(string $channel, array $filters, ?string $viewerUid, int $perPage): LengthAwarePaginator
  122. {
  123. $this->resetCache();
  124. $query = Discussion::withTrashed()
  125. ->select('discussions.*')
  126. ->addSelect([
  127. 's.book_id as sentence_book',
  128. 's.paragraph as sentence_paragraph',
  129. 's.word_start as sentence_word_start',
  130. 's.word_end as sentence_word_end',
  131. ])
  132. // 用 join 不用 belongsTo + whereHas:要按句子的 channel / 范围过滤、按经文顺序排序,
  133. // 关联做不到排序;而且 whereHas 拼出的 `sentences.uid = discussions.res_id` 没处插
  134. // ::text,pgsql 直接报 varchar = uuid。坐标顺带从这里取,不必再预加载一次
  135. ->join('sentences as s', fn ($join) => $join->on(DB::raw('discussions.res_id::text'), '=', 's.uid'))
  136. ->where('discussions.res_type', 'sentence')
  137. ->where('s.channel_uid', $channel)
  138. ->whereNull('s.deleted_at')
  139. ->whereNull('discussions.parent')
  140. // 删了的话题只有还挂着回复时才留下(墓碑),同 topicQuery()
  141. ->where(fn ($q) => $q->whereNull('discussions.deleted_at')
  142. ->orWhereExists(fn ($sub) => $sub->select(DB::raw(1))
  143. ->from('discussions as r')
  144. ->whereColumn('r.parent', 'discussions.id')
  145. ->whereNull('r.deleted_at')))
  146. ->when($filters['status'] ?? null, fn ($q, $value) => $q->where('discussions.status', $value))
  147. ->when($filters['severity'] ?? null, fn ($q, $value) => $q->whereIn('discussions.severity', explode(',', $value)))
  148. ->with(self::relations())
  149. ->withCount('replies as reply_count');
  150. $types = isset($filters['type']) ? explode(',', $filters['type']) : Discussion::THREAD_TYPES;
  151. $query->whereIn('discussions.type', $types);
  152. if (isset($filters['book'])) {
  153. $book = (int) $filters['book'];
  154. $query->where('s.book_id', $book);
  155. if (isset($filters['chapter'])) {
  156. $query->whereBetween('s.paragraph', $this->reading->chapterRange($book, (int) $filters['chapter']));
  157. } elseif (isset($filters['para'])) {
  158. $from = (int) $filters['para'];
  159. $to = (int) ($filters['to'] ?? $from);
  160. if ($to < $from) {
  161. throw ValidationException::withMessages(['to' => __('site.invalid_parameter')]);
  162. }
  163. $query->whereBetween('s.paragraph', [$from, $to]);
  164. }
  165. }
  166. // 可见性同 applyVisibility():一个 channel 里的句子管理者是同一批人,按 channel 判一次
  167. if ($viewerUid === null) {
  168. $query->whereNotIn('discussions.type', self::PRIVATE_TYPES);
  169. } elseif ($this->isBasic($viewerUid) && ! in_array($channel, $this->editableChannels($viewerUid), true)) {
  170. $query->where(fn ($q) => $q->whereNotIn('discussions.type', self::PRIVATE_TYPES)
  171. ->orWhere('discussions.editor_uid', $viewerUid));
  172. }
  173. $page = $query
  174. ->orderBy('s.book_id')
  175. ->orderBy('s.paragraph')
  176. ->orderBy('s.word_start')
  177. ->orderBy('discussions.created_at')
  178. ->paginate($perPage);
  179. $this->decorate($page->getCollection(), $viewerUid);
  180. return $page;
  181. }
  182. /**
  183. * 某个对象上的话题概况:各状态的话题数(只数没删的、当前用户看得见的),
  184. * 以及当前用户能在这里发起哪些类型的话题。
  185. *
  186. * @param array{target_type: string, target_id: string, type?: string} $filters
  187. * @return array{status_counts: array{active: int, close: int}, creatable_types: list<string>}
  188. */
  189. public function summary(array $filters, ?string $viewerUid): array
  190. {
  191. $this->resetCache();
  192. $query = Discussion::query()
  193. ->whereNull('parent')
  194. ->whereIn('type', Discussion::COMMENT_TYPES)
  195. ->where('res_type', $filters['target_type'])
  196. ->where('res_id', $filters['target_id'])
  197. ->tap(fn ($q) => $this->applyTypeFilter($q, $filters['type'] ?? null));
  198. $this->applyVisibility($query, $viewerUid, $filters['target_type'], $filters['target_id']);
  199. $counts = $query->toBase()
  200. ->select('status', DB::raw('count(*) as total'))
  201. ->groupBy('status')
  202. ->pluck('total', 'status');
  203. return [
  204. 'status_counts' => [
  205. 'active' => (int) ($counts['active'] ?? 0),
  206. 'close' => (int) ($counts['close'] ?? 0),
  207. ],
  208. 'creatable_types' => $this->creatableTypes($viewerUid, $filters['target_type'], $filters['target_id']),
  209. ];
  210. }
  211. /**
  212. * 一批对象(句子 / 逐词)的讨论角标:未关闭的评论线程数,以及其中查看者发起的数目。
  213. *
  214. * 一条 group by 查完;可见性口径同列表({@see self::applyVisibility()}),basic 角色能管理
  215. * 哪些对象批量算出,不逐个查。按请求顺序返回,没有讨论的给 0。
  216. *
  217. * TODO: 课程迁 v3 之后,补上 v2 课程模式的「学生的讨论」:逐词按坐标(book / paragraph / wid)
  218. * 匹配学员 channel 上同一个词的讨论(v2 `DiscussionCountController@store` 的 course_id 分支,
  219. * 前端 `WbwPaliDiscussionIcon` 的 wbw 参数)。现在只数这个对象本身上的。
  220. *
  221. * @param list<array{type: string, id: string}> $targets
  222. * @return list<array{target_type: string, target_id: string, total: int, mine: int}>
  223. */
  224. public function batchCount(array $targets, ?string $viewerUid): array
  225. {
  226. $this->resetCache();
  227. $idsByType = collect($targets)->groupBy('type')->map(fn ($items) => $items->pluck('id')->unique()->values()->all());
  228. $query = Discussion::query()
  229. ->whereNull('parent')
  230. ->where('status', 'active')
  231. ->whereIn('type', Discussion::THREAD_TYPES)
  232. ->where(function ($q) use ($idsByType) {
  233. foreach ($idsByType as $type => $ids) {
  234. $q->orWhere(fn ($q) => $q->where('res_type', $type)->whereIn('res_id', $ids));
  235. }
  236. });
  237. if ($viewerUid === null) {
  238. $query->whereNotIn('type', self::PRIVATE_TYPES);
  239. } elseif ($this->isBasic($viewerUid)) {
  240. $managed = $this->managedTargets($viewerUid, $idsByType->all());
  241. $query->where(fn ($q) => $q->whereNotIn('type', self::PRIVATE_TYPES)
  242. ->orWhere('editor_uid', $viewerUid)
  243. ->orWhereIn('res_id', $managed));
  244. }
  245. $rows = $query->toBase()
  246. ->select('res_type', 'res_id', DB::raw('count(*) as total'))
  247. ->selectRaw('sum(case when editor_uid = ? then 1 else 0 end) as mine', [$viewerUid])
  248. ->groupBy('res_type', 'res_id')
  249. ->get()
  250. ->keyBy(fn ($row) => $row->res_type.'/'.$row->res_id);
  251. return array_map(function (array $target) use ($rows) {
  252. $row = $rows[$target['type'].'/'.$target['id']] ?? null;
  253. return [
  254. 'target_type' => $target['type'],
  255. 'target_id' => $target['id'],
  256. 'total' => (int) ($row->total ?? 0),
  257. 'mine' => (int) ($row->mine ?? 0),
  258. ];
  259. }, array_values($targets));
  260. }
  261. /**
  262. * 单条(话题或回复)。删除了的回复、删除了且没有回复的话题都当作不存在。
  263. */
  264. public function show(Discussion $discussion, ?string $viewerUid): Discussion
  265. {
  266. $this->resetCache();
  267. $this->assertComment($discussion);
  268. if ($discussion->trashed() && ($discussion->parent !== null || ! $discussion->replies()->exists())) {
  269. abort(404);
  270. }
  271. $topic = $discussion->parent === null ? $discussion : Discussion::withTrashed()->findOrFail($discussion->parent);
  272. $this->assertVisible($topic, $viewerUid);
  273. $discussion->load(self::relations());
  274. if ($discussion->parent === null) {
  275. $discussion->loadCount('replies as reply_count');
  276. } else {
  277. $discussion->type = $topic->type;
  278. }
  279. $this->decorate(collect([$discussion]), $viewerUid);
  280. return $discussion;
  281. }
  282. /**
  283. * 话题下的回复,按时间正序。墓碑话题的回复照样可读。
  284. *
  285. * @return LengthAwarePaginator<int, Discussion>
  286. */
  287. public function listReplies(Discussion $topic, ?string $viewerUid, int $perPage): LengthAwarePaginator
  288. {
  289. $this->resetCache();
  290. $this->assertComment($topic);
  291. if ($topic->parent !== null) {
  292. abort(404);
  293. }
  294. $this->assertVisible($topic, $viewerUid);
  295. $page = Discussion::query()
  296. ->where('parent', $topic->id)
  297. ->with(self::relations())
  298. ->orderBy('created_at')
  299. ->paginate($perPage);
  300. // 回复的类型一律跟随话题:历史数据里有 qa 话题下 type=discussion 的回复
  301. $page->getCollection()->each(fn (Discussion $reply) => $reply->type = $topic->type);
  302. $this->decorate($page->getCollection(), $viewerUid);
  303. return $page;
  304. }
  305. /**
  306. * 发起话题,或新建一条批注 / 评估(批注与评估不发通知)。
  307. *
  308. * 评估(assessment)一句只留一条:该句已有评估就就地覆盖(分数、方法、理由、标题、署名),
  309. * 回复保留,返回 200;没有才新建,返回 201。
  310. *
  311. * @param array{editor: string, co_editor: string|null, scope: array{channel: string, book: int}|null} $actor
  312. * @param array{target_type: string, target_id: string, type?: string, title?: string|null, content?: string|null, severity?: string|null, score?: int, 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
  313. */
  314. public function createTopic(array $actor, array $data): Discussion
  315. {
  316. $this->resetCache();
  317. $userUid = $actor['editor'];
  318. if (! $this->targetExists($data['target_type'], $data['target_id'])) {
  319. throw ValidationException::withMessages(['target_id' => __('messages.discussion_target_not_found')]);
  320. }
  321. $this->assertGrantCovers($actor, $data['target_type'], $data['target_id']);
  322. $type = $data['type'] ?? 'discussion';
  323. if (! in_array($type, $this->creatableTypes($userUid, $data['target_type'], $data['target_id']), true)) {
  324. abort(403, __('messages.discussion_forbidden'));
  325. }
  326. if ($type === Discussion::TYPE_ASSESSMENT) {
  327. return $this->fresh($this->saveAssessment($actor, $data), $userUid);
  328. }
  329. $topic = new Discussion;
  330. $topic->res_type = $data['target_type'];
  331. $topic->res_id = $data['target_id'];
  332. $topic->type = $type;
  333. $topic->title = $data['title'] ?? null;
  334. $topic->content = $data['content'] ?? null;
  335. $topic->content_type = 'markdown';
  336. $topic->severity = $data['severity'] ?? null;
  337. $topic->tpl_id = $data['tpl_id'] ?? null;
  338. $topic->fill(array_intersect_key($data, array_flip(self::ANCHOR_FIELDS)));
  339. $topic->editor_uid = $userUid;
  340. $topic->co_editor_uid = $actor['co_editor'];
  341. $topic->save();
  342. if (! $this->isAnnotation($topic) && ($data['notify'] ?? true)) {
  343. $this->notify($topic);
  344. }
  345. return $this->fresh($topic, $userUid);
  346. }
  347. /**
  348. * 新建或覆盖一句的评估。锁住句子行,免得并发评估同一句时各建一条。
  349. * 覆盖时署名换成这次评估的人(及代写的模型),created_at 不变。
  350. *
  351. * @param array{editor: string, co_editor: string|null, scope: array{channel: string, book: int}|null} $actor
  352. * @param array{target_id: string, title?: string|null, content?: string|null, severity?: string|null, score?: int, tpl_id?: string|null} $data
  353. */
  354. private function saveAssessment(array $actor, array $data): Discussion
  355. {
  356. return DB::transaction(function () use ($actor, $data) {
  357. Sentence::where('uid', $data['target_id'])->lockForUpdate()->first(['uid']);
  358. $assessment = Discussion::where('res_type', 'sentence')
  359. ->where('res_id', $data['target_id'])
  360. ->where('type', Discussion::TYPE_ASSESSMENT)
  361. ->whereNull('parent')
  362. ->latest('updated_at')
  363. ->first() ?? new Discussion;
  364. $assessment->res_type = 'sentence';
  365. $assessment->res_id = $data['target_id'];
  366. $assessment->type = Discussion::TYPE_ASSESSMENT;
  367. $assessment->title = $data['title'] ?? null;
  368. $assessment->content = $data['content'] ?? null;
  369. $assessment->content_type = 'markdown';
  370. $assessment->severity = $data['severity'];
  371. $assessment->score = (int) $data['score'];
  372. $assessment->tpl_id = $data['tpl_id'] ?? null;
  373. $assessment->editor_uid = $actor['editor'];
  374. $assessment->co_editor_uid = $actor['co_editor'];
  375. $assessment->save();
  376. return $assessment;
  377. });
  378. }
  379. /**
  380. * 回复话题。话题必须没关闭;不能回复一条回复。
  381. *
  382. * @param array{editor: string, co_editor: string|null, scope: array{channel: string, book: int}|null} $actor
  383. * @param array{content: string, notify?: bool} $data
  384. */
  385. public function createReply(Discussion $topic, array $actor, array $data): Discussion
  386. {
  387. $this->resetCache();
  388. $userUid = $actor['editor'];
  389. $this->assertComment($topic);
  390. if ($topic->parent !== null) {
  391. throw ValidationException::withMessages(['discussion' => __('messages.discussion_reply_to_reply')]);
  392. }
  393. $this->assertVisible($topic, $userUid);
  394. $this->assertGrantCovers($actor, $topic->res_type, $topic->res_id);
  395. if ($topic->status === 'close') {
  396. throw new BusinessException(__('messages.discussion_closed'), 409, 'discussion-closed');
  397. }
  398. if (! $this->canReply($userUid, $topic)) {
  399. abort(403, __('messages.discussion_forbidden'));
  400. }
  401. $reply = DB::transaction(function () use ($topic, $userUid, $actor, $data) {
  402. $reply = new Discussion;
  403. $reply->res_type = $topic->res_type;
  404. $reply->res_id = $topic->res_id;
  405. $reply->type = $topic->type;
  406. $reply->parent = $topic->id;
  407. $reply->content = $data['content'];
  408. $reply->content_type = 'markdown';
  409. $reply->editor_uid = $userUid;
  410. $reply->co_editor_uid = $actor['co_editor'];
  411. $reply->save();
  412. // children_count 列只有 v2 的 sent-discussion-tree 在读,v3 现算;这里只是让它别漂移
  413. $topic->increment('children_count');
  414. return $reply;
  415. });
  416. if ($data['notify'] ?? true) {
  417. $this->notify($reply);
  418. }
  419. return $this->fresh($reply, $userUid);
  420. }
  421. /**
  422. * 局部更新。title / status / type / severity 只对话题有效。
  423. *
  424. * 改 type 时回复跟着改;新类型不收原来的 severity 时,severity 清空。锚点不清:
  425. * 留着不显示也不影响校验,改回可带锚点的类型时位置自动恢复。
  426. * 改成 glossary 须挂在句子上、且当前用户是该句的管理者(同新建)。
  427. * 批注:锚点可改,没有 status,type 只能在 note / commentary 之间改,
  428. * 正文不能清空,commentary 的正文必须是义注句子模板(按改后的 type 与正文判)。
  429. * 评估:可改 score、severity(评估方法,不能清空)、标题、正文;没有 status,不能与其它类型互转。
  430. *
  431. * 改了内容(标题、正文、锚点)时 co_editor 改成这次写入的协作者:人亲自改则清空,
  432. * AI 代改则换成该模型。只改 status / type / severity 不动 co_editor(评估的 score 算内容)。
  433. *
  434. * @param array{editor: string, co_editor: string|null, scope: array{channel: string, book: int}|null} $actor
  435. * @param array{title?: string, content?: string|null, status?: string, type?: string, severity?: string|null, score?: int, pos_start?: int|null, pos_end?: int|null, quote_exact?: string|null, quote_prefix?: string|null, quote_suffix?: string|null} $data
  436. */
  437. public function update(Discussion $discussion, array $actor, array $data): Discussion
  438. {
  439. $this->resetCache();
  440. $userUid = $actor['editor'];
  441. $this->assertComment($discussion);
  442. $this->assertGrantCovers($actor, $discussion->res_type, $discussion->res_id);
  443. $abilities = $this->abilities($discussion, $userUid);
  444. if (! $abilities['update']) {
  445. abort(403, __('messages.discussion_forbidden'));
  446. }
  447. $isTopic = $discussion->parent === null;
  448. $topicOnly = array_intersect_key($data, array_flip(['title', 'status', 'type', 'severity', 'score']));
  449. if (! $isTopic && $topicOnly !== []) {
  450. throw ValidationException::withMessages(
  451. array_fill_keys(array_keys($topicOnly), __('messages.discussion_topic_only'))
  452. );
  453. }
  454. $this->assertAnnotationUpdate($discussion, $data);
  455. if (array_key_exists('status', $data) && ! $abilities['close']) {
  456. abort(403, __('messages.discussion_forbidden'));
  457. }
  458. $type = $data['type'] ?? $discussion->type;
  459. if ($type === Discussion::TYPE_GLOSSARY && $discussion->type !== Discussion::TYPE_GLOSSARY) {
  460. $this->assertCanBecomeGlossary($discussion, $userUid);
  461. }
  462. if ($type === Discussion::TYPE_ASSESSMENT) {
  463. // 评估的 severity 是评估方法:不能清空,格式见 ASSESSMENT_METHOD_PATTERN
  464. if (array_key_exists('severity', $data)
  465. && ! preg_match(Discussion::ASSESSMENT_METHOD_PATTERN, (string) $data['severity'])) {
  466. throw ValidationException::withMessages(['severity' => __('messages.discussion_assessment_method')]);
  467. }
  468. } elseif (array_key_exists('severity', $data)) {
  469. if ($data['severity'] !== null && ! in_array($data['severity'], Discussion::SEVERITIES[$type] ?? [], true)) {
  470. throw ValidationException::withMessages(['severity' => __('messages.discussion_severity_not_allowed')]);
  471. }
  472. } elseif ($discussion->severity !== null && ! in_array($discussion->severity, Discussion::SEVERITIES[$type] ?? [], true)) {
  473. $data['severity'] = null;
  474. }
  475. DB::transaction(function () use ($discussion, $data, $actor) {
  476. $typeChanged = array_key_exists('type', $data) && $data['type'] !== $discussion->type;
  477. $authored = array_intersect_key($data, array_flip(['title', 'content', 'score', ...self::ANCHOR_FIELDS]));
  478. $discussion->fill($authored);
  479. // 改了内容,co_editor 就跟着这次写的人走:人亲自改 → 清空,AI 代改 → 换成这个模型。
  480. // 只改状态 / 类型 / severity 是管理动作,不动内容的署名。
  481. if ($authored !== []) {
  482. $discussion->co_editor_uid = $actor['co_editor'];
  483. }
  484. foreach (['status', 'type', 'severity'] as $field) {
  485. if (array_key_exists($field, $data)) {
  486. $discussion->{$field} = $data[$field];
  487. }
  488. }
  489. $discussion->save();
  490. if ($typeChanged) {
  491. Discussion::where('parent', $discussion->id)->update(['type' => $discussion->type]);
  492. }
  493. });
  494. return $this->fresh($discussion, $userUid);
  495. }
  496. /**
  497. * 软删除。作者本人或挂靠对象的管理者能删(v2 只允许作者)。
  498. *
  499. * 删话题不动回复:有回复的话题以墓碑形式继续出现在列表里;删回复会让话题的
  500. * children_count 减一。返回删除后的资源(AIP-164:软删的资源没有消失)。
  501. *
  502. * @param array{editor: string, co_editor: string|null, scope: array{channel: string, book: int}|null} $actor
  503. */
  504. public function delete(Discussion $discussion, array $actor): Discussion
  505. {
  506. $this->resetCache();
  507. $userUid = $actor['editor'];
  508. $this->assertComment($discussion);
  509. $this->assertGrantCovers($actor, $discussion->res_type, $discussion->res_id);
  510. if (! $this->abilities($discussion, $userUid)['delete']) {
  511. abort(403, __('messages.discussion_forbidden'));
  512. }
  513. DB::transaction(function () use ($discussion) {
  514. $discussion->delete();
  515. if ($discussion->parent !== null) {
  516. Discussion::where('id', $discussion->parent)
  517. ->where('children_count', '>', 0)
  518. ->decrement('children_count');
  519. }
  520. });
  521. return $this->fresh($discussion, $userUid);
  522. }
  523. /**
  524. * 编辑者与协作者的预加载声明。
  525. *
  526. * @return array<int, string>
  527. */
  528. private static function relations(): array
  529. {
  530. return [...UserService::eagerLoadActor(), UserService::eagerLoadAssistant('coEditor')];
  531. }
  532. /**
  533. * AI 代写时,授权必须覆盖这次写入落到的 channel / book;只有句子能定位到 channel,
  534. * 挂在别的对象上的一律不覆盖。人类直接写不受限。
  535. *
  536. * @param array{editor: string, co_editor: string|null, scope: array{channel: string, book: int}|null} $actor
  537. */
  538. private function assertGrantCovers(array $actor, string $targetType, string $targetId): void
  539. {
  540. if ($actor['scope'] === null) {
  541. return;
  542. }
  543. $sentence = $targetType === 'sentence'
  544. ? Sentence::where('uid', $targetId)->first(['channel_uid', 'book_id'])
  545. : null;
  546. if (! $this->grants->covers($actor['scope'], $sentence?->channel_uid, $sentence ? (int) $sentence->book_id : null)) {
  547. abort(403, __('messages.agent_grant_scope'));
  548. }
  549. }
  550. /**
  551. * 这批对象里查看者能管理的(所在 channel 可编辑),一次查出,供 basic 角色的可见性用。
  552. *
  553. * @param array<string, list<string>> $idsByType
  554. * @return list<string>
  555. */
  556. private function managedTargets(string $userUid, array $idsByType): array
  557. {
  558. $editable = $this->editableChannels($userUid);
  559. $managed = [];
  560. if (! empty($idsByType['sentence'])) {
  561. array_push($managed, ...Sentence::whereIn('uid', $idsByType['sentence'])
  562. ->whereIn('channel_uid', $editable)->pluck('uid')->all());
  563. }
  564. if (! empty($idsByType['wbw'])) {
  565. $blocks = WbwBlock::whereIn('channel_uid', $editable)->select('uid');
  566. array_push($managed, ...Wbw::whereIn('uid', $idsByType['wbw'])
  567. ->whereIn('block_uid', $blocks)->pluck('uid')->all());
  568. }
  569. return array_map('strval', $managed);
  570. }
  571. private function isAnnotation(Discussion $discussion): bool
  572. {
  573. return in_array($discussion->type, Discussion::INJECTED_TYPES, true);
  574. }
  575. /**
  576. * 列表 / 统计的类型过滤:传了就按传的,没传只算评论线程(批注要显式点名)。
  577. *
  578. * @param Builder<Discussion> $query
  579. */
  580. private function applyTypeFilter(Builder $query, ?string $type): void
  581. {
  582. $type === null
  583. ? $query->whereIn('type', Discussion::THREAD_TYPES)
  584. : $query->where('type', $type);
  585. }
  586. /**
  587. * 批注、评估与评论线程在 PATCH 上的差异,见 {@see self::update()}。
  588. *
  589. * @param array<string, mixed> $data
  590. */
  591. private function assertAnnotationUpdate(Discussion $discussion, array $data): void
  592. {
  593. $isAnnotation = $this->isAnnotation($discussion);
  594. $isAssessment = $discussion->type === Discussion::TYPE_ASSESSMENT;
  595. $errors = [];
  596. if (array_key_exists('type', $data)
  597. && ($data['type'] === Discussion::TYPE_ASSESSMENT) !== $isAssessment) {
  598. $errors['type'] = __('messages.discussion_assessment_type');
  599. } elseif (array_key_exists('type', $data)
  600. && in_array($data['type'], Discussion::INJECTED_TYPES, true) !== $isAnnotation) {
  601. $errors['type'] = __('messages.discussion_type_group');
  602. }
  603. if (array_key_exists('score', $data) && ! $isAssessment) {
  604. $errors['score'] = __('messages.discussion_score_not_allowed');
  605. }
  606. if ($isAssessment && array_key_exists('status', $data)) {
  607. $errors['status'] = __('messages.discussion_assessment_no_status');
  608. }
  609. if (! in_array($data['type'] ?? $discussion->type, Discussion::ANCHORED_TYPES, true)) {
  610. foreach (array_intersect(self::ANCHOR_FIELDS, array_keys($data)) as $field) {
  611. $errors[$field] = __('messages.discussion_anchor_thread');
  612. }
  613. }
  614. if ($isAnnotation) {
  615. if (array_key_exists('status', $data)) {
  616. $errors['status'] = __('messages.discussion_annotation_no_status');
  617. }
  618. $type = $data['type'] ?? $discussion->type;
  619. $content = trim((string) (array_key_exists('content', $data) ? $data['content'] : $discussion->content));
  620. if ($content === '') {
  621. $errors['content'] = __('validation.required', ['attribute' => 'content']);
  622. } elseif ($type === Discussion::TYPE_COMMENTARY && ! preg_match(Discussion::COMMENTARY_PATTERN, $content)) {
  623. $errors['content'] = __('messages.discussion_commentary_format');
  624. }
  625. }
  626. if ($errors !== []) {
  627. throw ValidationException::withMessages($errors);
  628. }
  629. }
  630. /**
  631. * 话题改成 glossary:与新建同口径,只能挂在句子上、只有该句的管理者能改。
  632. */
  633. private function assertCanBecomeGlossary(Discussion $discussion, string $userUid): void
  634. {
  635. if ($discussion->res_type !== 'sentence') {
  636. throw ValidationException::withMessages(['type' => __('messages.discussion_glossary_target')]);
  637. }
  638. if (! $this->canManageTarget($userUid, $discussion->res_type, $discussion->res_id)) {
  639. abort(403, __('messages.discussion_forbidden'));
  640. }
  641. }
  642. private function resetCache(): void
  643. {
  644. $this->editableChannels = [];
  645. $this->managers = [];
  646. }
  647. /**
  648. * 话题的基础查询:只要评论类型、只要顶级;删了的话题只有还挂着回复时才留下(墓碑)。
  649. *
  650. * @return Builder<Discussion>
  651. */
  652. private function topicQuery(): Builder
  653. {
  654. return Discussion::withTrashed()
  655. ->whereNull('parent')
  656. ->whereIn('type', Discussion::COMMENT_TYPES)
  657. ->where(fn ($q) => $q->whereNull('deleted_at')->orWhereHas('replies'))
  658. ->with(self::relations());
  659. }
  660. /**
  661. * 列表上的可见性过滤。口径见类注释。
  662. *
  663. * @param Builder<Discussion> $query
  664. */
  665. private function applyVisibility(Builder $query, ?string $viewerUid, string $targetType, string $targetId): void
  666. {
  667. if ($viewerUid === null) {
  668. $query->whereNotIn('type', self::PRIVATE_TYPES);
  669. return;
  670. }
  671. if ($this->isBasic($viewerUid) && ! $this->canManageTarget($viewerUid, $targetType, $targetId)) {
  672. $query->where(fn ($q) => $q->whereNotIn('type', self::PRIVATE_TYPES)
  673. ->orWhere('editor_uid', $viewerUid));
  674. }
  675. }
  676. /**
  677. * 单条话题的可见性。看不见就当作不存在(404),不暴露它的存在。
  678. */
  679. private function assertVisible(Discussion $topic, ?string $viewerUid): void
  680. {
  681. if (! in_array($topic->type, self::PRIVATE_TYPES, true)) {
  682. return;
  683. }
  684. if ($viewerUid === null) {
  685. abort(404);
  686. }
  687. if ($topic->editor_uid !== $viewerUid
  688. && $this->isBasic($viewerUid)
  689. && ! $this->canManageTarget($viewerUid, $topic->res_type, $topic->res_id)) {
  690. abort(404);
  691. }
  692. }
  693. /**
  694. * 评论线程与批注以外的记录(如任务状态日志)不归 v3 discussions 管。
  695. */
  696. private function assertComment(Discussion $discussion): void
  697. {
  698. if (! in_array($discussion->type, Discussion::COMMENT_TYPES, true)) {
  699. abort(404);
  700. }
  701. }
  702. /**
  703. * 当前用户能在这个对象上发起哪些类型的话题。
  704. *
  705. * 协作类(discussion / issue)登录即可;qa / help 沿用 v2 `can_create` 的口径:
  706. * 只能发在文章上,而且只有文章的编辑者能发;批注(note / commentary)、glossary 与 assessment 只能加在
  707. * 句子上,而且只有该句的管理者能加(v2 登录即可,v3 收紧)。
  708. *
  709. * @return list<string>
  710. */
  711. private function creatableTypes(?string $userUid, string $targetType, string $targetId): array
  712. {
  713. if ($userUid === null) {
  714. return [];
  715. }
  716. $types = self::COLLABORATION_TYPES;
  717. if ($targetType === 'article' && $this->canManageTarget($userUid, $targetType, $targetId)) {
  718. array_push($types, 'qa', 'help');
  719. }
  720. // 批注与评估注入阅读页、所有读者可见,只有该句的管理者能加
  721. if ($targetType === 'sentence' && $this->canManageTarget($userUid, $targetType, $targetId)) {
  722. array_push($types, Discussion::TYPE_GLOSSARY, Discussion::TYPE_NOTE, Discussion::TYPE_COMMENTARY, Discussion::TYPE_ASSESSMENT);
  723. }
  724. return $types;
  725. }
  726. /**
  727. * 当前用户对一条记录能做的动作。前端按它决定显示哪些菜单,后端按它放行。
  728. *
  729. * @return array{update: bool, delete: bool, close: bool, reply: bool}
  730. */
  731. private function abilities(Discussion $discussion, ?string $viewerUid): array
  732. {
  733. $none = ['update' => false, 'delete' => false, 'close' => false, 'reply' => false];
  734. if ($viewerUid === null || $discussion->trashed()) {
  735. return $none;
  736. }
  737. $isAuthor = $discussion->editor_uid === $viewerUid;
  738. $canManage = $isAuthor || $this->canManageTarget($viewerUid, $discussion->res_type, $discussion->res_id);
  739. $isTopic = $discussion->parent === null;
  740. if (in_array($discussion->type, Discussion::STATELESS_TYPES, true)) {
  741. // 批注与评估没有开关;回复与评论线程一样,登录即可
  742. return [
  743. 'update' => $canManage,
  744. 'delete' => $canManage,
  745. 'close' => false,
  746. 'reply' => $isTopic && $this->canReply($viewerUid, $discussion),
  747. ];
  748. }
  749. return [
  750. 'update' => $canManage,
  751. // 作者,或挂靠对象的管理者(审核别人的评论)。v2 只允许作者删,v3 放开:
  752. // 删除是软删,删错了可以恢复
  753. 'delete' => $canManage,
  754. 'close' => $isTopic && $canManage,
  755. 'reply' => $isTopic && $discussion->status === 'active' && $this->canReply($viewerUid, $discussion),
  756. ];
  757. }
  758. /**
  759. * 能否回复:登录即可;文章上的 qa 只有文章编辑者能答(沿用 v2 `can_reply` 的口径)。
  760. */
  761. private function canReply(?string $userUid, Discussion $topic): bool
  762. {
  763. if ($userUid === null) {
  764. return false;
  765. }
  766. if ($topic->type === 'qa' && $topic->res_type === 'article') {
  767. return $this->canManageTarget($userUid, 'article', $topic->res_id);
  768. }
  769. return true;
  770. }
  771. /**
  772. * 能否管理挂靠的对象:句子与逐词看所在 channel 的编辑权,文章看文章的编辑权,
  773. * 其它对象(术语、任务)没有「管理者」这一说,只有作者本人。
  774. */
  775. private function canManageTarget(string $userUid, string $targetType, string $targetId): bool
  776. {
  777. $key = "{$userUid}|{$targetType}|{$targetId}";
  778. if (array_key_exists($key, $this->managers)) {
  779. return $this->managers[$key];
  780. }
  781. $result = match ($targetType) {
  782. 'sentence', 'wbw' => ($channel = $this->channelOf($targetType, $targetId)) !== null
  783. && in_array($channel, $this->editableChannels($userUid), true),
  784. 'article' => $this->canEditArticle($userUid, $targetId),
  785. default => false,
  786. };
  787. return $this->managers[$key] = $result;
  788. }
  789. /**
  790. * 句子 / 逐词所在的 channel。
  791. */
  792. private function channelOf(string $targetType, string $targetId): ?string
  793. {
  794. if ($targetType === 'sentence') {
  795. return Sentence::where('uid', $targetId)->value('channel_uid');
  796. }
  797. $blockUid = Wbw::where('uid', $targetId)->value('block_uid');
  798. return $blockUid ? WbwBlock::where('uid', $blockUid)->value('channel_uid') : null;
  799. }
  800. /**
  801. * @return array<int, string>
  802. */
  803. private function editableChannels(string $userUid): array
  804. {
  805. return $this->editableChannels[$userUid] ??= ChannelApi::getCanEditByUser($userUid);
  806. }
  807. /**
  808. * 文章编辑权:所有者、文章协作者(power ≥ 20),或文章所在的同主人文集的协作者。
  809. * 与 v2 `ArticleController::userCanEdit()` 同口径,v3 不去依赖 v2 的控制器。
  810. */
  811. private function canEditArticle(string $userUid, string $articleUid): bool
  812. {
  813. $article = Article::where('uid', $articleUid)->first(['uid', 'owner']);
  814. if (! $article) {
  815. return false;
  816. }
  817. if ($article->owner === $userUid || ShareApi::getResPower($userUid, $article->uid) >= 20) {
  818. return true;
  819. }
  820. $collections = ArticleCollectionOwner::whereIn(
  821. 'uid',
  822. ArticleCollection::where('article_id', $article->uid)->select('collect_id')
  823. )->where('owner', $article->owner)->pluck('uid');
  824. return $collections->contains(fn ($uid) => ShareApi::getResPower($userUid, $uid) >= 20);
  825. }
  826. private function isBasic(string $userUid): bool
  827. {
  828. return in_array('basic', (array) ($this->users->byUuid($userUid)['roles'] ?? []), true);
  829. }
  830. private function targetExists(string $targetType, string $targetId): bool
  831. {
  832. return match ($targetType) {
  833. 'sentence' => Sentence::where('uid', $targetId)->exists(),
  834. 'wbw' => Wbw::where('uid', $targetId)->exists(),
  835. 'article' => Article::where('uid', $targetId)->exists(),
  836. 'term' => DhammaTerm::where('guid', $targetId)->exists(),
  837. 'task' => Task::where('id', $targetId)->exists(),
  838. default => false,
  839. };
  840. }
  841. /**
  842. * 写操作之后重新读出完整资源(含墓碑),挂好关系与权限。
  843. *
  844. * 保留 wasRecentlyCreated:Laravel 的 ResourceResponse 据此把新建的响应设为 201,
  845. * 控制器不用手写状态码。
  846. */
  847. private function fresh(Discussion $discussion, string $userUid): Discussion
  848. {
  849. $fresh = Discussion::withTrashed()->with(self::relations())->findOrFail($discussion->id);
  850. $fresh->wasRecentlyCreated = $discussion->wasRecentlyCreated;
  851. if ($fresh->parent === null) {
  852. $fresh->loadCount('replies as reply_count');
  853. }
  854. $this->decorate(collect([$fresh]), $userUid);
  855. return $fresh;
  856. }
  857. /**
  858. * 给一批记录挂上权限与正文渲染结果。
  859. *
  860. * 只有挂在句子上的才渲染:正文里的模板要用句子所在的 channel 去解析。
  861. * channel 一次批量查出,不逐行查。
  862. *
  863. * @param Collection<int, Discussion> $items
  864. */
  865. private function decorate(Collection $items, ?string $viewerUid): void
  866. {
  867. $sentenceIds = $items->where('res_type', 'sentence')->pluck('res_id')->unique()->values();
  868. $channels = $sentenceIds->isEmpty()
  869. ? collect()
  870. : Sentence::whereIn('uid', $sentenceIds)->pluck('channel_uid', 'uid');
  871. foreach ($items as $item) {
  872. $item->abilities = $this->abilities($item, $viewerUid);
  873. $channel = $item->res_type === 'sentence' ? ($channels[$item->res_id] ?? null) : null;
  874. if ($channel && ! $item->trashed() && $item->content) {
  875. $item->rendered = [
  876. 'html' => MdRender::render($item->content, [$channel], null, 'read'),
  877. 'summary' => MdRender::render($item->content, [$channel], null, 'read', 'translation', 'markdown', 'text'),
  878. ];
  879. }
  880. }
  881. }
  882. /**
  883. * 推给 mq:discussion 工作进程(站内信 + webhook)。
  884. *
  885. * 载荷沿用 v2 `DiscussionResource` 的形状:那是工作进程读的消息契约
  886. * (读 editor.nickName、parent、res_type…),不是 v2 的 HTTP 契约。
  887. */
  888. private function notify(Discussion $discussion): void
  889. {
  890. Mq::publish('discussion', new MqDiscussionPayload($discussion));
  891. }
  892. }