ExportDiscussion.php 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  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. ->count();
  44. if ($total === 0) {
  45. $this->warn("No discussions found for editor: {$editorUid}");
  46. return self::SUCCESS;
  47. }
  48. $this->info("Found {$total} discussion(s). Processing...");
  49. // 3. 打开文件句柄(流式写入,不在内存中拼接整个 Markdown)
  50. $filename = "discussion_export_{$editorUid}_".now()->format('YmdHis').'.md';
  51. $this->outputPath = storage_path("app/tmp/{$filename}");
  52. $this->fileHandle = fopen($this->outputPath, 'w');
  53. if (! $this->fileHandle) {
  54. $this->error("Cannot open file for writing: {$this->outputPath}");
  55. return self::FAILURE;
  56. }
  57. // 写文件头
  58. $this->writeLine("# 讨论导出报告\n");
  59. $this->writeLine("- **Editor UID**: {$editorUid}");
  60. $this->writeLine('- **导出时间**: '.now()->toDateTimeString());
  61. $this->writeLine("\n---\n");
  62. // 4. 分批处理(每批 50 条),避免内存溢出
  63. $progressBar = $this->output->createProgressBar($total);
  64. $progressBar->start();
  65. DB::table('discussions')
  66. ->where('editor_uid', $editorUid)
  67. ->where('status', 'active')
  68. ->orderBy('created_at', 'asc')
  69. ->select(['id', 'res_id', 'res_type', 'content', 'created_at'])
  70. ->chunk(50, function ($discussions) use ($progressBar) {
  71. $this->processChunk($discussions);
  72. $progressBar->advance($discussions->count());
  73. // 每批处理完后主动释放内存
  74. gc_collect_cycles();
  75. });
  76. $progressBar->finish();
  77. $this->newLine();
  78. fclose($this->fileHandle);
  79. $this->info("\n✅ 导出完成!文件已保存到: {$this->outputPath}");
  80. return self::SUCCESS;
  81. }
  82. /**
  83. * 处理一批 discussions。
  84. */
  85. private function processChunk(Collection $discussions): void
  86. {
  87. // --- 批量查译文 sentences ---
  88. $resIds = $discussions->pluck('res_id')->unique()->values()->all();
  89. $translationMap = DB::table('sentences')
  90. ->whereIn('uid', $resIds)
  91. ->select([
  92. 'uid',
  93. 'book_id',
  94. 'paragraph',
  95. 'word_start',
  96. 'word_end',
  97. 'content',
  98. 'channel_uid',
  99. ])
  100. ->get()
  101. ->keyBy('uid');
  102. // --- 批量查 sent_histories(分小批,避免超大 IN) ---
  103. $historiesMap = [];
  104. foreach (array_chunk($resIds, 100) as $batch) {
  105. DB::table('sent_histories')
  106. ->whereIn('sent_uid', $batch)
  107. ->orderBy('create_time', 'asc')
  108. ->select(['sent_uid', 'content', 'create_time'])
  109. ->each(function ($row) use (&$historiesMap) {
  110. $historiesMap[$row->sent_uid][] = $row;
  111. });
  112. }
  113. // --- 收集本批所有唯一坐标,批量查巴利原文 ---
  114. $coordKeys = [];
  115. foreach ($translationMap as $t) {
  116. $key = "{$t->book_id}_{$t->paragraph}_{$t->word_start}_{$t->word_end}";
  117. $coordKeys[$key] = $t;
  118. }
  119. $paliMap = $this->fetchPaliSentences($coordKeys);
  120. // --- 写 Markdown ---
  121. foreach ($discussions as $discussion) {
  122. $sentUid = $discussion->res_id;
  123. $translation = $translationMap->get($sentUid);
  124. if (! $translation) {
  125. continue;
  126. }
  127. $coordKey = "{$translation->book_id}_{$translation->paragraph}_{$translation->word_start}_{$translation->word_end}";
  128. $pali = $paliMap[$coordKey] ?? null;
  129. $paliContent = $pali ? trim($pali->content ?? '(无原文)') : '(未找到巴利原文)';
  130. $discussionCreatedAt = $discussion->created_at
  131. ? Carbon::parse($discussion->created_at)
  132. : null;
  133. $histories = $historiesMap[$sentUid] ?? [];
  134. $matchedHistory = $this->findClosestHistory($histories, $discussionCreatedAt);
  135. $translationAtTime = $matchedHistory
  136. ? trim($matchedHistory->content)
  137. : trim($translation->content ?? '(无译文内容)');
  138. $this->writeLine("# {$paliContent}\n");
  139. $this->writeLine(" - **历史译文**: {$translationAtTime}");
  140. $this->writeLine(' - **评论**: '.trim($discussion->title ?? '').trim($discussion->content ?? ''));
  141. $this->writeLine(" - **当前译文**: {$translation->content}");
  142. $this->writeLine('');
  143. }
  144. // 显式释放本批数据
  145. unset($translationMap, $historiesMap, $coordKeys, $paliMap);
  146. }
  147. /**
  148. * 批量查询巴利原文,每组最多 30 个坐标,避免超大 SQL。
  149. *
  150. * @param array<string, object> $coordKeys key="{book_id}_{paragraph}_{word_start}_{word_end}"
  151. * @return array<string, object>
  152. */
  153. private function fetchPaliSentences(array $coordKeys): array
  154. {
  155. $paliMap = [];
  156. foreach (array_chunk(array_values($coordKeys), 30) as $group) {
  157. $results = DB::table('sentences')
  158. ->where('channel_uid', $this->orgChannelId)
  159. ->where(function ($q) use ($group) {
  160. foreach ($group as $t) {
  161. $q->orWhere(function ($sub) use ($t) {
  162. $sub->where('book_id', $t->book_id)
  163. ->where('paragraph', $t->paragraph)
  164. ->where('word_start', $t->word_start)
  165. ->where('word_end', $t->word_end);
  166. });
  167. }
  168. })
  169. ->select(['book_id', 'paragraph', 'word_start', 'word_end', 'content'])
  170. ->get();
  171. foreach ($results as $ps) {
  172. $key = "{$ps->book_id}_{$ps->paragraph}_{$ps->word_start}_{$ps->word_end}";
  173. $paliMap[$key] = $ps;
  174. }
  175. unset($results);
  176. }
  177. return $paliMap;
  178. }
  179. /**
  180. * 流式写入一行到文件。
  181. */
  182. private function writeLine(string $line): void
  183. {
  184. fwrite($this->fileHandle, $line."\n");
  185. }
  186. /**
  187. * 在历史记录中找评论发布时间之前最近的那条。
  188. * 若全部在评论之后,则退而取最早一条。
  189. *
  190. * @param array $histories sent_histories(已按 create_time ASC 排序)
  191. * @param Carbon|null $discussionCreatedAt 评论发布时间
  192. */
  193. private function findClosestHistory(array $histories, ?Carbon $discussionCreatedAt): ?object
  194. {
  195. if (empty($histories)) {
  196. return null;
  197. }
  198. if (! $discussionCreatedAt) {
  199. return end($histories) ?: null;
  200. }
  201. $discussionTimestamp = $discussionCreatedAt->timestamp;
  202. $best = null;
  203. $bestDiff = PHP_INT_MAX;
  204. foreach ($histories as $h) {
  205. $historyTime = (int) $h->create_time;
  206. if ($historyTime <= $discussionTimestamp) {
  207. $diff = $discussionTimestamp - $historyTime;
  208. if ($diff < $bestDiff) {
  209. $bestDiff = $diff;
  210. $best = $h;
  211. }
  212. }
  213. }
  214. // 所有历史都在评论之后 → 取最早一条
  215. return $best ?? $histories[0];
  216. }
  217. }