Răsfoiți Sursa

Merge pull request #2483 from visuddhinanda/development

Development
visuddhinanda 6 zile în urmă
părinte
comite
a62b933f62
71 a modificat fișierele cu 831 adăugiri și 306 ștergeri
  1. 2 2
      api-v13/app/Console/Commands/CacheDictPreference.php
  2. 3 3
      api-v13/app/Console/Commands/CacheWbwPreference.php
  3. 4 4
      api-v13/app/Console/Commands/ClearAppCache.php
  4. 2 2
      api-v13/app/Console/Commands/ClearEmbeddingsCache.php
  5. 2 2
      api-v13/app/Console/Commands/ExportChapterIndex.php
  6. 2 2
      api-v13/app/Console/Commands/ExportOffline.php
  7. 4 4
      api-v13/app/Console/Commands/ExportZip.php
  8. 4 4
      api-v13/app/Console/Commands/ExportZip2.php
  9. 5 5
      api-v13/app/Console/Commands/ExtractPaliTerm.php
  10. 5 5
      api-v13/app/Console/Commands/IndexTerm.php
  11. 2 2
      api-v13/app/Console/Commands/IndexTipitaka.php
  12. 6 6
      api-v13/app/Console/Commands/MqAiTranslate.php
  13. 3 3
      api-v13/app/Console/Commands/RemoveTermCache.php
  14. 2 2
      api-v13/app/Console/Commands/StatisticsNissayaCover.php
  15. 7 21
      api-v13/app/Console/Commands/TestRedis.php
  16. 5 5
      api-v13/app/Console/Commands/UpgradeAITerm.php
  17. 5 5
      api-v13/app/Console/Commands/UpgradeAITranslation.php
  18. 5 5
      api-v13/app/Console/Commands/UpgradeChapterDynamicWeekly.php
  19. 2 2
      api-v13/app/Console/Commands/UpgradeDict.php
  20. 3 3
      api-v13/app/Console/Commands/UpgradeDictDefaultMeaning.php
  21. 5 5
      api-v13/app/Console/Commands/UpgradeProgress.php
  22. 5 5
      api-v13/app/Console/Commands/UpgradeProgressChapter.php
  23. 5 5
      api-v13/app/Console/Commands/UpgradeProgressPara.php
  24. 5 5
      api-v13/app/Console/Commands/UpgradeSystemCommentary.php
  25. 36 0
      api-v13/app/Facades/RedisCache.php
  26. 5 5
      api-v13/app/Http/Api/TaskApi.php
  27. 2 2
      api-v13/app/Http/Api/TemplateRender.php
  28. 2 2
      api-v13/app/Http/Controllers/BookTitleController.php
  29. 3 3
      api-v13/app/Http/Controllers/CorpusController.php
  30. 6 6
      api-v13/app/Http/Controllers/DhammaTermController.php
  31. 2 2
      api-v13/app/Http/Controllers/DictMeaningController.php
  32. 2 2
      api-v13/app/Http/Controllers/EmailCertificationController.php
  33. 3 3
      api-v13/app/Http/Controllers/HealthCheckController.php
  34. 2 2
      api-v13/app/Http/Controllers/NissayaCoverController.php
  35. 2 2
      api-v13/app/Http/Controllers/PaliTextController.php
  36. 2 2
      api-v13/app/Http/Controllers/ProgressChapterController.php
  37. 3 3
      api-v13/app/Http/Controllers/ProgressImgController.php
  38. 6 6
      api-v13/app/Http/Controllers/RelationController.php
  39. 6 6
      api-v13/app/Http/Controllers/SentenceController.php
  40. 2 2
      api-v13/app/Http/Controllers/SentenceInfoController.php
  41. 3 3
      api-v13/app/Http/Controllers/SysModelController.php
  42. 9 9
      api-v13/app/Http/Controllers/UserStatisticController.php
  43. 2 2
      api-v13/app/Http/Controllers/VocabularyController.php
  44. 3 3
      api-v13/app/Http/Controllers/WbwLookupController.php
  45. 2 1
      api-v13/app/Http/Controllers/WordIndexController.php
  46. 5 5
      api-v13/app/Http/Middleware/ApiLog.php
  47. 3 3
      api-v13/app/Http/Resources/RelationResource.php
  48. 2 2
      api-v13/app/Http/Resources/SearchResource.php
  49. 2 2
      api-v13/app/Jobs/ProcessAITranslateJob.php
  50. 3 2
      api-v13/app/Mail/EmailCertif.php
  51. 10 0
      api-v13/app/Providers/AppServiceProvider.php
  52. 2 2
      api-v13/app/Services/AIModelService.php
  53. 9 9
      api-v13/app/Services/AiTranslateService.php
  54. 50 45
      api-v13/app/Services/OpenSearchService.php
  55. 3 3
      api-v13/app/Services/PacketService.php
  56. 5 5
      api-v13/app/Services/PaliContentService.php
  57. 186 0
      api-v13/app/Services/RedisCache.php
  58. 63 0
      api-v13/app/Services/RedisCacheFake.php
  59. 4 4
      api-v13/app/Services/SummaryService.php
  60. 18 9
      api-v13/app/Services/Template/TemplateService.php
  61. 6 6
      api-v13/app/Services/V3/SignUpService.php
  62. 3 3
      api-v13/app/Tools/ExportDownload.php
  63. 17 15
      api-v13/app/Tools/TurboSplit.php
  64. 0 9
      api-v13/config/custom.php
  65. 10 0
      api-v13/config/mint.php
  66. 2 0
      api-v13/documents/tasks.md
  67. 3 3
      api-v13/tests/Feature/ReadingAnnotationsTest.php
  68. 211 0
      api-v13/tests/Feature/RedisCacheTest.php
  69. 3 3
      api-v13/tests/Feature/TipitakaReadingChunkTest.php
  70. 3 3
      api-v13/tests/Feature/TipitakaReadingRenderTest.php
  71. 12 0
      api-v13/tests/TestCase.php

+ 2 - 2
api-v13/app/Console/Commands/CacheDictPreference.php

@@ -2,10 +2,10 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Models\UserDict;
 use App\Tools\Tools;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 
 class CacheDictPreference extends Command
@@ -61,7 +61,7 @@ class CacheDictPreference extends Command
             $meaning = trim($meaning, ' $');
             if (! empty($meaning)) {
                 $m = explode('$', $meaning);
-                Cache::put("{$prefix}/{$word->word}/{$word->language}", $m[0]);
+                RedisCache::put("{$prefix}/{$word->word}/{$word->language}", $m[0]);
             }
             $bar->advance();
             $count++;

+ 3 - 3
api-v13/app/Console/Commands/CacheWbwPreference.php

@@ -2,10 +2,10 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Models\WbwAnalysis;
 use App\Tools\Tools;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 
 class CacheWbwPreference extends Command
@@ -74,7 +74,7 @@ class CacheWbwPreference extends Command
                     ->where('editor_id', $value->editor_id)
                     ->orderBy('updated_at', 'desc')
                     ->value('data');
-                Cache::put("{$prefix}/{$value->wbw_word}/{$value->type}/{$value->editor_id}", $data);
+                RedisCache::put("{$prefix}/{$value->wbw_word}/{$value->type}/{$value->editor_id}", $data);
                 $bar->advance();
                 $count++;
                 if ($count % 1000 === 0) {
@@ -109,7 +109,7 @@ class CacheWbwPreference extends Command
                     ->orderBy('count', 'desc')
                     ->first();
 
-                Cache::put("{$prefix}/{$value->wbw_word}/{$value->type}/0", $data->data);
+                RedisCache::put("{$prefix}/{$value->wbw_word}/{$value->type}/0", $data->data);
                 $bar->advance();
             }
             $bar->finish();

+ 4 - 4
api-v13/app/Console/Commands/ClearAppCache.php

