ExportDiscussion.php 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. <?php
  2. namespace App\Console\Commands;
  3. use App\Http\Api\ChannelApi;
  4. use Carbon\Carbon;
  5. use Illuminate\Console\Command;
  6. use Illuminate\Support\Collection;
  7. use Illuminate\Support\Facades\DB;
  8. class ExportDiscussion extends Command
  9. {
  10. /**
  11. * The name and signature of the console command.
  12. * php artisan export:discussion
  13. */
  14. protected $signature = 'export:discussion {editor : The editor UID to export discussions for}';
  15. /**
  16. * The console command description.
  17. */
  18. protected $description = 'Export discussions made by a specific editor to a Markdown file';
  19. /** @var string 巴利原文 channel_uid */
  20. private string $orgChannelId;
  21. /** @var resource 输出文件句柄(流式写入,避免大字符串堆积在内存) */
  22. private $fileHandle;
  23. /** @var string 输出文件路径 */
  24. private string $outputPath;
  25. /**
  26. * Execute the console command.
  27. */
  28. public function handle(): int
  29. {
  30. $editorUid = $this->argument('editor');
  31. $this->info("Fetching discussions for editor: {$editorUid}");
  32. // 1. 获取巴利原文 channel_uid
  33. $this->orgChannelId = ChannelApi::getSysChannel('_System_Pali_VRI_');
  34. if (! $this->orgChannelId) {
  35. $this->error('Failed to retrieve Pali source channel ID.');
  36. return self::FAILURE;
  37. }
  38. $this->info("Pali channel ID: {$this->orgChannelId}");
  39. // 2. 统计总数(用于进度条)
  40. $total = DB::table('discussions')
  41. ->where('editor_uid', $editorUid)
  42. ->where('status', 'active')
  43. // discussions 已改为软删除;裸 DB::table() 不走模型的全局作用域,要自己排除
  44. ->whereNull('deleted_at')
  45. ->count();
  46. if ($total === 0) {
  47. $this->warn("No discussions found for editor: {$editorUid}");
  48. return self::SUCCESS;
  49. }
  50. $this->info("Found {$total} discussion(s). Processing...");
  51. // 3. 打开文件句柄(流式写入,不在内存中拼接整个 Markdown)
  52. $filename = "discussion_export_{$editorUid}_".now()->format('YmdHis').'.md';
  53. $this->outputPath = storage_path("app/tmp/{$filename}");
  54. $this->fileHandle = fopen($this->outputPath, 'w');
  55. if (! $this->fileHandle) {
  56. $this->error("Cannot open file for writing: {$this->outputPath}");
  57. return self::FAILURE;
  58. }
  59. // 写文件头
  60. $this->writeLine("# 讨论导出报告\n");
  61. $this->writeLine("- **Editor UID**: {$editorUid}");
  62. $this->writeLine('- **导出时间**: '.now()->toDateTimeString());
  63. $this->writeLine("\n---\n");
  64. // 4. 分批处理(每批 50 条),避免内存溢出
  65. $progressBar = $this->output->createProgressBar($total);
  66. $progressBar->start();
  67. DB::table('discussions')
  68. ->where('editor_uid', $editorUid)
  69. ->where('status', 'active')
  70. // discussions 已改为软删除;裸 DB::table() 不走模型的全局作用域,要自己排除
  71. ->whereNull('deleted_at')
  72. ->orderBy('created_at', 'asc')
  73. ->select(['id', 'res_id', 'res_type', 'content', 'created_at'])
  74. ->chunk(50, function ($discussions) use ($progressBar) {
  75. $this->processChunk($discussions);
  76. $progressBar->advance($discussions->count());
  77. // 每批处理完后主动释放内存
  78. gc_collect_cycles();
  79. });
  80. $progressBar->finish();
  81. $this->newLine();
  82. fclose($this->fileHandle);
  83. $this->info("\n✅ 导出完成!文件已保存到: {$this->outputPath}");
  84. return self::SUCCESS;
  85. }
  86. /**
  87. * 处理一批 discussions。
  88. */
  89. private function processChunk(Collection $discussions): void
  90. {
  91. // --- 批量查译文 sentences ---
  92. $resIds = $discussions->pluck('res_id')->unique()->values()->all();
  93. $translationMap = DB::table('sentences')
  94. ->whereIn('uid', $resIds)
  95. ->select([
  96. 'uid',
  97. 'book_id',
  98. 'paragraph',
  99. 'word_start',
  100. 'word_end',
  101. 'content',
  102. 'channel_uid',
  103. ])
  104. ->get()
  105. ->keyBy('uid');
  106. // --- 批量查 sent_histories(分小批,避免超大 IN) ---
  107. $historiesMap = [];
  108. foreach (array_chunk($resIds, 100) as $batch) {
  109. DB::table('sent_histories')
  110. ->whereIn('sent_uid', $batch)
  111. ->orderBy('create_time', 'asc')
  112. ->select(['sent_uid', 'content', 'create_time'])
  113. ->each(function ($row) use (&$historiesMap) {
  114. $historiesMap[$row->sent_uid][] = $row;
  115. });
  116. }
  117. // --- 收集本批所有唯一坐标,批量查巴利原文 ---
  118. $coordKeys = [];
  119. foreach ($translationMap as $t) {
  120. $key = "{$t->book_id}_{$t->paragraph}_{$t->word_start}_{$t->word_end}";
  121. $coordKeys[$key] = $t;
  122. }
  123. $paliMap = $this->fetchPaliSentences($coordKeys);
  124. // --- 写 Markdown ---
  125. foreach ($discussions as $discussion) {
  126. $sentUid = $discussion->res_id;
  127. $translation = $translationMap->get($sentUid);
  128. if (! $translation) {
  129. continue;
  130. }
  131. $coordKey = "{$translation->book_id}_{$translation->paragraph}_{$translation->word_start}_{$translation->word_end}";
  132. $pali = $paliMap[$coordKey] ?? null;
  133. $paliContent = $pali ? trim($pali->content ?? '(无原文)') : '(未找到巴利原文)';
  134. $discussionCreatedAt = $discussion->created_at
  135. ? Carbon::parse($discussion->created_at)
  136. : null;
  137. $histories = $historiesMap[$sentUid] ?? [];
  138. $matchedHistory = $this->findClosestHistory($histories, $discussionCreatedAt);
  139. $translationAtTime = $matchedHistory
  140. ? trim($matchedHistory->content)
  141. : trim($translation->content ?? '(无译文内容)');
  142. $this->writeLine("# {$paliContent}\n");
  143. $this->writeLine(" - **历史译文**: {$translationAtTime}");
  144. $this->writeLine(' - **评论**: '.trim($discussion->title ?? '').trim($discussion->content ?? ''));
  145. $this->writeLine(" - **当前译文**: {$translation->content}");
  146. $this->writeLine('');
  147. }
  148. // 显式释放本批数据
  149. unset($translationMap, $historiesMap, $coordKeys, $paliMap);
  150. }
  151. /**
  152. * 批量查询巴利原文,每组最多 30 个坐标,避免超大 SQL。
  153. *
  154. * @param array<string, object> $coordKeys key="{book_id}_{paragraph}_{word_start}_{word_end}"
  155. * @return array<string, object>
  156. */
  157. private function fetchPaliSentences(array $coordKeys): array
  158. {
  159. $paliMap = [];
  160. foreach (array_chunk(array_values($coordKeys), 30) as $group) {
  161. $results = DB::table('sentences')
  162. ->where('channel_uid', $this->orgChannelId)
  163. ->where(function ($q) use ($group) {
  164. foreach ($group as $t) {
  165. $q->orWhere(function ($sub) use ($t) {
  166. $sub->where('book_id', $t->book_id)
  167. ->where('paragraph', $t->paragraph)
  168. ->where('word_start', $t->word_start)
  169. ->where('word_end', $t->word_end);
  170. });
  171. }
  172. })
  173. ->select(['book_id', 'paragraph', 'word_start', 'word_end', 'content'])
  174. ->get();
  175. foreach ($results as $ps) {
  176. $key = "{$ps->book_id}_{$ps->paragraph}_{$ps->word_start}_{$ps->word_end}";
  177. $paliMap[$key] = $ps;
  178. }
  179. unset($results);
  180. }
  181. return $paliMap;
  182. }
  183. /**
  184. * 流式写入一行到文件。
  185. */
  186. private function writeLine(string $line): void
  187. {
  188. fwrite($this->fileHandle, $line."\n");
  189. }
  190. /**
  191. * 在历史记录中找评论发布时间之前最近的那条。
  192. * 若全部在评论之后,则退而取最早一条。
  193. *
  194. * @param array $histories sent_histories(已按 create_time ASC 排序)
  195. * @param Carbon|null $discussionCreatedAt 评论发布时间
  196. */
  197. private function findClosestHistory(array $histories, ?Carbon $discussionCreatedAt): ?object
  198. {
  199. if (empty($histories)) {
  200. return null;
  201. }
  202. if (! $discussionCreatedAt) {
  203. return end($histories) ?: null;
  204. }
  205. $discussionTimestamp = $discussionCreatedAt->timestamp;
  206. $best = null;
  207. $bestDiff = PHP_INT_MAX;
  208. foreach ($histories as $h) {
  209. $historyTime = (int) $h->create_time;
  210. if ($historyTime <= $discussionTimestamp) {
  211. $diff = $discussionTimestamp - $historyTime;
  212. if ($diff < $bestDiff) {
  213. $bestDiff = $diff;
  214. $best = $h;
  215. }
  216. }
  217. }
  218. // 所有历史都在评论之后 → 取最早一条
  219. return $best ?? $histories[0];
  220. }
  221. }