PacketService.php 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. <?php
  2. namespace App\Services;
  3. use App\Http\Api\ChannelApi;
  4. use App\Models\Channel;
  5. use Illuminate\Support\Facades\App;
  6. use Illuminate\Support\Facades\Cache;
  7. use Illuminate\Support\Facades\DB;
  8. use Illuminate\Support\Facades\Log;
  9. use Illuminate\Support\Facades\Storage;
  10. use ZipArchive;
  11. /**
  12. * PacketService
  13. *
  14. * 用于导出句子数据为训练数据包的服务类
  15. * 将指定版本的译文与巴利原文配对导出为JSONL格式,并打包为ZIP文件
  16. */
  17. class PacketService
  18. {
  19. /**
  20. * 每批处理的记录数
  21. */
  22. private const CHUNK_SIZE = 1000;
  23. /**
  24. * 临时文件存储路径
  25. */
  26. private const TEMP_DIR = 'temp/packet';
  27. /**
  28. * 巴利原文的channel_uid
  29. */
  30. private string $paliChannelUid;
  31. /**
  32. * 译文版本的channel_uid数组
  33. */
  34. private array $translationChannelUids;
  35. /**
  36. * 临时文件路径集合
  37. */
  38. private array $tempFiles = [];
  39. /**
  40. * @param string $paliChannelUid 巴利原文的channel_uid
  41. * @param array $translationChannelUids 译文版本的channel_uid数组
  42. */
  43. public function channels(array $translationChannelUids)
  44. {
  45. $this->paliChannelUid = ChannelApi::getSysChannel('_System_Pali_VRI_');
  46. $this->translationChannelUids = $translationChannelUids;
  47. }
  48. /**
  49. * 执行导出并打包
  50. *
  51. * @return string 返回生成的ZIP文件路径
  52. *
  53. * @throws \Exception
  54. */
  55. public function export(): string
  56. {
  57. try {
  58. // 创建临时目录
  59. $this->createTempDirectory();
  60. // 导出所有译文文件
  61. foreach ($this->translationChannelUids as $channelUid) {
  62. $this->exportTranslation($channelUid);
  63. }
  64. // 打包ZIP文件
  65. $zipPath = $this->createZipArchive();
  66. // 清理临时文件
  67. $this->cleanupTempFiles();
  68. return $zipPath;
  69. } catch (\Exception $e) {
  70. // 发生错误时也要清理临时文件
  71. $this->cleanupTempFiles();
  72. throw $e;
  73. }
  74. }
  75. /**
  76. * 创建临时目录
  77. */
  78. private function createTempDirectory(): void
  79. {
  80. $tempPath = storage_path('app/'.self::TEMP_DIR);
  81. if (! is_dir($tempPath)) {
  82. mkdir($tempPath, 0755, true);
  83. }
  84. // 创建translations子目录
  85. $translationsPath = $tempPath.'/translations';
  86. if (! is_dir($translationsPath)) {
  87. mkdir($translationsPath, 0755, true);
  88. }
  89. }
  90. /**
  91. * 导出指定译文版本的数据
  92. *
  93. * @param string $channelUid 译文版本的channel_uid
  94. */
  95. private function exportTranslation(string $channelUid): void
  96. {
  97. // 获取channel名称
  98. $channelName = $this->getChannelName($channelUid);
  99. // 创建JSONL文件
  100. $filename = $channelName.'.jsonl';
  101. $filepath = storage_path('app/'.self::TEMP_DIR.'/translations/'.$filename);
  102. // 记录临时文件路径
  103. $this->tempFiles[] = $filepath;
  104. // 打开文件准备写入
  105. $handle = fopen($filepath, 'w');
  106. if ($handle === false) {
  107. throw new \RuntimeException("无法创建文件: {$filepath}");
  108. }
  109. try {
  110. // 分批查询并写入数据
  111. $this->writeTranslationData($handle, $channelUid);
  112. } finally {
  113. fclose($handle);
  114. }
  115. }
  116. /**
  117. * 查询并写入译文数据
  118. *
  119. * @param resource $handle 文件句柄
  120. * @param string $channelUid 译文版本的channel_uid
  121. */
  122. private function writeTranslationData($handle, string $channelUid): void
  123. {
  124. // 构建查询,联表获取译文和巴利文
  125. DB::table('sentences as s1')
  126. ->select([
  127. 's1.book_id',
  128. 's1.paragraph',
  129. 's1.word_start',
  130. 's1.word_end',
  131. 's1.content as translation',
  132. 's2.content as pali',
  133. ])
  134. ->join('sentences as s2', function ($join) {
  135. $join->on('s1.book_id', '=', 's2.book_id')
  136. ->on('s1.paragraph', '=', 's2.paragraph')
  137. ->on('s1.word_start', '=', 's2.word_start')
  138. ->on('s1.word_end', '=', 's2.word_end')
  139. ->where('s2.channel_uid', '=', $this->paliChannelUid);
  140. })
  141. ->where('s1.channel_uid', '=', $channelUid)
  142. ->whereNotNull('s1.content')
  143. ->where('s1.content', '!=', '')
  144. ->orderBy('s1.book_id')
  145. ->orderBy('s1.paragraph')
  146. ->orderBy('s1.word_start')
  147. ->orderBy('s1.word_end')
  148. ->chunk(self::CHUNK_SIZE, function ($sentences) use ($handle) {
  149. foreach ($sentences as $sentence) {
  150. // 如果没有译文,跳过
  151. if (empty($sentence->translation)) {
  152. continue;
  153. }
  154. // 构建ID
  155. $id = sprintf(
  156. '%s-%s-%s-%s',
  157. $sentence->book_id,
  158. $sentence->paragraph,
  159. $sentence->word_start,
  160. $sentence->word_end
  161. );
  162. // 构建JSON对象
  163. $data = [
  164. 'id' => $id,
  165. 'pali' => $sentence->pali ?? '',
  166. 'translation' => $sentence->translation,
  167. ];
  168. // 写入JSONL格式(每行一个JSON对象)
  169. fwrite($handle, json_encode($data, JSON_UNESCAPED_UNICODE)."\n");
  170. }
  171. });
  172. }
  173. /**
  174. * 获取channel名称
  175. *
  176. * @param string $channelUid channel的uuid
  177. * @return string channel名称,如果找不到则返回uuid
  178. */
  179. private function getChannelName(string $channelUid): string
  180. {
  181. $channel = Channel::where('uid', $channelUid)->first();
  182. return $channel?->name ?? $channelUid;
  183. }
  184. /**
  185. * 创建ZIP压缩包
  186. *
  187. * @return string 返回ZIP文件在Storage中的路径
  188. *
  189. * @throws \RuntimeException
  190. */
  191. private function createZipArchive(): string
  192. {
  193. $timestamp = now()->format('YmdHis');
  194. $zipFilename = "training_data_{$timestamp}.zip";
  195. $zipPath = storage_path('app/packet/'.$zipFilename);
  196. // 确保packet目录存在
  197. $packetDir = storage_path('app/packet');
  198. if (! is_dir($packetDir)) {
  199. mkdir($packetDir, 0755, true);
  200. }
  201. $zip = new ZipArchive;
  202. if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
  203. throw new \RuntimeException("无法创建ZIP文件: {$zipPath}");
  204. }
  205. try {
  206. // 添加所有JSONL文件到ZIP
  207. $translationsDir = storage_path('app/'.self::TEMP_DIR.'/translations');
  208. if (is_dir($translationsDir)) {
  209. $files = scandir($translationsDir);
  210. foreach ($files as $file) {
  211. if ($file === '.' || $file === '..') {
  212. continue;
  213. }
  214. $filePath = $translationsDir.'/'.$file;
  215. if (is_file($filePath)) {
  216. // 添加到ZIP的translations目录下
  217. $zip->addFile($filePath, 'translations/'.$file);
  218. }
  219. }
  220. }
  221. $zip->close();
  222. } catch (\Exception $e) {
  223. $zip->close();
  224. throw $e;
  225. }
  226. // 返回相对于Storage的路径
  227. return 'packet/'.$zipFilename;
  228. }
  229. /**
  230. * 清理临时文件和目录
  231. */
  232. private function cleanupTempFiles(): void
  233. {
  234. $tempPath = storage_path('app/'.self::TEMP_DIR);
  235. if (is_dir($tempPath)) {
  236. $this->deleteDirectory($tempPath);
  237. }
  238. }
  239. /**
  240. * 递归删除目录
  241. *
  242. * @param string $dir 目录路径
  243. */
  244. private function deleteDirectory(string $dir): void
  245. {
  246. if (! is_dir($dir)) {
  247. return;
  248. }
  249. $files = array_diff(scandir($dir), ['.', '..']);
  250. foreach ($files as $file) {
  251. $path = $dir.'/'.$file;
  252. is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
  253. }
  254. rmdir($dir);
  255. }
  256. public function index(?string $id = null)
  257. {
  258. $key = '/offline/index';
  259. if (! Cache::has($key)) {
  260. return [];
  261. }
  262. $fileInfo = Cache::get($key);
  263. $output = [];
  264. foreach ($fileInfo as $key => $file) {
  265. if ($id) {
  266. if ($file['id'] !== $id) {
  267. continue;
  268. }
  269. }
  270. $zipFile = $file['filename'];
  271. $bucket = config('mint.attachments.bucket_name.temporary');
  272. $tmpFile = $bucket.'/'.$zipFile;
  273. $url = [];
  274. foreach (config('mint.server.cdn_urls') as $key => $cdn) {
  275. $url[] = [
  276. 'link' => $cdn.'/'.$zipFile,
  277. 'hostname' => 'cdn-'.$key,
  278. ];
  279. }
  280. if (App::environment('local')) {
  281. $s3Link = Storage::url($tmpFile);
  282. } else {
  283. try {
  284. $s3Link = Storage::temporaryUrl($tmpFile, now()->addDays(2));
  285. } catch (\Exception $e) {
  286. Log::error('offline-index {Exception}', ['exception' => $e]);
  287. continue;
  288. }
  289. }
  290. $url[] = [
  291. 'link' => $s3Link,
  292. 'hostname' => 'Amazon cloud storage(Hongkong)',
  293. ];
  294. $file['url'] = $url;
  295. Log::debug('offline-index: file info=', ['data' => $file]);
  296. $output[] = $file;
  297. }
  298. return $output;
  299. }
  300. }