@@ -2,8 +2,8 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 
 class ClearAppCache extends Command
 {
@@ -40,7 +40,7 @@ class ClearAppCache extends Command
         if (! $name && ! $this->option('all')) {
             $this->line('可清理的缓存项:');
             foreach (self::CACHES as $key => $item) {
-                $cached = collect($item['keys'])->filter(fn ($k) => Cache::has($k))->count();
+                $cached = collect($item['keys'])->filter(fn ($k) => RedisCache::has($k))->count();
                 $state = $cached > 0 ? "已缓存 {$cached}/".count($item['keys']) : '未缓存';
                 $this->line(sprintf('  %-14s %-34s [%s]', $key, $item['desc'], $state));
             }
@@ -62,8 +62,8 @@ class ClearAppCache extends Command
         foreach ($targets as $key => $item) {
             foreach ($item['keys'] as $cacheKey) {
                 // forget 对不存在的 key 也返回 true,所以先问一次才能如实报告清了几条
-                $existed = Cache::has($cacheKey);
-                Cache::forget($cacheKey);
+                $existed = RedisCache::has($cacheKey);
+                RedisCache::forget($cacheKey);
                 if ($existed) {
                     $cleared++;
                     $this->info("已清理 {$key}:{$cacheKey}");

+ 2 - 2
api-v13/app/Console/Commands/ClearEmbeddingsCache.php

@@ -36,8 +36,8 @@ class ClearEmbeddingsCache extends Command
                 $this->warn("缓存不存在: \"{$text}\"");
             }
         } else {
-            $count = $service->clearAllEmbeddingCache();
-            $this->info("已清理所有 embedding 缓存,共 {$count} 条");
+            $service->clearAllEmbeddingCache();
+            $this->info('已让所有 embedding 缓存失效,旧缓存 7 天内自然过期');
         }
 
         return 0;

+ 2 - 2
api-v13/app/Console/Commands/ExportChapterIndex.php

@@ -2,11 +2,11 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Models\Channel;
 use App\Models\ProgressChapter;
 use App\Tools\Tools;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 
 class ExportChapterIndex extends Command
@@ -65,7 +65,7 @@ class ExportChapterIndex extends Command
 
         $publicChannels = Channel::where('status', 30)->select('uid')->get();
         $rows = ProgressChapter::whereIn('channel_id', $publicChannels)->count();
-        Cache::put('/export/chapter/count', $rows, 3600 * 10);
+        RedisCache::put('/export/chapter/count', $rows, 3600 * 10);
         $bar = $this->output->createProgressBar($rows);
         foreach (
             ProgressChapter::whereIn('channel_id', $publicChannels)

+ 2 - 2
api-v13/app/Console/Commands/ExportOffline.php

@@ -2,9 +2,9 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Tools\Tools;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 
 class ExportOffline extends Command
@@ -55,7 +55,7 @@ class ExportOffline extends Command
         }
 
         // 清空redis
-        Cache::put('/offline/index', []);
+        RedisCache::put('/offline/index', []);
 
         // 删除全部的旧文件
         foreach (scandir($exportDir) as $key => $file) {

+ 4 - 4
api-v13/app/Console/Commands/ExportZip.php

@@ -2,9 +2,9 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use Illuminate\Console\Command;
 use Illuminate\Support\Facades\App;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Storage;
 use Symfony\Component\Process\Process;
@@ -170,7 +170,7 @@ class ExportZip extends Command
             'link' => $link,
             'hostname' => 'Amazon cloud storage(Hongkong)',
         ];
-        $info = Cache::get('/offline/index');
+        $info = RedisCache::get('/offline/index');
         if (! is_array($info)) {
             $info = [];
         }
@@ -180,11 +180,11 @@ class ExportZip extends Command
             'filename' => $zipFile,
             'url' => $url,
             'create_at' => date('Y-m-d H:i:s'),
-            'chapter' => Cache::get('/export/chapter/count'),
+            'chapter' => RedisCache::get('/export/chapter/count'),
             'filesize' => filesize($zipFullFileName),
             'min_app_ver' => '1.3',
         ];
-        Cache::put('/offline/index', $info);
+        RedisCache::put('/offline/index', $info);
         sleep(5);
         try {
             unlink($exportFullFileName);

+ 4 - 4
api-v13/app/Console/Commands/ExportZip2.php

@@ -2,9 +2,9 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use Illuminate\Console\Command;
 use Illuminate\Support\Facades\App;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Storage;
 use Symfony\Component\Process\Process;
@@ -142,7 +142,7 @@ class ExportZip2 extends Command
         |--------------------------------------------------------------------------
         */
 
-        $info = Cache::get('/offline/index', []);
+        $info = RedisCache::get('/offline/index', []);
         if (! is_array($info)) {
             $info = [];
         }
@@ -158,12 +158,12 @@ class ExportZip2 extends Command
             'filename' => $zipFile,
             'url' => $url,
             'create_at' => now()->toDateTimeString(),
-            'chapter' => Cache::get('/export/chapter/count'),
+            'chapter' => RedisCache::get('/export/chapter/count'),
             'filesize' => filesize($zipFullFileName),
             'min_app_ver' => '1.3',
         ];
 
-        Cache::put('/offline/index', $info);
+        RedisCache::put('/offline/index', $info);
 
         /*
         |--------------------------------------------------------------------------

+ 5 - 5
api-v13/app/Console/Commands/ExtractPaliTerm.php

@@ -2,6 +2,7 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Helpers\LlmResponseParser;
 use App\Http\Api\ChannelApi;
 use App\Http\Resources\AiModelResource;
@@ -11,7 +12,6 @@ use App\Services\OpenAIService;
 use App\Services\SentenceService;
 use App\Services\TermService;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 
 class ExtractPaliTerm extends Command
@@ -113,7 +113,7 @@ class ExtractPaliTerm extends Command
         }
 
         if ($this->option('fresh')) {
-            Cache::forget(self::CURSOR_KEY);
+            RedisCache::forget(self::CURSOR_KEY);
             $this->info('cleared checkpoint');
         }
 
@@ -131,7 +131,7 @@ class ExtractPaliTerm extends Command
             return 1;
         }
 
-        $cursor = Cache::get(self::CURSOR_KEY, []);
+        $cursor = RedisCache::get(self::CURSOR_KEY, []);
 
         // ---------- 模式二:仅 book —— 同一 book 从断点续,跑完即止,不跳到下一 book ----------
         if ($this->option('book')) {
@@ -156,7 +156,7 @@ class ExtractPaliTerm extends Command
             $this->runBook($book, $glossaryJson, $book === $startBook ? $cursor : []);
         }
         // 全部完成,清空断点
-        Cache::forget(self::CURSOR_KEY);
+        RedisCache::forget(self::CURSOR_KEY);
         $this->info('all books completed');
 
         return 0;
@@ -221,7 +221,7 @@ class ExtractPaliTerm extends Command
             $this->processChapter($book, $start, $end, $glossaryJson);
 
             // 该 chapter 完成后写入断点(中途中断时不会误标记未完成的 chapter)
-            Cache::put(self::CURSOR_KEY, ['book' => $book, 'paragraph' => $start], now()->addHours(48));
+            RedisCache::put(self::CURSOR_KEY, ['book' => $book, 'paragraph' => $start], now()->addHours(48));
         }
     }
 

+ 5 - 5
api-v13/app/Console/Commands/IndexTerm.php

@@ -2,11 +2,11 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Models\DhammaTerm;
 use App\Services\OpenSearchService;
 use App\Services\TermIndexService;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 
 class IndexTerm extends Command
@@ -40,7 +40,7 @@ class IndexTerm extends Command
         }
 
         if ($this->option('fresh')) {
-            Cache::forget(self::CACHE_KEY);
+            RedisCache::forget(self::CACHE_KEY);
             $this->info('Cleared cached cursor.');
         }
 
@@ -61,7 +61,7 @@ class IndexTerm extends Command
             }
 
             // 从缓存恢复断点:跳过上次已处理的记录
-            $lastId = Cache::get(self::CACHE_KEY);
+            $lastId = RedisCache::get(self::CACHE_KEY);
             if ($lastId && ! $word) {
                 $terms = $terms->where('id', '>', $lastId);
                 $this->info("Resuming after id={$lastId}");
@@ -79,7 +79,7 @@ class IndexTerm extends Command
                     $this->info("[{$percent}%]-{$curr}/{$total}  {$term->word}");
 
                     // 每 10 条保存一次断点
-                    Cache::put(self::CACHE_KEY, $term->id, now()->addHours(48));
+                    RedisCache::put(self::CACHE_KEY, $term->id, now()->addHours(48));
                 }
 
                 if ($this->isTest) {
@@ -91,7 +91,7 @@ class IndexTerm extends Command
             }
 
             // 全部完成,清除断点缓存
-            Cache::forget(self::CACHE_KEY);
+            RedisCache::forget(self::CACHE_KEY);
             $this->info("index-term finished. total: {$curr}");
 
             return 0;

+ 2 - 2
api-v13/app/Console/Commands/IndexTipitaka.php

@@ -2,6 +2,7 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Http\Api\ChannelApi;
 use App\Models\PaliText;
 use App\Models\ProgressChapter;
@@ -12,7 +13,6 @@ use App\Services\SearchPaliDataService;
 use App\Services\SummaryService;
 use App\Services\TagService;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 
 class IndexTipitaka extends Command
@@ -395,7 +395,7 @@ class IndexTipitaka extends Command
             $document['title']['text']['pali'] = $title;
         }
         $document['content']['display'] = $param['content'];             // 展示
-        Cache::put($docId, $param['content']);
+        RedisCache::put($docId, $param['content']);
 
         if ($this->isTest) {
             $this->info($param['content']);

+ 6 - 6
api-v13/app/Console/Commands/MqAiTranslate.php

@@ -2,11 +2,11 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Http\Api\Mq;
 use App\Tools\Tools;
 use Illuminate\Console\Command;
 use Illuminate\Http\Client\RequestException;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Log;
 
@@ -63,12 +63,12 @@ class MqAiTranslate extends Command
             // 获取model token
             $first = $messages[0];
             $taskId = $first->task->info->id;
-            Cache::put("/task/{$taskId}/message_id", $messageId);
+            RedisCache::put("/task/{$taskId}/message_id", $messageId);
             $pointerKey = "/message/{$messageId}/pointer";
             $pointer = 0;
-            if (Cache::has($pointerKey)) {
+            if (RedisCache::has($pointerKey)) {
                 // 回到上次中断的点
-                $pointer = Cache::get($pointerKey);
+                $pointer = RedisCache::get($pointerKey);
             }
 
             $modelToken = $first->model->token;
@@ -95,7 +95,7 @@ class MqAiTranslate extends Command
             }
 
             for ($i = $pointer; $i < count($messages); $i++) {
-                Cache::put($pointerKey, $i);
+                RedisCache::put($pointerKey, $i);
                 $message = $messages[$i];
                 $taskDiscussionContent = [];
                 $param = [
@@ -326,7 +326,7 @@ class MqAiTranslate extends Command
                     $this->setTaskStatus($message->task->info->id, 'done', $modelToken);
                 }
             }
-            Cache::forget($pointerKey);
+            RedisCache::forget($pointerKey);
             $this->info('ai translate task complete');
 
             return 0;

+ 3 - 3
api-v13/app/Console/Commands/RemoveTermCache.php

@@ -2,10 +2,10 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Models\Channel;
 use App\Tools\Tools;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 
 class RemoveTermCache extends Command
 {
@@ -50,9 +50,9 @@ class RemoveTermCache extends Command
         } else {
             foreach ($channels as $key => $channel) {
                 $key = "/term/{$channel}/{$word}";
-                if (Cache::has($key)) {
+                if (RedisCache::has($key)) {
                     $this->info('has:'.$key);
-                    Cache::forget($key);
+                    RedisCache::forget($key);
                 }
             }
         }

+ 2 - 2
api-v13/app/Console/Commands/StatisticsNissayaCover.php

@@ -2,12 +2,12 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Models\Channel;
 use App\Models\PaliSentence;
 use App\Models\Sentence;
 use App\Tools\Tools;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 
 class StatisticsNissayaCover extends Command
 {
@@ -353,7 +353,7 @@ class StatisticsNissayaCover extends Command
             $this->info($type.'='.$pali.'='.$final);
             $output[] = ['type' => $type, 'total' => $pali, 'final' => $final];
         }
-        Cache::put('/statistics/nissaya/cover', $output, 48 * 3600);
+        RedisCache::put('/statistics/nissaya/cover', $output, 48 * 3600);
 
         return 0;
     }

+ 7 - 21
api-v13/app/Console/Commands/TestRedis.php

@@ -2,8 +2,8 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Redis;
 
 class TestRedis extends Command
@@ -71,10 +71,10 @@ class TestRedis extends Command
         $this->info('testing cache put');
         $key = 'cache-key';
 
-        Cache::put($key, $value, 1000);
-        if (Cache::has($key)) {
+        RedisCache::put($key, $value, 1000);
+        if (RedisCache::has($key)) {
             $this->info("cache::put() exist key={$key}");
-            if (Cache::get($key) == $value) {
+            if (RedisCache::get($key) == $value) {
                 $this->info("cache::get() ok value={$value}");
             } else {
                 $this->error('cache::get() fail ');
@@ -83,27 +83,13 @@ class TestRedis extends Command
             $this->error('no key cache-key');
         }
 
-        $key = 'cache-key-2';
-        $this->info('testing cache() function');
-        $this->info("cache() key={$key} value={$value}");
-        cache(['cache-key-2' => $value]);
-        if (Cache::has($key)) {
-            if (Cache::get($key) == $value) {
-                $this->info("cache() get ok value={$value}");
-            } else {
-                $this->error('cache::get() fail ');
-            }
-        } else {
-            $this->error('no key cache-key-2');
-        }
-
         $key = 'cache-key-3';
         $this->info('testing cache remember()');
-        $value = Cache::remember($key, 600, function () {
+        $value = RedisCache::remember($key, 600, function () {
             return 'cache-value-3';
         });
-        if (Cache::has($key)) {
-            $this->info("{$key} exist value=".Cache::get($key));
+        if (RedisCache::has($key)) {
+            $this->info("{$key} exist value=".RedisCache::get($key));
         } else {
             $this->error('cache::remember() fail.');
         }

+ 5 - 5
api-v13/app/Console/Commands/UpgradeAITerm.php

@@ -2,13 +2,13 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Http\Resources\AiModelResource;
 use App\Services\AIAssistant\AITermService;
 use App\Services\AIModelService;
 use App\Services\AuthService;
 use App\Services\TermService;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 
 class UpgradeAITerm extends Command
 {
@@ -82,7 +82,7 @@ class UpgradeAITerm extends Command
         $this->aiTermService->setThinking($this->thinking);
 
         if ($this->option('fresh')) {
-            Cache::forget(self::CURSOR_KEY);
+            RedisCache::forget(self::CURSOR_KEY);
             $this->info('cleared checkpoint');
         }
 
@@ -114,7 +114,7 @@ class UpgradeAITerm extends Command
         $this->info("{$total} terms");
 
         $startIndex = 0;
-        $cursor = Cache::get(self::CURSOR_KEY, []);
+        $cursor = RedisCache::get(self::CURSOR_KEY, []);
         if (! empty($cursor)) {
             $pos = array_search($cursor['word'] ?? null, $terms, true);
             $startIndex = $pos !== false ? (int) $pos + 1 : (int) ($cursor['index'] ?? -1) + 1;
@@ -128,11 +128,11 @@ class UpgradeAITerm extends Command
             $this->info("[{$term}]content ".substr($result, 0, 30));
 
             // 该术语完成后写入断点(中途中断时不会误标记未完成的术语)
-            Cache::put(self::CURSOR_KEY, ['index' => $key, 'word' => $term], now()->addHours(48));
+            RedisCache::put(self::CURSOR_KEY, ['index' => $key, 'word' => $term], now()->addHours(48));
         }
 
         // 全部完成,清空断点
-        Cache::forget(self::CURSOR_KEY);
+        RedisCache::forget(self::CURSOR_KEY);
         $this->info('all terms completed');
     }
 }

+ 5 - 5
api-v13/app/Console/Commands/UpgradeAITranslation.php

@@ -2,6 +2,7 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Helpers\LlmResponseParser;
 use App\Http\Api\ChannelApi;
 use App\Http\Resources\AiModelResource;
@@ -14,7 +15,6 @@ use App\Services\AuthService;
 use App\Services\OpenAIService;
 use App\Services\SentenceService;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 
 class UpgradeAITranslation extends Command
@@ -146,7 +146,7 @@ class UpgradeAITranslation extends Command
         $cacheKey = self::CACHE_KEY_PREFIX.':'.$type.':'.$channelId;
 
         if ($this->option('fresh')) {
-            // Cache::forget($cacheKey);
+            // RedisCache::forget($cacheKey);
             $this->info('Cleared cached cursor.');
         }
 
@@ -154,7 +154,7 @@ class UpgradeAITranslation extends Command
         $isFullRun = ! $this->option('book') && ! $this->option('para');
 
         // 从缓存恢复已完成的 (book, para) 集合,作为重入时的稳定游标
-        $done = []; // Cache::get($cacheKey, []);
+        $done = []; // RedisCache::get($cacheKey, []);
 
         $books = [];
         if ($this->option('book')) {
@@ -209,7 +209,7 @@ class UpgradeAITranslation extends Command
                 $this->info($this->argument('type')." {$book}-{$paragraph} ".count($data).' sentences time='.$time);
                 // 该处理单元全部写库完成后再标记游标,确保中途中断不会误跳过
                 $done[$cursor] = true;
-                // Cache::put($cacheKey, $done, now()->addHours(24));
+                // RedisCache::put($cacheKey, $done, now()->addHours(24));
             }
 
             $param = [
@@ -236,7 +236,7 @@ class UpgradeAITranslation extends Command
 
         // 完整遍历正常结束,清空断点缓存
         if ($isFullRun) {
-            // Cache::forget($cacheKey);
+            // RedisCache::forget($cacheKey);
         }
 
         return 0;

+ 5 - 5
api-v13/app/Console/Commands/UpgradeChapterDynamicWeekly.php

@@ -2,13 +2,13 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Models\PaliText;
 use App\Models\ProgressChapter;
 use App\Models\SentHistory;
 use App\Tools\Tools;
 use Illuminate\Console\Command;
 use Illuminate\Support\Carbon;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 
 class UpgradeChapterDynamicWeekly extends Command
@@ -89,13 +89,13 @@ class UpgradeChapterDynamicWeekly extends Command
                 $progress[] = $count;
             }
             $key = "/chapter_dynamic/{$chapter->book}/{$chapter->para}/global";
-            Cache::put($key, $progress, 3600 * 24 * 7);
+            RedisCache::put($key, $progress, 3600 * 24 * 7);
 
             $bar->advance();
 
             if ($this->option('test')) {
                 $this->info("key:{$key}");
-                if (Cache::has($key)) {
+                if (RedisCache::has($key)) {
                     $this->info('has key '.$key);
                 }
                 break; // 调试代码
@@ -146,12 +146,12 @@ class UpgradeChapterDynamicWeekly extends Command
                 $progress[] = $count;
             }
             $key = "/chapter_dynamic/{$chapter->book}/{$chapter->para}/ch_{$chapter->channel_id}";
-            Cache::put($key, $progress, 3600 * 24 * 7);
+            RedisCache::put($key, $progress, 3600 * 24 * 7);
             $bar->advance();
 
             if ($this->option('test')) {
                 $this->info("key:{$key}");
-                if (Cache::has($key)) {
+                if (RedisCache::has($key)) {
                     $this->info('has key '.$key);
                 }
                 break; // 调试代码

+ 2 - 2
api-v13/app/Console/Commands/UpgradeDict.php

@@ -2,11 +2,11 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Models\DictInfo;
 use App\Models\UserDict;
 use App\Tools\Tools;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Str;
 
@@ -132,7 +132,7 @@ class UpgradeDict extends Command
                                                         if (isset($newPart[$part])) {
                                                             $newPart[$part][0]++;
                                                         } else {
-                                                            $partExists = Cache::remember('dict/part/'.$part, config('cache.expire', 1000), function () use ($part) {
+                                                            $partExists = RedisCache::remember('dict/part/'.$part, config('cache.expire', 1000), function () use ($part) {
                                                                 return UserDict::where('word', $part)->exists();
                                                             });
                                                             if (! $partExists) {

+ 3 - 3
api-v13/app/Console/Commands/UpgradeDictDefaultMeaning.php

@@ -11,10 +11,10 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Models\UserDict;
 use App\Tools\Tools;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 
 class UpgradeDictDefaultMeaning extends Command
@@ -117,7 +117,7 @@ class UpgradeDictDefaultMeaning extends Command
                     ->cursor() as $word
             ) {
                 if (! empty($word['note'])) {
-                    Cache::put("dict_first_mean/{$thisLang}/{$word['word']}", mb_substr($word['note'], 0, 50, 'UTF-8'));
+                    RedisCache::put("dict_first_mean/{$thisLang}/{$word['word']}", mb_substr($word['note'], 0, 50, 'UTF-8'));
                 }
                 $bar->advance();
             }
@@ -138,7 +138,7 @@ class UpgradeDictDefaultMeaning extends Command
                         if (! empty($_word) && $word['word'] === $_word) {
                             Log::info($cacheKey.':'.$cacheValue);
                         }
-                        Cache::put($cacheKey, $cacheValue);
+                        RedisCache::put($cacheKey, $cacheValue);
                     }
 
                     if ($count % 1000 === 0) {

+ 5 - 5
api-v13/app/Console/Commands/UpgradeProgress.php

@@ -2,10 +2,10 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use Illuminate\Console\Attributes\Description;
 use Illuminate\Console\Attributes\Signature;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 
 #[Signature('app:upgrade-progress {--fresh : Clear cached progress and start from scratch}')]
 #[Description('Run upgrade progress commands (reentrant via Cache)')]
@@ -17,11 +17,11 @@ class UpgradeProgress extends Command
     public function handle(): int
     {
         if ($this->option('fresh')) {
-            Cache::forget(self::CACHE_KEY);
+            RedisCache::forget(self::CACHE_KEY);
             $this->info('Cleared cached progress.');
         }
 
-        $completedSteps = Cache::get(self::CACHE_KEY, []);
+        $completedSteps = RedisCache::get(self::CACHE_KEY, []);
 
         $steps = [
             'para' => 'upgrade:progress.para',
@@ -45,10 +45,10 @@ class UpgradeProgress extends Command
             }
 
             $completedSteps[] = $key;
-            Cache::put(self::CACHE_KEY, $completedSteps, now()->addHours(48));
+            RedisCache::put(self::CACHE_KEY, $completedSteps, now()->addHours(48));
         }
 
-        Cache::forget(self::CACHE_KEY);
+        RedisCache::forget(self::CACHE_KEY);
         $this->info('All steps completed.');
 
         return 0;

+ 5 - 5
api-v13/app/Console/Commands/UpgradeProgressChapter.php

@@ -2,6 +2,7 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Http\Api\MdRender;
 use App\Models\Channel;
 use App\Models\PaliSentence;
@@ -15,7 +16,6 @@ use App\Tools\Markdown;
 use App\Tools\Tools;
 use Carbon\Carbon;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Validator;
 
 class UpgradeProgressChapter extends Command
@@ -36,7 +36,7 @@ class UpgradeProgressChapter extends Command
         }
 
         if ($this->option('fresh')) {
-            Cache::forget(self::CACHE_KEY);
+            RedisCache::forget(self::CACHE_KEY);
             $this->info('Cleared cached cursor.');
         }
 
@@ -61,7 +61,7 @@ class UpgradeProgressChapter extends Command
         $books = $this->buildBookList($book);
 
         // 从缓存恢复断点:跳过上次已完成的 book
-        $lastBookId = Cache::get(self::CACHE_KEY);
+        $lastBookId = RedisCache::get(self::CACHE_KEY);
         if ($lastBookId && ! $book) {
             $books = $books->filter(fn ($b) => $b->book_id > $lastBookId)->values();
             $this->info("Resuming after book={$lastBookId}");
@@ -112,11 +112,11 @@ class UpgradeProgressChapter extends Command
             }
 
             // 每完成一本书,保存断点
-            Cache::put(self::CACHE_KEY, $bookRow->book_id, now()->addHours(48));
+            RedisCache::put(self::CACHE_KEY, $bookRow->book_id, now()->addHours(48));
         }
 
         // 全部完成,清除断点缓存
-        Cache::forget(self::CACHE_KEY);
+        RedisCache::forget(self::CACHE_KEY);
 
         $time = time() - $startTime;
         $this->info("upgrade:progress.chapter finished in {$time}s tag count:{$tagCount}");

+ 5 - 5
api-v13/app/Console/Commands/UpgradeProgressPara.php

@@ -2,12 +2,12 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Models\PaliSentence;
 use App\Models\Progress;
 use App\Models\Sentence;
 use App\Tools\Tools;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 
 class UpgradeProgressPara extends Command
@@ -26,7 +26,7 @@ class UpgradeProgressPara extends Command
         }
 
         if ($this->option('fresh')) {
-            Cache::forget(self::CACHE_KEY);
+            RedisCache::forget(self::CACHE_KEY);
             $this->info('Cleared cached cursor.');
         }
 
@@ -45,7 +45,7 @@ class UpgradeProgressPara extends Command
         $sentences = $this->buildQuery($book, $para, $channelId);
 
         // 从缓存恢复断点:跳过上次已处理的记录
-        $cursor = Cache::get(self::CACHE_KEY);
+        $cursor = RedisCache::get(self::CACHE_KEY);
         if ($cursor && ! $this->option('book')) {
             $sentences = $this->applyResumeFilter($sentences, $cursor);
             $this->info("Resuming from book={$cursor['book']}, para={$cursor['para']}");
@@ -95,7 +95,7 @@ class UpgradeProgressPara extends Command
 
             // 每 500 条保存一次断点到缓存
             if ($curr % 500 === 0) {
-                Cache::put(self::CACHE_KEY, [
+                RedisCache::put(self::CACHE_KEY, [
                     'book' => $sentence->book_id,
                     'para' => $sentence->paragraph,
                 ], now()->addHours(48));
@@ -107,7 +107,7 @@ class UpgradeProgressPara extends Command
         }
 
         // 全部完成,清除断点缓存
-        Cache::forget(self::CACHE_KEY);
+        RedisCache::forget(self::CACHE_KEY);
 
         $time = time() - $startTime;
         $this->info("upgrade:progress.para finished in {$time}s");

+ 5 - 5
api-v13/app/Console/Commands/UpgradeSystemCommentary.php

@@ -2,6 +2,7 @@
 
 namespace App\Console\Commands;
 
+use App\Facades\RedisCache;
 use App\Helpers\LlmResponseParser;
 use App\Http\Api\ChannelApi;
 use App\Http\Resources\AiModelResource;
@@ -16,7 +17,6 @@ use App\Services\OpenAIService;
 use App\Services\SearchPaliDataService;
 use App\Services\SentenceService;
 use Illuminate\Console\Command;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 
 class UpgradeSystemCommentary extends Command
@@ -124,7 +124,7 @@ class UpgradeSystemCommentary extends Command
         }
 
         if ($this->option('fresh')) {
-            Cache::forget(self::CACHE_KEY);
+            RedisCache::forget(self::CACHE_KEY);
             $this->info('Cleared cached cursor.');
         }
 
@@ -137,7 +137,7 @@ class UpgradeSystemCommentary extends Command
         $isFullRun = ! $this->option('book') && ! $this->option('para');
 
         // 从缓存恢复已完成的 (book_name, cs_para) 集合,作为重入时的稳定游标
-        $done = Cache::get(self::CACHE_KEY, []);
+        $done = RedisCache::get(self::CACHE_KEY, []);
 
         // 需要跳过的 book_name 规则,逗号分隔,以 * 结尾为前缀匹配,否则全等匹配
         $skipPatterns = [];
@@ -294,13 +294,13 @@ class UpgradeSystemCommentary extends Command
 
                 // 该处理单元全部写库完成后再标记游标,确保中途中断不会误跳过
                 $done[$cursor] = true;
-                Cache::put(self::CACHE_KEY, $done, now()->addHours(24));
+                RedisCache::put(self::CACHE_KEY, $done, now()->addHours(24));
             }
         }
 
         // 完整遍历正常结束,清空断点缓存
         if ($isFullRun) {
-            Cache::forget(self::CACHE_KEY);
+            RedisCache::forget(self::CACHE_KEY);
         }
 
         return 0;

+ 36 - 0
api-v13/app/Facades/RedisCache.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Facades;
+
+use App\Services\RedisCacheFake;
+use Illuminate\Support\Facades\Facade;
+
+/**
+ * 业务代码用的缓存门面,取代 Illuminate\Support\Facades\Cache。
+ *
+ * @method static mixed get(string $key, mixed $default = null)
+ * @method static bool put(string $key, mixed $value, \DateTimeInterface|\DateInterval|int|null $ttl = null)
+ * @method static mixed remember(string $key, \DateTimeInterface|\DateInterval|int|null $ttl, \Closure $callback)
+ * @method static bool has(string $key)
+ * @method static bool forget(string $key)
+ * @method static int increment(string $key, int $value = 1)
+ *
+ * @see \App\Services\RedisCache
+ */
+class RedisCache extends Facade
+{
+    /**
+     * 换成内存实现,测试用。
+     */
+    public static function fake(): RedisCacheFake
+    {
+        static::swap($fake = new RedisCacheFake);
+
+        return $fake;
+    }
+
+    protected static function getFacadeAccessor(): string
+    {
+        return \App\Services\RedisCache::class;
+    }
+}

+ 5 - 5
api-v13/app/Http/Api/TaskApi.php

@@ -2,9 +2,9 @@
 
 namespace App\Http\Api;
 
+use App\Facades\RedisCache;
 use App\Models\Task;
 use App\Models\TaskRelation;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Str;
 
 class TaskApi
@@ -85,8 +85,8 @@ class TaskApi
     public static function getRelationTasks($taskId, $relation = 'pre')
     {
         $key = TaskApi::taskRelationRedisKey($taskId, $relation);
-        // Log::debug('task redis key=' . $key . ' has=' . Cache::has($key));
-        $data = Cache::remember($key, 24 * 3600, function () use ($taskId, $relation) {
+        // Log::debug('task redis key=' . $key . ' has=' . RedisCache::has($key));
+        $data = RedisCache::remember($key, 24 * 3600, function () use ($taskId, $relation) {
             if ($relation === 'pre') {
                 $where = 'next_task_id';
                 $select = 'task_id';
@@ -131,9 +131,9 @@ class TaskApi
         }
         foreach ($relationsId as $taskId => $value) {
             $key = TaskApi::taskRelationRedisKey($taskId, 'pre');
-            Cache::forget($key);
+            RedisCache::forget($key);
             $key = TaskApi::taskRelationRedisKey($taskId, 'next');
-            Cache::forget($key);
+            RedisCache::forget($key);
         }
     }
 

+ 2 - 2
api-v13/app/Http/Api/TemplateRender.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Api;
 
+use App\Facades\RedisCache;
 use App\Http\Controllers\CorpusController;
 use App\Models\BookTitle as BookSeries;
 use App\Models\Channel;
@@ -11,7 +12,6 @@ use App\Models\PageNumber;
 use App\Models\PaliText;
 use App\Services\ArticleService;
 use App\Tools\Tools;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
 
@@ -832,7 +832,7 @@ class TemplateRender
     {
         $paraId = $this->get_param($this->param, 'para', 1);
         $channelId = $this->channel_id[0];
-        $props = Cache::remember(
+        $props = RedisCache::remember(
             "/quote/{$channelId}/{$paraId}",
             config('mint.cache.expire'),
             function () use ($paraId, $channelId) {

+ 2 - 2
api-v13/app/Http/Controllers/BookTitleController.php

@@ -2,12 +2,12 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use App\Http\Resources\BookTitleResource;
 use App\Models\BookTitle;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
 use Illuminate\Support\Collection;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 
 class BookTitleController extends Controller
@@ -28,7 +28,7 @@ class BookTitleController extends Controller
     public function index()
     {
         //
-        $data = Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function () {
+        $data = RedisCache::remember(self::CACHE_KEY, self::CACHE_TTL, function () {
             $result = BookTitle::orderBy('sn')->get();
             $meta = $this->paliTextMeta($result);
             $relatedNames = $this->relatedNames($result);

+ 3 - 3
api-v13/app/Http/Controllers/CorpusController.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use App\Http\Api\ChannelApi;
 use App\Http\Api\MdRender;
 use App\Http\Api\StudioApi;
@@ -22,7 +23,6 @@ use App\Services\PaliContentService;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
 use Illuminate\Support\Arr;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Redis;
 use Illuminate\Support\Str;
@@ -768,7 +768,7 @@ class CorpusController extends Controller
 
                             break;
                         case 'nissaya':
-                            $newSent['html'] = Cache::remember(
+                            $newSent['html'] = RedisCache::remember(
                                 "/sent/{$channelId}/{$currSentId}/{$format}",
                                 config('mint.cache.expire'),
                                 function () use ($row, $mode, $format) {
@@ -1055,7 +1055,7 @@ class CorpusController extends Controller
         } else {
             $keyCanRead .= 'guest';
         }
-        $channelCanRead = Cache::remember(
+        $channelCanRead = RedisCache::remember(
             $keyCanRead,
             config('mint.cache.expire'),
             function () use ($userUuid) {

+ 6 - 6
api-v13/app/Http/Controllers/DhammaTermController.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use App\Http\Api\ChannelApi;
 use App\Http\Api\ShareApi;
 use App\Http\Api\StudioApi;
@@ -14,7 +15,6 @@ use App\Services\AuthService;
 use App\Services\TermIndexService;
 use App\Tools\Tools;
 use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
@@ -192,7 +192,7 @@ class DhammaTermController extends Controller
                 break;
             case 'hot-meaning':
                 $key = 'term/hot_meaning';
-                $value = Cache::remember($key, config('mint.cache.expire'), function () use ($request) {
+                $value = RedisCache::remember($key, config('mint.cache.expire'), function () use ($request) {
                     $hotMeaning = [];
                     $words = DhammaTerm::select('word')
                         ->where('language', $request->input('language'))
@@ -218,9 +218,9 @@ class DhammaTermController extends Controller
                             ];
                         }
                     }
-                    // FIXME: 外层 Cache::remember 已负责写缓存,这里重复写入且 TTL(3600)
+                    // FIXME: 外层 RedisCache::remember 已负责写缓存,这里重复写入且 TTL(3600)
                     // 与 config('mint.cache.expire') 不一致;应删除这一行。
-                    Cache::put($key, $hotMeaning, 3600);
+                    RedisCache::put($key, $hotMeaning, 3600);
 
                     return $hotMeaning;
                 });
@@ -404,10 +404,10 @@ class DhammaTermController extends Controller
             foreach ($channels as $channel) {
                 // FIXME: $channel 是 Eloquent 模型对象,插值后得到的是 JSON 而非 uid,
                 // 缓存键与写入时的键对不上,studio 级术语的缓存实际没被清掉;应用 $channel->uid。
-                Cache::forget("/term/{$channel}/{$term->word}");
+                RedisCache::forget("/term/{$channel}/{$term->word}");
             }
         } else {
-            Cache::forget("/term/{$term->channal}/{$term->word}");
+            RedisCache::forget("/term/{$term->channal}/{$term->word}");
         }
     }
 

+ 2 - 2
api-v13/app/Http/Controllers/DictMeaningController.php

@@ -2,10 +2,10 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use App\Models\UserDict;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
-use Illuminate\Support\Facades\Cache;
 
 class DictMeaningController extends Controller
 {
@@ -71,7 +71,7 @@ class DictMeaningController extends Controller
             foreach ($this->langOrder[$lang] as $key => $value) {
                 // 遍历每种语言。找到返回
                 $cacheKey = "dict_first_mean/{$value}/{$word}";
-                $meaning = Cache::get($cacheKey);
+                $meaning = RedisCache::get($cacheKey);
                 if (! empty($meaning)) {
                     $currMeaning = $meaning;
                     break;

+ 2 - 2
api-v13/app/Http/Controllers/EmailCertificationController.php

@@ -2,13 +2,13 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use App\Http\Resources\InviteResource;
 use App\Mail\EmailCertif;
 use App\Models\Invite;
 use App\Models\UserInfo;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Mail;
 use Illuminate\Support\Str;
@@ -74,7 +74,7 @@ class EmailCertificationController extends Controller
     public function show(string $id)
     {
         //
-        $code = Cache::get('/email/certification/'.$id);
+        $code = RedisCache::get('/email/certification/'.$id);
         if (empty($code)) {
             return $this->error('Certification is avalide', 200, 200);
         }

+ 3 - 3
api-v13/app/Http/Controllers/HealthCheckController.php

@@ -2,11 +2,11 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use App\Services\OpenSearchService;
 use App\Services\RabbitMQService;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 
@@ -34,8 +34,8 @@ class HealthCheckController extends Controller
 
         // Cache
         try {
-            Cache::put('health_check', true, 5);
-            Cache::get('health_check');
+            RedisCache::put('health_check', true, 5);
+            RedisCache::get('health_check');
             $checks['cache'] = true;
         } catch (\Throwable $e) {
             $checks['cache'] = false;

+ 2 - 2
api-v13/app/Http/Controllers/NissayaCoverController.php

@@ -2,9 +2,9 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
-use Illuminate\Support\Facades\Cache;
 
 class NissayaCoverController extends Controller
 {
@@ -16,7 +16,7 @@ class NissayaCoverController extends Controller
     public function index()
     {
         //
-        $cover = Cache::get('/statistics/nissaya/cover');
+        $cover = RedisCache::get('/statistics/nissaya/cover');
 
         return $this->ok($cover);
     }

+ 2 - 2
api-v13/app/Http/Controllers/PaliTextController.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use App\Http\Resources\PaliTextResource;
 use App\Models\BookTitle;
 use App\Models\PaliText;
@@ -10,7 +11,6 @@ use App\Models\TagMap;
 use App\Services\PaliTextService;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 
 class PaliTextController extends Controller
@@ -310,7 +310,7 @@ class PaliTextController extends Controller
                 if (is_object($value)) {
                     // TODO $value->book 可能不存在
                     $progress_key = "/chapter_dynamic/{$value->book}/{$value->paragraph}/global";
-                    $chapters[$key]->progress_line = Cache::get($progress_key);
+                    $chapters[$key]->progress_line = RedisCache::get($progress_key);
                 }
             }
         }

+ 2 - 2
api-v13/app/Http/Controllers/ProgressChapterController.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use App\Http\Api\StudioApi;
 use App\Models\Channel;
 use App\Models\Like;
@@ -13,7 +14,6 @@ use App\Models\View;
 use App\Services\AuthService;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Str;
 
@@ -241,7 +241,7 @@ class ProgressChapterController extends Controller
                     $chapters[$key]->studio = StudioApi::getById($value->owner_uid);
                     $chapters[$key]->channel = ['uid' => $value->channel_id, 'name' => $value->name, 'type' => $value->type];
                     $progress_key = "/chapter_dynamic/{$value->book}/{$value->para}/ch_{$value->channel_id}";
-                    $chapters[$key]->progress_line = Cache::get($progress_key);
+                    $chapters[$key]->progress_line = RedisCache::get($progress_key);
                 }
 
                 $all_count = count($chapters);

+ 3 - 3
api-v13/app/Http/Controllers/ProgressImgController.php

@@ -2,9 +2,9 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
-use Illuminate\Support\Facades\Cache;
 
 class ProgressImgController extends Controller
 {
@@ -39,10 +39,10 @@ class ProgressImgController extends Controller
         //
         return response()->stream(function () use ($id) {
             $key = str_replace('-', '/', $id);
-            $svg = Cache::remember('svg/'.$key, config('mint.cache.expire'), function () use ($key) {
+            $svg = RedisCache::remember('svg/'.$key, config('mint.cache.expire'), function () use ($key) {
                 $viewHeight = 60;
                 $svg = "<svg xmlns='http://www.w3.org/2000/svg'  fill='currentColor' viewBox='0 0 300 60'>";
-                $data = Cache::get($key);
+                $data = RedisCache::get($key);
                 if (is_array($data)) {
                     $point = [];
                     foreach ($data as $key => $value) {

+ 6 - 6
api-v13/app/Http/Controllers/RelationController.php

@@ -2,13 +2,13 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use App\Http\Resources\RelationResource;
 use App\Models\Relation;
 use App\Services\AuthService;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
-use Illuminate\Support\Facades\Cache;
 use PhpOffice\PhpSpreadsheet\Spreadsheet;
 use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
 
@@ -30,7 +30,7 @@ class RelationController extends Controller
     {
         if ($request->boolean('vocabulary')) {
             return $this->ok(
-                Cache::remember(
+                RedisCache::remember(
                     self::VOCABULARY_CACHE_KEY,
                     config('mint.cache.expire'),
                     fn () => $this->buildVocabularyPayload()
@@ -67,7 +67,7 @@ class RelationController extends Controller
         $relation->editor_id = $user['user_uid'];
         $relation->save();
 
-        Cache::forget(self::VOCABULARY_CACHE_KEY);
+        RedisCache::forget(self::VOCABULARY_CACHE_KEY);
 
         return $this->ok(new RelationResource($relation));
     }
@@ -101,7 +101,7 @@ class RelationController extends Controller
         $relation->editor_id = $user['user_uid'];
         $relation->save();
 
-        Cache::forget(self::VOCABULARY_CACHE_KEY);
+        RedisCache::forget(self::VOCABULARY_CACHE_KEY);
 
         return $this->ok(new RelationResource($relation));
     }
@@ -120,7 +120,7 @@ class RelationController extends Controller
 
         $deleted = $relation->delete();
 
-        Cache::forget(self::VOCABULARY_CACHE_KEY);
+        RedisCache::forget(self::VOCABULARY_CACHE_KEY);
 
         return $this->ok($deleted);
     }
@@ -204,7 +204,7 @@ class RelationController extends Controller
             $currLine++;
         }
 
-        Cache::forget(self::VOCABULARY_CACHE_KEY);
+        RedisCache::forget(self::VOCABULARY_CACHE_KEY);
 
         $success = $currLine - 2 - $countFail;
 

+ 6 - 6
api-v13/app/Http/Controllers/SentenceController.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use App\Http\Api\ChannelApi;
 use App\Http\Api\Mq;
 use App\Http\Api\PaliTextApi;
@@ -16,7 +17,6 @@ use App\Services\SentenceService;
 use App\Tools\OpsLog;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Redis;
 use Illuminate\Support\Str;
 
@@ -496,7 +496,7 @@ class SentenceController extends Controller
         // 清除cache
         $channelId = $param[4];
         $currSentId = "{$param[0]}-{$param[1]}-{$param[2]}-{$param[3]}";
-        Cache::forget("/sent/{$channelId}/{$currSentId}");
+        RedisCache::forget("/sent/{$channelId}/{$currSentId}");
         // 保存历史记录
         if ($request->has('prEditor')) {
             $this->sentenceService->saveHistory(
@@ -564,8 +564,8 @@ class SentenceController extends Controller
             if (! empty($word->meaning->value)) {
                 $newData['data'] = $word->meaning->value;
                 WbwAnalysis::insert($newData);
-                Cache::put("{$prefix}/{$word->real->value}/3/{$editorId}", $word->meaning->value);
-                Cache::put("{$prefix}/{$word->real->value}/3/0", $word->meaning->value);
+                RedisCache::put("{$prefix}/{$word->real->value}/3/{$editorId}", $word->meaning->value);
+                RedisCache::put("{$prefix}/{$word->real->value}/3/0", $word->meaning->value);
             }
             if (isset($word->factors) && isset($word->factorMeaning)) {
                 $factors = explode('+', str_replace('-', '+', $word->factors->value));
@@ -577,8 +577,8 @@ class SentenceController extends Controller
                             $newData['data'] = $factorMeaning[$key];
                             $newData['type'] = 5;
                             WbwAnalysis::insert($newData);
-                            Cache::put("{$prefix}/{$factor}/5/{$editorId}", $factorMeaning[$key]);
-                            Cache::put("{$prefix}/{$factor}/5/0", $factorMeaning[$key]);
+                            RedisCache::put("{$prefix}/{$factor}/5/{$editorId}", $factorMeaning[$key]);
+                            RedisCache::put("{$prefix}/{$factor}/5/0", $factorMeaning[$key]);
                         }
                     }
                 }

+ 2 - 2
api-v13/app/Http/Controllers/SentenceInfoController.php

@@ -2,13 +2,13 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use App\Http\Resources\SentResource;
 use App\Models\PaliSentence;
 use App\Models\PaliText;
 use App\Models\Sentence;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
-use Illuminate\Support\Facades\Cache;
 
 class SentenceInfoController extends Controller
 {
@@ -114,7 +114,7 @@ class SentenceInfoController extends Controller
             foreach ($sentFinished as $sent) {
                 // code...
                 $key_sent_id = $sent->book_id.'-'.$sent->paragraph.'-'.$sent->word_start.'-'.$sent->word_end;
-                $para_strlen += Cache::remember(
+                $para_strlen += RedisCache::remember(
                     'pali-sent/strlen/'.$key_sent_id,
                     config('mint.cache.expire'),
                     function () use ($sent) {

+ 3 - 3
api-v13/app/Http/Controllers/SysModelController.php

@@ -2,12 +2,12 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use App\Http\Resources\AiModelResource;
 use App\Models\AiModel;
 use App\Services\AuthService;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
-use Illuminate\Support\Facades\Cache;
 
 class SysModelController extends Controller
 {
@@ -25,7 +25,7 @@ class SysModelController extends Controller
         if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
         }
-        $modelsId = Cache::get($this->key.$request->input('view', 'wbw'));
+        $modelsId = RedisCache::get($this->key.$request->input('view', 'wbw'));
         if (! is_array($modelsId)) {
             $modelsId = [];
         }
@@ -52,7 +52,7 @@ class SysModelController extends Controller
         if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
         }
-        Cache::put(
+        RedisCache::put(
             $this->key.$request->input('view', 'wbw'),
             $request->input('models')
         );

+ 9 - 9
api-v13/app/Http/Controllers/UserStatisticController.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use App\Http\Api\UserApi;
 use App\Models\DhammaTerm;
 use App\Models\Sentence;
@@ -11,7 +12,6 @@ use App\Models\UserOperationLog;
 use App\Models\Wbw;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
-use Illuminate\Support\Facades\Cache;
 
 class UserStatisticController extends Controller
 {
@@ -68,7 +68,7 @@ class UserStatisticController extends Controller
         $myDictCount = 0;
         // 总经验值
         if (! $request->has('view') || $request->input('view') === 'exp-sum') {
-            $expSum = Cache::remember(
+            $expSum = RedisCache::remember(
                 "user/{$userName}/exp/sum",
                 $cacheExpiry,
                 function () use ($queryUserId) {
@@ -80,7 +80,7 @@ class UserStatisticController extends Controller
 
         // 逐词解析
         if (! $request->has('view') || $request->input('view') === 'wbw-count') {
-            $wbwCount = Cache::remember(
+            $wbwCount = RedisCache::remember(
                 "user/{$userName}/wbw/count",
                 $cacheExpiry,
                 function () use ($queryUserId) {
@@ -92,7 +92,7 @@ class UserStatisticController extends Controller
 
         // 查字典次数
         if (! $request->has('view') || $request->input('view') === 'lookup-count') {
-            $lookupCount = Cache::remember(
+            $lookupCount = RedisCache::remember(
                 "user/{$userName}/lookup/count",
                 $cacheExpiry,
                 function () use ($queryUserId) {
@@ -105,7 +105,7 @@ class UserStatisticController extends Controller
         // 译文
         // TODO 判断是否是译文channel
         if (! $request->has('view') || $request->input('view') === 'translation-count') {
-            $translationCount = Cache::remember(
+            $translationCount = RedisCache::remember(
                 "user/{$userName}/translation/count",
                 $cacheExpiry,
                 function () use ($queryUserUuid) {
@@ -113,7 +113,7 @@ class UserStatisticController extends Controller
                         ->count();
                 }
             );
-            $translationCountPub = Cache::remember(
+            $translationCountPub = RedisCache::remember(
                 "user/{$userName}/translation/count-pub",
                 $cacheExpiry,
                 function () use ($queryUserUuid) {
@@ -125,7 +125,7 @@ class UserStatisticController extends Controller
         }
         // 术语
         if (! $request->has('view') || $request->input('view') === 'term-count') {
-            $termCount = Cache::remember(
+            $termCount = RedisCache::remember(
                 "user/{$userName}/term/count",
                 $cacheExpiry,
                 function () use ($queryUserId) {
@@ -133,7 +133,7 @@ class UserStatisticController extends Controller
                         ->count();
                 }
             );
-            $termCountWithNote = Cache::remember(
+            $termCountWithNote = RedisCache::remember(
                 "user/{$userName}/term/count-note",
                 $cacheExpiry,
                 function () use ($queryUserId) {
@@ -145,7 +145,7 @@ class UserStatisticController extends Controller
         }
         // 单词本
         if (! $request->has('view') || $request->input('view') === 'my-dict-count') {
-            $myDictCount = Cache::remember(
+            $myDictCount = RedisCache::remember(
                 "user/{$userName}/dict/count",
                 $cacheExpiry,
                 function () use ($queryUserId) {

+ 2 - 2
api-v13/app/Http/Controllers/VocabularyController.php

@@ -2,11 +2,11 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use App\Http\Resources\VocabularyResource;
 use App\Models\Vocabulary;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
-use Illuminate\Support\Facades\Cache;
 
 class VocabularyController extends Controller
 {
@@ -25,7 +25,7 @@ class VocabularyController extends Controller
                     return $this->ok(['rows' => [], 'count' => 0]);
                 }
 
-                $payload = Cache::remember(
+                $payload = RedisCache::remember(
                     'v13:dict_vocabulary:'.md5($key),
                     config('mint.cache.expire'),
                     function () use ($key) {

+ 3 - 3
api-v13/app/Http/Controllers/WbwLookupController.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use App\Http\Api\DictApi;
 use App\Models\Channel;
 use App\Models\DictInfo;
@@ -11,7 +12,6 @@ use App\Services\AuthService;
 use App\Tools\CaseMan;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
-use Illuminate\Support\Facades\Cache;
 
 class WbwLookupController extends Controller
 {
@@ -167,11 +167,11 @@ class WbwLookupController extends Controller
             'case' => 8,
         ];
         $fieldId = $fieldMap[$field];
-        $myPreference = Cache::get("{$prefix}/{$word}/{$fieldId}/{$userId}");
+        $myPreference = RedisCache::get("{$prefix}/{$word}/{$fieldId}/{$userId}");
         if (! empty($myPreference)) {
             return ['value' => $myPreference, 'status' => 5];
         } else {
-            $myPreference = Cache::get("{$prefix}/{$word}/3/0");
+            $myPreference = RedisCache::get("{$prefix}/{$word}/3/0");
             if (! empty($myPreference)) {
                 return ['value' => $myPreference, 'status' => 5];
             }

+ 2 - 1
api-v13/app/Http/Controllers/WordIndexController.php

@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers;
 
+use App\Facades\RedisCache;
 use App\Models\WordIndex;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
@@ -21,7 +22,7 @@ class WordIndexController extends Controller
             case 'key':
                 $key = $request->input('key');
                 /*
-                $result = Cache::remember("/word_index/{$key}",10,function() use($key){
+                $result = RedisCache::remember("/word_index/{$key}",10,function() use($key){
                     return WordIndex::where('word','like',$key."%")
                                     ->whereOr('word_en','like',$key."%")
                                     ->orderBy('word_en')

+ 5 - 5
api-v13/app/Http/Middleware/ApiLog.php

@@ -2,9 +2,9 @@
 
 namespace App\Http\Middleware;
 
+use App\Facades\RedisCache;
 use Closure;
 use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 use Symfony\Component\HttpFoundation\Response;
 
@@ -68,11 +68,11 @@ class ApiLog
         $delayKey = "{$key}/delay";
 
         // 原子操作(Redis 驱动下安全)
-        Cache::increment($countKey);
-        Cache::increment($delayKey, $delay);
+        RedisCache::increment($countKey);
+        RedisCache::increment($delayKey, $delay);
 
         // increment 不会刷新 TTL,需手动补
-        Cache::put($countKey, Cache::get($countKey, 0), $ttl);
-        Cache::put($delayKey, Cache::get($delayKey, 0), $ttl);
+        RedisCache::put($countKey, RedisCache::get($countKey, 0), $ttl);
+        RedisCache::put($delayKey, RedisCache::get($delayKey, 0), $ttl);
     }
 }

+ 3 - 3
api-v13/app/Http/Resources/RelationResource.php

@@ -15,9 +15,9 @@ class RelationResource extends JsonResource
      * Transform the resource into an array.
      *
      * The payload must contain only scalars and arrays: it is cached by
-     * RelationController, and Laravel 13 refuses to unserialize objects from
-     * cache unless they are listed in cache.serializable_classes. Hence the
-     * associative json_decode() and the ISO-8601 date strings.
+     * RelationController, and RedisCache stores JSON, so objects would come
+     * back as associative arrays anyway. Hence the associative json_decode()
+     * and the ISO-8601 date strings.
      *
      * @param  Request  $request
      * @return array|Arrayable|\JsonSerializable

+ 2 - 2
api-v13/app/Http/Resources/SearchResource.php

@@ -2,13 +2,13 @@
 
 namespace App\Http\Resources;
 
+use App\Facades\RedisCache;
 use App\Http\Api\ChannelApi;
 use App\Models\PaliText;
 use App\Models\Sentence;
 use Illuminate\Contracts\Support\Arrayable;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;
-use Illuminate\Support\Facades\Cache;
 
 class SearchResource extends JsonResource
 {
@@ -35,7 +35,7 @@ class SearchResource extends JsonResource
         } elseif (isset($this->content)) {
             $data['content'] = $this->content;
         } else {
-            $channelId = Cache::remember('_System_Pali_VRI_', config('mint.cache.expire'), function () {
+            $channelId = RedisCache::remember('_System_Pali_VRI_', config('mint.cache.expire'), function () {
                 return ChannelApi::getSysChannel('_System_Pali_VRI_');
             });
 

+ 2 - 2
api-v13/app/Jobs/ProcessAITranslateJob.php

@@ -3,9 +3,9 @@
 namespace App\Jobs;
 
 use App\Exceptions\TaskFailException;
+use App\Facades\RedisCache;
 use App\Services\AiTranslateService;
 use App\Services\RabbitMQService;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 
 class ProcessAITranslateJob extends BaseRabbitMQJob
@@ -52,7 +52,7 @@ class ProcessAITranslateJob extends BaseRabbitMQJob
         $mq = app(RabbitMQService::class);
         $queue = 'ai_translate_v2';
         $msgId = $mq->publishMessage($queue, []);
-        Cache::put("/mq/message/{$msgId}/data", $data);
+        RedisCache::put("/mq/message/{$msgId}/data", $data);
 
         return $msgId;
     }

+ 3 - 2
api-v13/app/Mail/EmailCertif.php

@@ -2,10 +2,10 @@
 
 namespace App\Mail;
 
+use App\Facades\RedisCache;
 use Illuminate\Bus\Queueable;
 use Illuminate\Mail\Mailable;
 use Illuminate\Queue\SerializesModels;
-use Illuminate\Support\Facades\Cache;
 
 class EmailCertif extends Mailable
 {
@@ -38,7 +38,8 @@ class EmailCertif extends Mailable
         // 生成一个介于 1000 到 9999 之间的随机整数
         $randomNumber = random_int(1000, 9999);
         $key = '/email/certification/'.$this->uuid;
-        Cache::put($key, $randomNumber, 30 * 60);
+        // 存成字符串:GET /v2/email-certification/{id} 一直返回 "1234",v4 注册页用 === 比对表单里的字符串
+        RedisCache::put($key, (string) $randomNumber, 30 * 60);
 
         return $this->view('emails.certification.'.$this->lang)
             ->with([

+ 10 - 0
api-v13/app/Providers/AppServiceProvider.php

@@ -3,6 +3,7 @@
 namespace App\Providers;
 
 use App\Services\PaliSeriesesService;
+use App\Services\RedisCache;
 use App\Services\RomanizeService;
 use App\Services\UserService;
 use App\Tools\QueryBuilderMacro;
@@ -54,6 +55,15 @@ class AppServiceProvider extends ServiceProvider
         */
         $this->app->singleton(RomanizeService::class);
 
+        /*
+        |--------------------------------------------------------------------------
+        | Redis Cache(业务代码专用,门面 App\Facades\RedisCache,连 Redis Cluster)
+        |--------------------------------------------------------------------------
+        | 只登记这个类,不替换框架的 cache 绑定:RateLimiter、schedule 互斥锁、
+        | snowflake 序列等框架内部仍走 Laravel Cache(config/cache.php)。
+        */
+        $this->app->singleton(RedisCache::class);
+
         /*
         |--------------------------------------------------------------------------
         | Pali Serieses Service

+ 2 - 2
api-v13/app/Services/AIModelService.php

@@ -2,9 +2,9 @@
 
 namespace App\Services;
 
+use App\Facades\RedisCache;
 use App\Http\Resources\AiModelResource;
 use App\Models\AiModel;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Schema;
 
 class AIModelService
@@ -48,7 +48,7 @@ class AIModelService
 
         $sysModels = [];
         foreach ($types as $key => $type) {
-            $sysModels[$type] = $this->getModelsById(Cache::get('/ai/model/system/'.$type) ?? []);
+            $sysModels[$type] = $this->getModelsById(RedisCache::get('/ai/model/system/'.$type) ?? []);
         }
         if (! empty($type)) {
             return $sysModels[$type];

+ 9 - 9
api-v13/app/Services/AiTranslateService.php

@@ -4,6 +4,7 @@ namespace App\Services;
 
 use App\Exceptions\SectionTimeoutException;
 use App\Exceptions\TaskFailException;
+use App\Facades\RedisCache;
 use App\Http\Api\ChannelApi;
 use App\Http\Api\MdRender;
 use App\Models\AiModel;
@@ -13,7 +14,6 @@ use App\Models\Sentence;
 use App\Models\Task;
 use App\Tools\Tools;
 use Illuminate\Http\Client\RequestException;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Log;
 
@@ -68,12 +68,12 @@ class AiTranslateService
         $first = $messages[0];
         $this->task = $first->task->info;
         $taskId = $this->task->id;
-        Cache::put("/task/{$taskId}/message_id", $messageId);
+        RedisCache::put("/task/{$taskId}/message_id", $messageId);
         $pointerKey = "/task/{$taskId}/pointer";
         $pointer = 0;
-        if (Cache::has($pointerKey)) {
+        if (RedisCache::has($pointerKey)) {
             // 回到上次中断的点
-            $pointer = Cache::get($pointerKey);
+            $pointer = RedisCache::get($pointerKey);
             Log::info("last break point {$pointer}");
         }
 
@@ -108,7 +108,7 @@ class AiTranslateService
                 return false;
             }
 
-            Cache::put($pointerKey, $i);
+            RedisCache::put($pointerKey, $i);
             $message = $messages[$i];
             $taskDiscussionContent = [];
 
@@ -206,7 +206,7 @@ class AiTranslateService
         // 任务完成 修改任务状态为 done
         if ($i === count($messages)) {
             $this->setTaskStatus($this->task->id, 'done');
-            Cache::forget($pointerKey);
+            RedisCache::forget($pointerKey);
             Log::info('ai translate task complete');
         }
 
@@ -619,9 +619,9 @@ class AiTranslateService
         $aiModel['token'] = $modelToken;
 
         /**
-         * 返回值会被 ProcessAITranslateJob::publish() 写入缓存。Laravel 13 默认
-         * 拒绝从缓存反序列化对象(cache.serializable_classes = false),所以这里
-         * 先把模型摊平成数组。JSON 输出与直接编码模型完全一致。
+         * 返回值会被 ProcessAITranslateJob::publish() 写入缓存。RedisCache 存 JSON,
+         * 对象读回来会变成关联数组,所以这里先把模型摊平成数组,写入前后形状一致。
+         * JSON 输出与直接编码模型完全一致。
          */
         $aiModelData = $aiModel->toArray();
         $taskData = $task->toArray();

+ 50 - 45
api-v13/app/Services/OpenSearchService.php

@@ -4,17 +4,17 @@
 
 namespace App\Services;
 
+use App\Facades\RedisCache;
 use Exception;
 use GuzzleHttp\Client;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
-
 use OpenSearch\GuzzleClientFactory;
 
-
 class OpenSearchService
 {
+    private const EMBEDDING_CACHE_VERSION_KEY = 'embedding/version';
+
     protected $client;
 
     protected $http;
@@ -395,12 +395,12 @@ class OpenSearchService
     {
         try {
             $info = $this->client->info();
-            $message = 'OpenSearch 连接成功: ' . json_encode($info['version']['number']);
+            $message = 'OpenSearch 连接成功: '.json_encode($info['version']['number']);
             Log::info($message);
 
             return [true, $message];
         } catch (Exception $e) {
-            $message = 'OpenSearch 连接失败: ' . $e->getMessage();
+            $message = 'OpenSearch 连接失败: '.$e->getMessage();
             Log::error($message);
 
             return [false, $message];
@@ -497,14 +497,13 @@ class OpenSearchService
     public function updatePaliSynonymsPath(string $filename): array
     {
 
-
         $index = config('mint.opensearch.index');
 
         if (! $this->client->indices()->exists(['index' => $index])) {
             throw new Exception("Index [$index] does not exist.");
         }
 
-        $path = Str::finish(config('mint.opensearch.config.synonyms_path'), '/') . $filename;
+        $path = Str::finish(config('mint.opensearch.config.synonyms_path'), '/').$filename;
 
         Log::debug('OpenSearchService::updatePaliSynonymsPath', [
             'index' => $index,
@@ -879,7 +878,7 @@ class OpenSearchService
     {
         $fields = [];
         foreach ($this->weights['fuzzy'] as $field => $weight) {
-            $fields[] = $field . '^' . $weight;
+            $fields[] = $field.'^'.$weight;
         }
 
         return [
@@ -910,7 +909,7 @@ class OpenSearchService
             if (in_array($field, ['fuzzy_ratio', 'semantic_ratio'])) {
                 continue;
             }
-            $fuzzyFields[] = $field . '^' . $weight;
+            $fuzzyFields[] = $field.'^'.$weight;
         }
 
         $fuzzyPart = [
@@ -970,7 +969,7 @@ class OpenSearchService
      * 调用 OpenAI Embedding API 将文本转为向量
      *
      * 使用 Redis 缓存(TTL 7 天),相同文本不会重复请求 API,
-     * 缓存 key 格式为 "embedding:{md5(text)}"。
+     * 缓存 key 格式见 embeddingCacheKey()。
      *
      * @param  string  $text  输入文本
      * @return array 1536 维 float 向量
@@ -983,29 +982,40 @@ class OpenSearchService
             throw new Exception('请在 .env 设置 OPENAI_API_KEY');
         }
 
-        $cacheKey = 'embedding:' . md5($text);
+        return RedisCache::remember($this->embeddingCacheKey($text), now()->addDays(7), function () use ($text) {
+            $response = $this->http->post('embeddings', [
+                'headers' => [
+                    'Authorization' => 'Bearer '.$this->openaiApiKey,
+                    'Content-Type' => 'application/json',
+                ],
+                'json' => [
+                    'model' => 'text-embedding-3-small',
+                    'input' => $text,
+                ],
+            ]);
 
-        return Cache::tags(['embedding'])
-            ->remember($cacheKey, now()->addDays(7), function () use ($text) {
-                $response = $this->http->post('embeddings', [
-                    'headers' => [
-                        'Authorization' => 'Bearer ' . $this->openaiApiKey,
-                        'Content-Type' => 'application/json',
-                    ],
-                    'json' => [
-                        'model' => 'text-embedding-3-small',
-                        'input' => $text,
-                    ],
-                ]);
+            $json = json_decode((string) $response->getBody(), true);
 
-                $json = json_decode((string) $response->getBody(), true);
+            if (empty($json['data'][0]['embedding'])) {
+                throw new Exception('OpenAI embedding 返回异常: '.json_encode($json));
+            }
 
-                if (empty($json['data'][0]['embedding'])) {
-                    throw new Exception('OpenAI embedding 返回异常: ' . json_encode($json));
-                }
+            return $json['data'][0]['embedding'];
+        });
+    }
 
-                return $json['data'][0]['embedding'];
-            });
+    /**
+     * embedding 缓存键:"embedding/v{版本}/{md5(text)}"。
+     *
+     * 版本号存在 EMBEDDING_CACHE_VERSION_KEY,clearAllEmbeddingCache() 把它加一,
+     * 旧版本的键就再也不会被读到,7 天后自然过期。原来用 Cache::tags(),
+     * tag 索引是另外的 key,在 Redis Cluster 上跨 slot。
+     */
+    private function embeddingCacheKey(string $text): string
+    {
+        $version = RedisCache::get(self::EMBEDDING_CACHE_VERSION_KEY, 0);
+
+        return "embedding/v{$version}/".md5($text);
     }
 
     /**
@@ -1019,25 +1029,20 @@ class OpenSearchService
      */
     public function clearEmbeddingCache(string $text): bool
     {
-        $cacheKey = 'embedding:' . md5($text);
-
-        return Cache::forget($cacheKey);
+        return RedisCache::forget($this->embeddingCacheKey($text));
     }
 
     /**
-     * 清除 Redis 中所有 embedding 缓存
-     *
-     * 匹配 "embedding:*" 模式的全部键,生产环境请谨慎调用。
+     * 让所有 embedding 缓存失效
      *
-     * @return int 已删除的缓存条数
+     * 不逐条删除,而是把版本号加一;旧键 7 天内自然过期。
      *
      * @example
-     *   $count = $service->clearAllEmbeddingCache();
-     *   echo "已清理缓存 {$count} 条";
+     *   $service->clearAllEmbeddingCache();
      */
     public function clearAllEmbeddingCache(): void
     {
-        Cache::tags(['embedding'])->flush();
+        RedisCache::increment(self::EMBEDDING_CACHE_VERSION_KEY);
     }
 
     /**
@@ -1097,7 +1102,7 @@ class OpenSearchService
         // 过滤无效字段
         $searchFields = array_values(array_filter(
             $searchFields,
-            fn($field) => isset($fieldMap[$field])
+            fn ($field) => isset($fieldMap[$field])
         ));
 
         if (empty($searchFields)) {
@@ -1107,7 +1112,7 @@ class OpenSearchService
         // 构建 suggest DSL
         $suggests = [];
         foreach ($searchFields as $field) {
-            $suggests[$field . '_suggest'] = [
+            $suggests[$field.'_suggest'] = [
                 'prefix' => $query,
                 'completion' => [
                     'field' => $fieldMap[$field],
@@ -1131,7 +1136,7 @@ class OpenSearchService
         // 整理结果,附加来源字段
         $results = [];
         foreach ($searchFields as $field) {
-            $options = $response['suggest'][$field . '_suggest'][0]['options'] ?? [];
+            $options = $response['suggest'][$field.'_suggest'][0]['options'] ?? [];
 
             foreach ($options as $opt) {
                 $results[] = [
@@ -1145,7 +1150,7 @@ class OpenSearchService
         }
 
         // 按分数降序排序
-        usort($results, fn($a, $b) => $b['score'] <=> $a['score']);
+        usort($results, fn ($a, $b) => $b['score'] <=> $a['score']);
 
         return $results;
     }
@@ -1198,10 +1203,10 @@ class OpenSearchService
         ]);
 
         $tokens = array_map(
-            fn($token) => $token['token'] ?? '',
+            fn ($token) => $token['token'] ?? '',
             $response['tokens'] ?? []
         );
 
-        return array_values(array_unique(array_filter($tokens, fn($t) => $t !== '')));
+        return array_values(array_unique(array_filter($tokens, fn ($t) => $t !== '')));
     }
 }

+ 3 - 3
api-v13/app/Services/PacketService.php

@@ -2,10 +2,10 @@
 
 namespace App\Services;
 
+use App\Facades\RedisCache;
 use App\Http\Api\ChannelApi;
 use App\Models\Channel;
 use Illuminate\Support\Facades\App;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Storage;
@@ -304,10 +304,10 @@ class PacketService
     {
         $key = '/offline/index';
 
-        if (! Cache::has($key)) {
+        if (! RedisCache::has($key)) {
             return [];
         }
-        $fileInfo = Cache::get($key);
+        $fileInfo = RedisCache::get($key);
         $output = [];
         foreach ($fileInfo as $key => $file) {
             if ($id) {

+ 5 - 5
api-v13/app/Services/PaliContentService.php

@@ -4,6 +4,7 @@
 
 namespace App\Services;
 
+use App\Facades\RedisCache;
 use App\Http\Api\ChannelApi;
 use App\Http\Api\MdRender;
 use App\Http\Api\StudioApi;
@@ -18,7 +19,6 @@ use App\Models\SentSimIndex;
 use App\Models\Wbw;
 use App\Models\WbwBlock;
 use Illuminate\Support\Arr;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
 
@@ -73,7 +73,7 @@ class PaliContentService
         } else {
             $keyCanRead .= 'guest';
         }
-        $channelCanRead = Cache::remember(
+        $channelCanRead = RedisCache::remember(
             $keyCanRead,
             config('mint.cache.expire'),
             function () use ($userUuid) {
@@ -338,7 +338,7 @@ class PaliContentService
 
                                 break;
                             case 'nissaya':
-                                $newSent['html'] = Cache::remember(
+                                $newSent['html'] = RedisCache::remember(
                                     "/sent/{$channelId}/{$ids}/{$format}",
                                     config('mint.cache.expire'),
                                     function () use ($row, $mode, $format) {
@@ -663,7 +663,7 @@ class PaliContentService
         $level = $this->paragraphLevel($book, $para);
         // 缓存句子,段落外壳与标题级别有关,不进缓存
         $key = self::paragraphCacheKey($book, $para, $channelUid, $format);
-        $cached = Cache::remember(
+        $cached = RedisCache::remember(
             $key,
             config('mint.cache.expire'),
             function () use ($book, $para, $channelUid, $format) {
@@ -734,7 +734,7 @@ class PaliContentService
     public static function forgetParagraph(int $book, int $para, string $channelUid): void
     {
         foreach (self::FORMATS as $format) {
-            Cache::forget(self::paragraphCacheKey($book, $para, $channelUid, $format));
+            RedisCache::forget(self::paragraphCacheKey($book, $para, $channelUid, $format));
         }
     }
 

+ 186 - 0
api-v13/app/Services/RedisCache.php

@@ -0,0 +1,186 @@
+<?php
+
+namespace App\Services;
+
+use Closure;
+use DateInterval;
+use DateTimeInterface;
+use Illuminate\Support\InteractsWithTime;
+use RedisCluster;
+
+/**
+ * 直接操作 Redis 的缓存,取代 Laravel 原生 Cache。
+ *
+ * 前身是 api-v8 的 App\Tools\RedisClusters。接口与 Laravel 的 Cache 门面对齐
+ * (方法名、参数、返回值、null 与 TTL 语义一致),但只实现 api-v13 真正用到的
+ * get / put / remember / has / forget / increment 六个;tags、flush 这类在
+ * Redis Cluster 上跨 slot 或会清空整个集群的操作故意不提供。
+ *
+ * 连的是 Redis Cluster,不走 Laravel 的 Redis 门面,用 phpredis 的 RedisCluster 手写连接
+ * (https://github.com/phpredis/phpredis/blob/develop/cluster.md):
+ * - 连接:config('mint.redis_cluster.host') : config('mint.redis_cluster.port') 作为种子节点,
+ *   其余节点由 phpredis 从集群拓扑自动发现;首次读写时才建连
+ * - key:config('mint.redis_cluster.prefix') . '/' . $key($key 已以 / 开头就不再加),
+ *   不再叠加 REDIS_PREFIX / CACHE_PREFIX。所以 `foo` 与 `/foo` 是同一个 key
+ * - 值:JSON。读回来对象一律是关联数组,数字是 int / float(不再是数字字符串);
+ *   整数编码后就是纯文本数字,ai-translate 用 int() 直接读 `/task/{id}/pointer` 不受影响。
+ *   存不了 JSON 的值(非 UTF-8 字符串、INF/NAN)写入时抛 JsonException
+ *
+ * 集群与 Laravel Cache 用的单机 Redis 不是同一个库,旧缓存不迁移,切换后冷启动。
+ *
+ * 业务代码通过门面 App\Facades\RedisCache 调用;测试用 RedisCache::fake()
+ * 换成内存实现(见 RedisCacheFake)。
+ */
+class RedisCache
+{
+    use InteractsWithTime;
+
+    /**
+     * 调用方没给 TTL(或传 null)时的默认过期秒数:30 天。不提供永久保存。
+     */
+    public static int $defaultTtl = 30 * 24 * 60 * 60;
+
+    /** @var list<string> 种子节点,形如 `host:port` */
+    protected array $seeds;
+
+    protected string $prefix;
+
+    protected ?RedisCluster $client = null;
+
+    /**
+     * @param  list<string>|null  $seeds
+     */
+    public function __construct(?array $seeds = null, ?string $prefix = null)
+    {
+        $this->seeds = $seeds ?? [config('mint.redis_cluster.host').':'.config('mint.redis_cluster.port')];
+        $this->prefix = $prefix ?? (string) config('mint.redis_cluster.prefix', '');
+    }
+
+    /**
+     * 读取;不存在时返回 $default(可以是闭包)。
+     */
+    public function get(string $key, mixed $default = null): mixed
+    {
+        $value = $this->read($this->key($key));
+
+        return $value === null ? value($default) : $this->unserialize($value);
+    }
+
+    /**
+     * 写入。$ttl 为 null 时用 static::$defaultTtl;算出来的秒数 <= 0 时等同 forget()。
+     */
+    public function put(string $key, mixed $value, DateTimeInterface|DateInterval|int|null $ttl = null): bool
+    {
+        $seconds = $this->secondsUntil($ttl ?? static::$defaultTtl);
+
+        if ($seconds <= 0) {
+            return $this->forget($key);
+        }
+
+        return $this->write($this->key($key), $this->serialize($value), $seconds);
+    }
+
+    /**
+     * 命中就返回缓存,否则执行 $callback 并写入。
+     *
+     * 与 Laravel 一致:缓存值为 null 视同未命中,下次会重新计算。
+     */
+    public function remember(string $key, DateTimeInterface|DateInterval|int|null $ttl, Closure $callback): mixed
+    {
+        $value = $this->get($key);
+
+        if ($value !== null) {
+            return $value;
+        }
+
+        $value = $callback();
+        $this->put($key, $value, $ttl);
+
+        return $value;
+    }
+
+    /**
+     * 与 Laravel 一致:值为 null 视同不存在。
+     */
+    public function has(string $key): bool
+    {
+        return $this->get($key) !== null;
+    }
+
+    public function forget(string $key): bool
+    {
+        return $this->delete($this->key($key));
+    }
+
+    /**
+     * 原子自增(INCRBY),返回自增后的值。key 不存在时从 0 开始,且不带过期时间
+     * (不套用 $defaultTtl:版本号 key 靠它永久递增,过期归零会让旧缓存重新命中)。
+     */
+    public function increment(string $key, int $value = 1): int
+    {
+        return $this->incrementBy($this->key($key), $value);
+    }
+
+    /**
+     * 前缀与 key 之间用 / 隔开:`dev` + `foo` → `dev/foo`;`/task/1` 本身带 /,得 `dev/task/1`。
+     */
+    protected function key(string $key): string
+    {
+        return $this->prefix.(str_starts_with($key, '/') ? $key : '/'.$key);
+    }
+
+    /**
+     * 以下四个是仅有的 Redis 存取原语,RedisCacheFake 只需覆盖它们。
+     * 参数 $key 已带前缀。
+     */
+    protected function read(string $key): ?string
+    {
+        $value = $this->redis()->get($key);
+
+        return $value === false ? null : $value;
+    }
+
+    /**
+     * 一律用一条 `SET key value EX seconds`:写值与设过期原子完成。
+     * phpredis 的写法是 `set($key, $value, ['EX' => $seconds])`,不是 Laravel 的 `'EX', $seconds`。
+     * 不要拆成 SET + EXPIRE(api-v8 的写法)——两条之间出错会留下永不过期的 key。
+     */
+    protected function write(string $key, string $value, int $seconds): bool
+    {
+        return (bool) $this->redis()->set($key, $value, ['EX' => $seconds]);
+    }
+
+    protected function delete(string $key): bool
+    {
+        return (bool) $this->redis()->del($key);
+    }
+
+    protected function incrementBy(string $key, int $value): int
+    {
+        return (int) $this->redis()->incrby($key, $value);
+    }
+
+    /**
+     * 第一个参数 null 表示不读 php.ini 里的具名集群,直接用种子节点。
+     */
+    protected function redis(): RedisCluster
+    {
+        return $this->client ??= new RedisCluster(null, $this->seeds);
+    }
+
+    /**
+     * 不转义中文与斜杠,省空间也方便在 redis-cli 里直接看;1.0 保持为 1.0,读回来仍是 float。
+     */
+    protected function serialize(mixed $value): string
+    {
+        return json_encode(
+            $value,
+            JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR,
+        );
+    }
+
+    protected function unserialize(string $value): mixed
+    {
+        return json_decode($value, true, 512, JSON_THROW_ON_ERROR);
+    }
+}

+ 63 - 0
api-v13/app/Services/RedisCacheFake.php

@@ -0,0 +1,63 @@
+<?php
+
+namespace App\Services;
+
+/**
+ * RedisCache 的内存实现,供测试使用(相当于 Laravel Cache 的 array store)。
+ *
+ * 只替换四个存取原语,序列化、TTL 换算、null 语义仍走 RedisCache 本身,
+ * 所以测试覆盖到的是真实逻辑。通过 App\Facades\RedisCache::fake() 启用。
+ */
+class RedisCacheFake extends RedisCache
+{
+    /** @var array<string, array{value: string, expiresAt: int|null}> */
+    protected array $items = [];
+
+    protected function read(string $key): ?string
+    {
+        $item = $this->items[$key] ?? null;
+
+        if ($item === null) {
+            return null;
+        }
+
+        if ($item['expiresAt'] !== null && $item['expiresAt'] <= $this->currentTime()) {
+            unset($this->items[$key]);
+
+            return null;
+        }
+
+        return $item['value'];
+    }
+
+    protected function write(string $key, string $value, int $seconds): bool
+    {
+        $this->items[$key] = [
+            'value' => $value,
+            'expiresAt' => $this->currentTime() + $seconds,
+        ];
+
+        return true;
+    }
+
+    protected function delete(string $key): bool
+    {
+        $existed = $this->read($key) !== null;
+        unset($this->items[$key]);
+
+        return $existed;
+    }
+
+    protected function incrementBy(string $key, int $value): int
+    {
+        $current = $this->read($key);
+        $next = (int) ($current ?? 0) + $value;
+
+        $this->items[$key] = [
+            'value' => (string) $next,
+            'expiresAt' => $current === null ? null : $this->items[$key]['expiresAt'],
+        ];
+
+        return $next;
+    }
+}

+ 4 - 4
api-v13/app/Services/SummaryService.php

@@ -2,7 +2,7 @@
 
 namespace App\Services;
 
-use Illuminate\Support\Facades\Cache;
+use App\Facades\RedisCache;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Log;
 
@@ -57,10 +57,10 @@ class SummaryService
         $cacheKey = 'summary_'.md5($text);
 
         // 2️⃣ 检查缓存命中
-        if (! $forceRefresh && Cache::has($cacheKey)) {
+        if (! $forceRefresh && RedisCache::has($cacheKey)) {
             Log::debug('SummaryService cache hit', ['key' => $cacheKey]);
 
-            return Cache::get($cacheKey);
+            return RedisCache::get($cacheKey);
         }
 
         Log::debug('SummaryService generating new summary', [
@@ -94,7 +94,7 @@ class SummaryService
         }
 
         // 4️⃣ 写入缓存(默认缓存 1 周)
-        Cache::put($cacheKey, $finalSummary, now()->addWeek());
+        RedisCache::put($cacheKey, $finalSummary, now()->addWeek());
 
         Log::debug('SummaryService cached new summary', [
             'key' => $cacheKey,

+ 18 - 9
api-v13/app/Services/Template/TemplateService.php

@@ -2,12 +2,14 @@
 
 namespace App\Services\Template;
 
+use App\Facades\RedisCache;
 use App\Services\Template\Renderers\RendererFactory;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 
 class TemplateService
 {
+    private const CACHE_VERSION_KEY = 'template/version';
+
     private TemplateParser $parser;
 
     private bool $cacheEnabled;
@@ -30,8 +32,8 @@ class TemplateService
             // 生成缓存键
             $cacheKey = $this->generateCacheKey($content, $format);
 
-            if ($this->cacheEnabled && Cache::has($cacheKey)) {
-                return Cache::get($cacheKey);
+            if ($this->cacheEnabled && RedisCache::has($cacheKey)) {
+                return RedisCache::get($cacheKey);
             }
 
             // 解析内容
@@ -48,7 +50,7 @@ class TemplateService
 
             // 缓存结果
             if ($this->cacheEnabled) {
-                Cache::put($cacheKey, $result, $this->cacheTtl);
+                RedisCache::put($cacheKey, $result, $this->cacheTtl);
             }
 
             return $result;
@@ -137,20 +139,27 @@ class TemplateService
     }
 
     /**
-     * 生成缓存键
+     * 生成缓存键。
+     *
+     * 键里带版本号:模板定义变了就把版本号加一,旧版本的缓存不再被读到、到期自然消失。
      */
     private function generateCacheKey(string $content, string $format): string
     {
-        return 'template_'.$format.'_'.md5($content);
+        $version = RedisCache::get(self::CACHE_VERSION_KEY, 0);
+
+        return "template/v{$version}/{$format}/".md5($content);
     }
 
     /**
-     * 清除模板相关缓存
+     * 让所有模板渲染缓存失效。
+     *
+     * 缓存键是内容的 md5,按模板名查不出受影响的键,所以整体换版本号。
+     * 原来用的是 Cache::flush(),在 Redis Cluster 上会清空所有 master,
+     * 连队列和其他业务缓存一起清掉。
      */
     private function clearTemplateCache(string $templateName): void
     {
-        // 这里可以实现更精确的缓存清除逻辑
-        Cache::flush(); // 简单起见,清除所有缓存
+        RedisCache::increment(self::CACHE_VERSION_KEY);
     }
 
     /**

+ 6 - 6
api-v13/app/Services/V3/SignUpService.php

@@ -3,11 +3,11 @@
 namespace App\Services\V3;
 
 use App\Exceptions\BusinessException;
+use App\Facades\RedisCache;
 use App\Mail\V3\EmailCertificationMail;
 use App\Models\Channel;
 use App\Models\Invite;
 use App\Models\UserInfo;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Mail;
@@ -63,7 +63,7 @@ class SignUpService
     public function sendCode(string $email, ?string $lang): void
     {
         $code = (string) random_int(100000, 999999);
-        Cache::put($this->codeKey($email), [
+        RedisCache::put($this->codeKey($email), [
             'digest' => hash('sha256', $code),
             'attempts' => 0,
         ], now()->addMinutes(self::CODE_TTL_MINUTES));
@@ -89,20 +89,20 @@ class SignUpService
     public function verifyCode(string $email, string $code): Invite
     {
         $key = $this->codeKey($email);
-        $entry = Cache::get($key);
+        $entry = RedisCache::get($key);
 
         if (! is_array($entry) || ! hash_equals($entry['digest'], hash('sha256', $code))) {
             if (is_array($entry)) {
                 $attempts = $entry['attempts'] + 1;
                 $attempts >= self::CODE_MAX_ATTEMPTS
-                    ? Cache::forget($key)
-                    : Cache::put($key, [...$entry, 'attempts' => $attempts], now()->addMinutes(self::CODE_TTL_MINUTES));
+                    ? RedisCache::forget($key)
+                    : RedisCache::put($key, [...$entry, 'attempts' => $attempts], now()->addMinutes(self::CODE_TTL_MINUTES));
             }
 
             throw ValidationException::withMessages(['code' => __('messages.sign_up_code_invalid')]);
         }
 
-        Cache::forget($key);
+        RedisCache::forget($key);
 
         $invite = Invite::whereRaw('lower(email) = ?', [mb_strtolower($email)])->first()
             ?? (new Invite)->forceFill([

+ 3 - 3
api-v13/app/Tools/ExportDownload.php

@@ -2,8 +2,8 @@
 
 namespace App\Tools;
 
+use App\Facades\RedisCache;
 use Illuminate\Support\Facades\App;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Str;
@@ -70,7 +70,7 @@ class ExportDownload
         if ($this->downloadUrl) {
             $data['url'] = $this->downloadUrl;
         }
-        Cache::put(
+        RedisCache::put(
             $this->currStatusKey,
             $data,
             $this->statusExpiry
@@ -82,7 +82,7 @@ class ExportDownload
 
     public function getStatus()
     {
-        return Cache::get($this->currStatusKey);
+        return RedisCache::get($this->currStatusKey);
     }
 
     public function upload(string $type, $sections, $bookMeta)

+ 17 - 15
api-v13/app/Tools/TurboSplit.php

@@ -4,9 +4,9 @@ namespace App\Tools;
 
 require_once __DIR__.'/../../public/app/public/casesuf.inc';
 
+use App\Facades\RedisCache;
 use App\Models\UserDict;
 use App\Models\WordPart;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Log;
 
 class TurboSplit
@@ -196,7 +196,7 @@ class TurboSplit
         $search = $word;
 
         // 获取单词权重
-        $weight = Cache::remember(
+        $weight = RedisCache::remember(
             'palicanon/wordpart/weight/'.$search,
             config('mint.cache.expire'),
             function () use ($search) {
@@ -229,7 +229,7 @@ class TurboSplit
             $base_weight = 0;
             $len = 0;
             foreach ($newWord as $x => $x_len) {
-                $weight = Cache::remember(
+                $weight = RedisCache::remember(
                     'palicanon/wordpart/weight/'.$x,
                     config('mint.cache.expire'),
                     function () use ($x) {
@@ -260,7 +260,7 @@ class TurboSplit
 
         $isFound = false;
         $count = 0;
-        $wordPart = Cache::remember(
+        $wordPart = RedisCache::remember(
             "turbosplit/part/{$word}",
             config('mint.cache.expire'),
             function () use ($word) {
@@ -284,18 +284,20 @@ class TurboSplit
 
         // fomular of confidence value 信心值计算公式
         if ($isFound) {
-            $cf = Cache::remember(
-                'turbosplit/confidence/'.$word,
-                config('mint.cache.expire'),
-                function () use ($word, $count, $case_len) {
-                    $len = mb_strlen($word, 'UTF-8') - $case_len;
-                    $len_correct = 1.2;
-                    $count2 = 1.1 + pow($count, 1.18);
-                    $conf_num = pow(1 / $count2, pow(($len - 0.5), $len_correct));
-
-                    return round(1 / (1 + 640 * $conf_num), 9);
+            // 词本身就是语尾时 $len = 0,pow(-0.5, 1.2) 得 NAN;JSON 存不了 NAN,这种值不进缓存
+            $cacheKey = 'turbosplit/confidence/'.$word;
+            $cf = RedisCache::get($cacheKey);
+            if ($cf === null) {
+                $len = mb_strlen($word, 'UTF-8') - $case_len;
+                $len_correct = 1.2;
+                $count2 = 1.1 + pow($count, 1.18);
+                $conf_num = pow(1 / $count2, pow(($len - 0.5), $len_correct));
+                $cf = round(1 / (1 + 640 * $conf_num), 9);
+
+                if (is_finite($cf)) {
+                    RedisCache::put($cacheKey, $cf, config('mint.cache.expire'));
                 }
-            );
+            }
 
             return $cf;
         } else {

+ 0 - 9
api-v13/config/custom.php

@@ -1,9 +0,0 @@
-<?php
-
-return [
-    'redis_cluster' => [
-        'host' => env('REDIS_CLUSTER_HOST', '127.0.0.1'),
-        'port' => env('REDIS_CLUSTER_PORT', '6379'),
-        'prefix' => env('REDIS_CLUSTER_PREFIX', ''),
-    ],
-];

+ 10 - 0
api-v13/config/mint.php

@@ -87,6 +87,16 @@ return [
         'expire' => env('CACHE_EXPIRE', 36000),
     ],
 
+    /**
+     * 集群使用独立的配置
+     * 而且不能用laravel 原生Redis, Cache 需要手写连接函数
+     * https://github.com/phpredis/phpredis/blob/develop/cluster.md
+     */
+    'redis_cluster' => [
+        'host' => env('REDIS_CLUSTER_HOST', '127.0.0.1'),
+        'port' => env('REDIS_CLUSTER_PORT', '6379'),
+        'prefix' => env('REDIS_CLUSTER_PREFIX', ''),
+    ],
     /*
     |--------------------------------------------------------------------------
     | 另外增添的路径

+ 2 - 0
api-v13/documents/tasks.md

@@ -1,5 +1,7 @@
 # task list
 
+全部的消息队列和定时任务列表
+
 ## MQ workers
 
 ```bash

+ 3 - 3
api-v13/tests/Feature/ReadingAnnotationsTest.php

@@ -1,9 +1,9 @@
 <?php
 
+use App\Facades\RedisCache;
 use App\Models\Discussion;
 use App\Services\PaliContentService;
 use Illuminate\Foundation\Testing\RefreshDatabase;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Str;
 
 uses(RefreshDatabase::class);
@@ -89,9 +89,9 @@ it('drops the paragraph cache when either kind of annotation changes', function
     $this->getJson($url)->assertOk();
 
     $key = PaliContentService::paragraphCacheKey(9101, 1, $channel, 'html');
-    expect(Cache::has($key))->toBeTrue();
+    expect(RedisCache::has($key))->toBeTrue();
 
     makeAnnotation($sentence->uid, 'note', '译者按。', 5);
-    expect(Cache::has($key))->toBeFalse();
+    expect(RedisCache::has($key))->toBeFalse();
     expect($this->getJson($url)->json('data.0.display'))->toContain('译者按。');
 });

+ 211 - 0
api-v13/tests/Feature/RedisCacheTest.php

@@ -0,0 +1,211 @@
+<?php
+
+use App\Facades\RedisCache;
+use App\Services\RedisCache as RedisCacheService;
+
+/*
+| 第一部分:语义。TestCase 已把门面换成 RedisCacheFake,跑的是 RedisCache
+| 本身的 JSON 序列化 / TTL / null 逻辑;TTL 与 null 语义与 Laravel Cache 逐条对齐。
+*/
+
+test('get 不存在时返回默认值,闭包默认值会被求值', function () {
+    expect(RedisCache::get('missing'))->toBeNull()
+        ->and(RedisCache::get('missing', []))->toBe([])
+        ->and(RedisCache::get('missing', fn () => 'lazy'))->toBe('lazy');
+});
+
+test('put 后 get 原样取回数组、字符串、布尔', function () {
+    RedisCache::put('arr', ['a' => 1, '中' => '文']);
+    RedisCache::put('str', 'abc');
+    RedisCache::put('bool', false);
+
+    expect(RedisCache::get('arr'))->toBe(['a' => 1, '中' => '文'])
+        ->and(RedisCache::get('str'))->toBe('abc')
+        ->and(RedisCache::get('bool'))->toBeFalse();
+});
+
+test('JSON 往返:数字保持类型,数字字符串仍是字符串', function () {
+    RedisCache::put('int', 7);
+    RedisCache::put('float', 1.0);
+    RedisCache::put('numeric-string', '007');
+
+    expect(RedisCache::get('int'))->toBe(7)
+        ->and(RedisCache::get('float'))->toBe(1.0)
+        ->and(RedisCache::get('numeric-string'))->toBe('007');
+});
+
+test('对象读回来是关联数组', function () {
+    RedisCache::put('obj', (object) ['a' => 1, 'nested' => (object) ['b' => 'ā']]);
+    RedisCache::put('collection', collect([['id' => 1], ['id' => 2]]));
+
+    expect(RedisCache::get('obj'))->toBe(['a' => 1, 'nested' => ['b' => 'ā']])
+        ->and(RedisCache::get('collection'))->toBe([['id' => 1], ['id' => 2]]);
+});
+
+test('存不了 JSON 的值写入时抛异常', function () {
+    RedisCache::put('bad', "\xB1\x31");
+})->throws(JsonException::class);
+
+test('put 带 TTL 到期失效', function () {
+    RedisCache::put('int-ttl', 'x', 10);
+    RedisCache::put('date-ttl', 'x', now()->addSeconds(10));
+    RedisCache::put('interval-ttl', 'x', new DateInterval('PT10S'));
+
+    $this->travel(11)->seconds();
+
+    expect(RedisCache::has('int-ttl'))->toBeFalse()
+        ->and(RedisCache::has('date-ttl'))->toBeFalse()
+        ->and(RedisCache::has('interval-ttl'))->toBeFalse();
+});
+
+test('put / remember 不带 TTL 时按默认 30 天过期', function () {
+    RedisCache::put('no-ttl', 'x');
+    RedisCache::put('null-ttl', 'x', null);
+    RedisCache::remember('remember-null-ttl', null, fn () => 'x');
+
+    $this->travelTo(now()->addDays(30)->subSecond());
+    expect(RedisCache::has('no-ttl'))->toBeTrue()
+        ->and(RedisCache::has('null-ttl'))->toBeTrue()
+        ->and(RedisCache::has('remember-null-ttl'))->toBeTrue();
+
+    $this->travel(1)->seconds();
+    expect(RedisCache::has('no-ttl'))->toBeFalse()
+        ->and(RedisCache::has('null-ttl'))->toBeFalse()
+        ->and(RedisCache::has('remember-null-ttl'))->toBeFalse();
+});
+
+test('put 的 TTL <= 0 等同 forget', function () {
+    RedisCache::put('k', 'x');
+    RedisCache::put('k', 'y', 0);
+
+    expect(RedisCache::has('k'))->toBeFalse();
+});
+
+test('remember 命中时不执行回调', function () {
+    RedisCache::put('k', 'cached');
+
+    $value = RedisCache::remember('k', 60, fn () => throw new RuntimeException('不该执行'));
+
+    expect($value)->toBe('cached');
+});
+
+test('remember 回调返回 null 时不算命中,下次重新计算', function () {
+    $calls = 0;
+    $callback = function () use (&$calls) {
+        $calls++;
+
+        return null;
+    };
+
+    RedisCache::remember('k', 60, $callback);
+    RedisCache::remember('k', 60, $callback);
+
+    expect($calls)->toBe(2);
+});
+
+test('has 对 null 值返回 false', function () {
+    RedisCache::put('k', null);
+
+    expect(RedisCache::has('k'))->toBeFalse();
+});
+
+test('forget 返回是否真的删掉了', function () {
+    RedisCache::put('k', 'x');
+
+    expect(RedisCache::forget('k'))->toBeTrue()
+        ->and(RedisCache::forget('k'))->toBeFalse()
+        ->and(RedisCache::has('k'))->toBeFalse();
+});
+
+test('key 带不带开头的 / 是同一个 key', function () {
+    RedisCache::put('a/b', 1);
+    RedisCache::put('/c', 2);
+
+    expect(RedisCache::get('/a/b'))->toBe(1)
+        ->and(RedisCache::get('c'))->toBe(2);
+});
+
+test('increment 从 0 开始累加,返回新值', function () {
+    expect(RedisCache::increment('c'))->toBe(1)
+        ->and(RedisCache::increment('c', 5))->toBe(6)
+        ->and(RedisCache::get('c'))->toBe(6);
+});
+
+/*
+| 第二部分:契约。连真实 Redis Cluster(mint.redis_cluster),证明 key 名带前缀、
+| 值是 JSON、整数是纯文本(ai-translate 用 int() 读 /task/{id}/pointer)、TTL 与写值一条命令落地,
+| 以及 phpredis 对不存在的 key 返回 false 时被归一成 null。集群不可用时跳过。
+*/
+
+function redisCacheTestPrefix(): string
+{
+    return 'pest-redis-cache';
+}
+
+function rawRedisCluster(): RedisCluster
+{
+    return new RedisCluster(null, [config('mint.redis_cluster.host').':'.config('mint.redis_cluster.port')]);
+}
+
+function realRedisAvailable(): bool
+{
+    try {
+        rawRedisCluster()->get('pest-ping');
+
+        return true;
+    } catch (Throwable) {
+        return false;
+    }
+}
+
+afterEach(function () {
+    if (! realRedisAvailable()) {
+        return;
+    }
+
+    $redis = rawRedisCluster();
+    foreach (['pointer', 'ttl', 'missing', 'default-ttl'] as $key) {
+        $redis->del(redisCacheTestPrefix().'/'.$key);
+    }
+});
+
+test('数字以纯文本落在集群里,key 带前缀', function () {
+    (new RedisCacheService(prefix: redisCacheTestPrefix()))->put('pointer', 7);
+
+    expect(rawRedisCluster()->get(redisCacheTestPrefix().'/pointer'))->toBe('7');
+})->skip(fn () => ! realRedisAvailable(), '没有可用的 Redis Cluster');
+
+test('带 TTL 写入时 key 带过期时间,读回原值', function () {
+    $cache = new RedisCacheService(prefix: redisCacheTestPrefix());
+    $cache->put('ttl', ['a' => 1, 'b' => 'ā'], 60);
+
+    expect(rawRedisCluster()->get(redisCacheTestPrefix().'/ttl'))->toBe('{"a":1,"b":"ā"}')
+        ->and($cache->get('ttl'))->toBe(['a' => 1, 'b' => 'ā'])
+        ->and(rawRedisCluster()->ttl(redisCacheTestPrefix().'/ttl'))->toBeGreaterThan(0)->toBeLessThanOrEqual(60);
+})->skip(fn () => ! realRedisAvailable(), '没有可用的 Redis Cluster');
+
+test('不带 TTL 写入时集群里的 key 带默认 30 天过期', function () {
+    (new RedisCacheService(prefix: redisCacheTestPrefix()))->put('default-ttl', 'x');
+
+    expect(rawRedisCluster()->ttl(redisCacheTestPrefix().'/default-ttl'))
+        ->toBeGreaterThan(RedisCacheService::$defaultTtl - 10)
+        ->toBeLessThanOrEqual(RedisCacheService::$defaultTtl);
+})->skip(fn () => ! realRedisAvailable(), '没有可用的 Redis Cluster');
+
+test('前缀与 key 之间用 / 隔开,key 自带 / 时不重复', function () {
+    $cache = new RedisCacheService(prefix: redisCacheTestPrefix());
+    $cache->put('pointer', 1, 60);
+    $cache->put('/ttl', 2, 60);
+
+    expect(rawRedisCluster()->get(redisCacheTestPrefix().'/pointer'))->toBe('1')
+        ->and(rawRedisCluster()->get(redisCacheTestPrefix().'/ttl'))->toBe('2')
+        ->and(rawRedisCluster()->exists(redisCacheTestPrefix().'//ttl'))->toBe(0);
+})->skip(fn () => ! realRedisAvailable(), '没有可用的 Redis Cluster');
+
+test('不存在的 key 读到 null / 默认值,forget 返回 false', function () {
+    $cache = new RedisCacheService(prefix: redisCacheTestPrefix());
+
+    expect($cache->get('missing'))->toBeNull()
+        ->and($cache->get('missing', 'd'))->toBe('d')
+        ->and($cache->forget('missing'))->toBeFalse();
+})->skip(fn () => ! realRedisAvailable(), '没有可用的 Redis Cluster');

+ 3 - 3
api-v13/tests/Feature/TipitakaReadingChunkTest.php

@@ -186,9 +186,9 @@ it('clamps page_size to the per-block ceiling and reports the effective value',
     expect($this->getJson($base.'&page_size=1000')->assertOk()->json('meta'))
         ->toMatchArray(['page_size' => 200, 'page_size_unit' => 'para']);
 
-    // 字节模式上限 5000
-    expect($this->getJson($base.'&page_size=20000&unit=byte')->assertOk()->json('meta'))
-        ->toMatchArray(['page_size' => 5000, 'page_size_unit' => 'byte']);
+    // 字节模式上限 50000
+    expect($this->getJson($base.'&page_size=200000&unit=byte')->assertOk()->json('meta'))
+        ->toMatchArray(['page_size' => 50000, 'page_size_unit' => 'byte']);
 
     // 没到上限的照常返回原值
     expect($this->getJson($base.'&page_size=3')->assertOk()->json('meta.page_size'))->toBe(3);

+ 3 - 3
api-v13/tests/Feature/TipitakaReadingRenderTest.php

@@ -1,10 +1,10 @@
 <?php
 
+use App\Facades\RedisCache;
 use App\Models\PaliText;
 use App\Models\Sentence;
 use App\Services\PaliContentService;
 use Illuminate\Foundation\Testing\RefreshDatabase;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Str;
 
 uses(RefreshDatabase::class);
@@ -157,13 +157,13 @@ it('caches the paragraph and drops the cache when a sentence changes', function
     $this->getJson($url)->assertOk();
 
     $key = PaliContentService::paragraphCacheKey(9001, 1, $channel, 'html');
-    expect(Cache::has($key))->toBeTrue();
+    expect(RedisCache::has($key))->toBeTrue();
 
     $sentence = Sentence::where('book_id', 9001)->where('paragraph', 1)->orderBy('word_start')->first();
     $sentence->content = 'changed sentence';
     $sentence->save();
 
-    expect(Cache::has($key))->toBeFalse();
+    expect(RedisCache::has($key))->toBeFalse();
     expect($this->getJson($url)->json('data.0.display'))->toContain('changed sentence');
 });
 

+ 12 - 0
api-v13/tests/TestCase.php

@@ -2,6 +2,7 @@
 
 namespace Tests;
 
+use App\Facades\RedisCache;
 use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
 
 abstract class TestCase extends BaseTestCase
@@ -44,4 +45,15 @@ abstract class TestCase extends BaseTestCase
 
         return parent::createApplication();
     }
+
+    /**
+     * RedisCache 默认换成内存实现,相当于原来 CACHE_STORE=array 的作用:
+     * 测试不碰开发 Redis,CI 也不需要起 Redis。
+     */
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        RedisCache::fake();
+    }
 }