Discussion.php 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. <?php
  2. namespace App\Models;
  3. use App\Services\PaliContentService;
  4. use Illuminate\Database\Eloquent\Factories\HasFactory;
  5. use Illuminate\Database\Eloquent\Model;
  6. use Illuminate\Database\Eloquent\Relations\BelongsTo;
  7. use Illuminate\Database\Eloquent\Relations\HasMany;
  8. use Illuminate\Database\Eloquent\SoftDeletes;
  9. /**
  10. * 表 `discussions`。
  11. *
  12. * 这一张表装着几种东西:评论线程(discussion / qa / help / issue)、阅读页批注
  13. * (note / commentary,会被注入阅读页),以及 v2 TaskStatusController 写进来的任务
  14. * 状态日志(res_type=task)。v3 的 `/v3/discussions` 管前两种({@see self::COMMENT_TYPES})。
  15. *
  16. * 2026-10-15 起软删除:删除话题时这一行留作「墓碑」,回复不受影响。
  17. */
  18. class Discussion extends Model
  19. {
  20. use HasFactory;
  21. use SoftDeletes;
  22. protected $primaryKey = 'id';
  23. /**
  24. * 主键是 uuid,由库的默认值生成(所以 incrementing 仍为 true:靠 insert … returning id
  25. * 拿回主键)。keyType 必须声明为 string:默认的 int 会让 whereIn 走 whereIntegerInRaw,
  26. * 把 uuid 转成整数(loadCount / 预加载时出现 `id in (2)`)。
  27. */
  28. protected $keyType = 'string';
  29. protected $casts = [
  30. 'id' => 'string',
  31. 'pos_start' => 'integer',
  32. 'pos_end' => 'integer',
  33. 'score' => 'integer',
  34. ];
  35. // 批量填充
  36. protected $fillable = [
  37. 'res_id',
  38. 'res_type',
  39. 'score',
  40. 'type',
  41. 'tpl_id',
  42. 'title',
  43. 'content',
  44. 'content_type',
  45. 'parent',
  46. 'editor_uid',
  47. 'pos_start',
  48. 'pos_end',
  49. 'quote_exact',
  50. 'quote_prefix',
  51. 'quote_suffix',
  52. ];
  53. /**
  54. * 义注 / 复注对应(巴利注释书,不是普通评论):content 是注释书的句子模板
  55. * `{{book-para-start-end}}`(可多句并列),渲染成那几句的译文并带 <cite> 出处。
  56. */
  57. public const TYPE_COMMENTARY = 'commentary';
  58. /** commentary 的 content 格式:一个或多个句子模板(与 PaliContentService 注入时的判定一致) */
  59. public const COMMENTARY_PATTERN = '/^(?:\{\{\d+-\d+-\d+-\d+\}\}\s*)+$/';
  60. /** 普通边注:content 就是注解正文,没有出处 */
  61. public const TYPE_NOTE = 'note';
  62. /** 批注类型:作为边注注入阅读页,没有回复以外的线程属性(开关、severity) */
  63. public const INJECTED_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE];
  64. /**
  65. * 外挂术语:不改译文原文,给锚点处的一段译文挂上术语(title 是术语词头),阅读页把那段
  66. * 原文渲染成 `[[title#原文]]`。带锚点、只能挂在句子上。与术语表(dhamma terms)是两回事。
  67. */
  68. public const TYPE_GLOSSARY = 'glossary';
  69. /**
  70. * 译文评估:用某种方法给一句译文打分。score 是归一到 0–100 的整数分数,severity 存评估方法
  71. * ({@see self::ASSESSMENT_METHOD_PATTERN}),content 写理由,title 可省。
  72. * 只能挂在句子上、只有该句的管理者能建(AI 凭授权代写);一句只留一条,重新评估覆盖旧的。
  73. * 没有开关状态、不能与其它类型互转、不带锚点;注入阅读页,在句尾显示按分数着色的对勾。
  74. */
  75. public const TYPE_ASSESSMENT = 'assessment';
  76. /** 评估方法(存在 severity 列,varchar(16)):字母数字开头,可含 . _ - */
  77. public const ASSESSMENT_METHOD_PATTERN = '/^[A-Za-z0-9][A-Za-z0-9._-]{0,15}$/';
  78. /** 没有开关状态的类型:批注 + 评估 */
  79. public const STATELESS_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE, self::TYPE_ASSESSMENT];
  80. /** 评论线程:话题 + 一层回复,有开关状态 */
  81. public const THREAD_TYPES = ['discussion', 'qa', 'help', 'issue', self::TYPE_GLOSSARY];
  82. /** 可以带锚点(pos_* / quote_*)、定位到句子文本某一段的类型:批注 + issue / glossary */
  83. public const ANCHORED_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE, 'issue', self::TYPE_GLOSSARY];
  84. /**
  85. * 会被注入阅读页的全部类型({@see PaliContentService::injectAnnotationNotes()}):
  86. * 批注插边注,glossary 换成术语,issue 高亮,assessment 句尾对勾。issue / glossary 只注入未关闭的。
  87. */
  88. public const READING_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE, 'issue', self::TYPE_GLOSSARY, self::TYPE_ASSESSMENT];
  89. /** 只能挂在句子上的类型:批注 + glossary + assessment */
  90. public const SENTENCE_ONLY_TYPES = [self::TYPE_COMMENTARY, self::TYPE_NOTE, self::TYPE_GLOSSARY, self::TYPE_ASSESSMENT];
  91. /**
  92. * `/v3/discussions` 管的全部类型:评论线程({@see self::THREAD_TYPES})+ 批注
  93. * ({@see self::INJECTED_TYPES})+ 评估({@see self::TYPE_ASSESSMENT})。
  94. * 批注与评估都注入阅读页,可以回复,没有开关。
  95. */
  96. public const COMMENT_TYPES = ['discussion', 'qa', 'help', 'issue', 'glossary', 'note', 'commentary', 'assessment'];
  97. /**
  98. * 各类型允许的 severity 取值;不在表里的类型不允许设 severity。
  99. * assessment 例外:severity 存评估方法,按 {@see self::ASSESSMENT_METHOD_PATTERN} 校验,必填。
  100. */
  101. public const SEVERITIES = [
  102. 'issue' => ['error', 'warning', 'suggestion'],
  103. 'help' => ['urgent', 'normal'],
  104. ];
  105. /** 话题状态:active 未关闭,close 已关闭(v4 在读这两个值,不能改) */
  106. public const STATUSES = ['active', 'close'];
  107. /**
  108. * 挂靠目标的类型(res_type)。v3 对外叫 target_type。
  109. * chapter / channel / feedback 在两个前端里都没有调用方,不收。
  110. */
  111. public const TARGET_TYPES = ['sentence', 'wbw', 'article', 'term', 'task'];
  112. /**
  113. * 当前查看者能做的动作,由 `App\Services\V3\DiscussionService` 算好后挂上,
  114. * 供 Resource 读取(Resource 不查库)。声明成属性而不是 attribute,不会被存进表。
  115. *
  116. * @var array{update: bool, delete: bool, close: bool, reply: bool}|null
  117. */
  118. public ?array $abilities = null;
  119. /**
  120. * 正文渲染结果(只有挂在句子上的才有,需要句子所在的 channel 才能渲染模板),
  121. * 同样由 Service 算好后挂上。
  122. *
  123. * @var array{html: string, summary: string}|null
  124. */
  125. public ?array $rendered = null;
  126. /**
  127. * 编辑者(人类用户)。editor_uid 对 user_infos.userid。
  128. */
  129. public function user(): BelongsTo
  130. {
  131. return $this->belongsTo(UserInfo::class, 'editor_uid', 'userid');
  132. }
  133. /**
  134. * 编辑者(AI 助手)。与 {@see self::user()} 共用 uuid 空间,二者择一。
  135. */
  136. public function aiModel(): BelongsTo
  137. {
  138. return $this->belongsTo(AiModel::class, 'editor_uid', 'uid');
  139. }
  140. /**
  141. * 协作者:人类授权代写的 AI 模型(co_editor_uid)。此时 editor 是授权人。
  142. * 存量数据里 AI 写的行没有它,editor_uid 直接是模型,见 {@see self::aiModel()}。
  143. */
  144. public function coEditor(): BelongsTo
  145. {
  146. return $this->belongsTo(AiModel::class, 'co_editor_uid', 'uid');
  147. }
  148. /**
  149. * 话题下的回复(只有一层)。软删的回复自动排除。
  150. */
  151. public function replies(): HasMany
  152. {
  153. return $this->hasMany(Discussion::class, 'parent');
  154. }
  155. /**
  156. * 这几类会被注入到所挂句子的阅读页里({@see self::READING_TYPES}),阅读页按
  157. * (book, para, channel) 缓存,所以增改删它们(含关闭 / 重开)都要清掉那一段的缓存。
  158. * 改之前是、改之后不是(或反过来)的也要清。
  159. */
  160. protected static function booted(): void
  161. {
  162. $forget = function (Discussion $discussion) {
  163. $wasInjected = in_array($discussion->getOriginal('type'), self::READING_TYPES, true);
  164. if (! in_array($discussion->type, self::READING_TYPES, true) && ! $wasInjected) {
  165. return;
  166. }
  167. if ($discussion->res_type !== 'sentence' || empty($discussion->res_id)) {
  168. return;
  169. }
  170. $sentence = Sentence::where('uid', $discussion->res_id)
  171. ->first(['book_id', 'paragraph', 'channel_uid']);
  172. if (! $sentence) {
  173. return;
  174. }
  175. PaliContentService::forgetParagraph(
  176. (int) $sentence->book_id,
  177. (int) $sentence->paragraph,
  178. (string) $sentence->channel_uid
  179. );
  180. };
  181. static::saved($forget);
  182. static::deleted($forget);
  183. }
  184. // 设置默认值
  185. protected $attributes = [
  186. 'content_type' => 'markdown',
  187. 'type' => 'discussion',
  188. ];
  189. }