Przeglądaj źródła

Merge pull request #2427 from visuddhinanda/development

Development
visuddhinanda 2 tygodni temu
rodzic
commit
8a8bc2ce2d

+ 3 - 0
api-v13/.env.example

@@ -72,3 +72,6 @@ OPENSEARCH_PASSWORD=""
 OPENSEARCH_SSL_VERIFICATION=false
 
 JWT_SECRETS_KEY=
+
+# 运维接口令牌 /api/ops/*
+APP_OPS_TOKEN=

+ 109 - 9
api-v13/app/Console/Commands/ExportPaliSynonyms.php

@@ -2,19 +2,21 @@
 
 namespace App\Console\Commands;
 
-use Illuminate\Console\Command;
 use App\Http\Api\DictApi;
-use App\Models\UserDict;
 use App\Models\DhammaTerm;
+use App\Models\UserDict;
+use App\Services\OpenSearchService;
+use Illuminate\Console\Command;
 
 class ExportPaliSynonyms extends Command
 {
     /**
      * The name and signature of the console command.
-     * php artisan export:pali.synonyms --output=
+     * php artisan export:pali.synonyms --output= [--test]
+     *
      * @var string
      */
-    protected $signature = 'export:pali.synonyms {--output=}';
+    protected $signature = 'export:pali.synonyms {--output=} {--test : 只导出一行,用于快速检查输出格式}';
 
     /**
      * The console command description.
@@ -40,8 +42,9 @@ class ExportPaliSynonyms extends Command
      */
     public function handle()
     {
-        if (!$this->option('output')) {
+        if (! $this->option('output')) {
             $this->error('please set output file option --output=file');
+
             return 1;
         }
         /*
@@ -52,14 +55,17 @@ class ExportPaliSynonyms extends Command
         $dictId[] = DictApi::getSysDict('robot_compound');
 */
         $filename = $this->option('output');
-        $fp = fopen($filename, 'w') or die("Unable to open file!");
+        $fp = fopen($filename, 'w') or exit('Unable to open file!');
 
         $parents = UserDict::select('parent')
             ->whereNotNull('parent')
             ->where('parent', '<>', '')
             ->groupBy('parent')->cursor();
 
-        foreach ($parents as  $parent) {
+        $droppedTerms = 0;
+        $droppedLines = 0;
+
+        foreach ($parents as $parent) {
             if (str_contains($parent->parent, ' ')) {
                 continue;
             }
@@ -75,7 +81,7 @@ class ExportPaliSynonyms extends Command
             foreach ($teams as $term) {
                 $wordsList[$term->meaning] = 1;
             }
-            $this->info("[{$parent->parent}] " . count($words) . " team=" . count($teams));
+            $this->info("[{$parent->parent}] ".count($words).' team='.count($teams));
             // 合并 $parent->parent, $words->word, $team->meaning 为一个字符串数组
             $combinedArray = [];
             $combinedArray[] = $parent->parent;
@@ -83,14 +89,108 @@ class ExportPaliSynonyms extends Command
                 $combinedArray[] = $word;
             }
 
+            // 过滤掉会被 analyzer 完全消除的 term,否则建索引时会失败
+            $termCount = count($combinedArray);
+            $combinedArray = $this->filterSynonymTerms($combinedArray);
+            $droppedTerms += $termCount - count($combinedArray);
+
+            // 同义词行至少要有两个 term 才有意义
+            if (count($combinedArray) < 2) {
+                $droppedLines++;
+
+                continue;
+            }
+
             // 将 $combinedArray 写入 CSV 文件
             fputcsv($fp, $combinedArray);
-        }
 
+            if ($this->option('test')) {
+                $this->warn('--test 已开启,只导出一行。');
+                break;
+            }
+        }
 
         // 关闭文件
         fclose($fp);
+
+        // 写index.json
+        $info = [
+            'index' => config('mint.opensearch.index'),
+            'pali_synonyms' => app(OpenSearchService::class)->getPaliSynonymsSetting(),
+        ];
+        file_put_contents(
+            $this->changeExtensionToJson($filename),
+            json_encode($info, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
+        );
+        $this->info("过滤掉非法 term {$droppedTerms} 个,整行丢弃 {$droppedLines} 行。");
         $this->info('done');
+
         return 0;
     }
+
+    /**
+     * 词典源码标记,这类 term 不是真正的词条
+     *
+     * 1. 含 # ‹ › 的:如 "#=cetayati)"、"(#=)(‹paññāpeti)"。
+     *    其中以 # 开头的 term 若落在行首,整行会被 OpenSearch 当成注释静默忽略。
+     * 2. 以 ( [ < 开头的:如 "(ku的离格)"、"[ava-hīḷanā<hīḍ]",是词源/变格标注。
+     *    只匹配开头,保证 "阿拉汉[果]"、"bhesajja[ṃ]" 这类正常释义不受影响。
+     */
+    private const DIRTY_TERM_PATTERN = '/[#‹›]|^[(\[<<]/u';
+
+    /**
+     * 过滤掉不能用作同义词的 term,并按原顺序去重
+     *
+     * 丢弃两类 term:
+     * 1. 不含任何字母或数字的(例如 "②"、"——"、"?)")。OpenSearch 的 synonym_graph
+     *    filter 会用 analyzer 分析每一个 term,这类 term 分析后被完全消除,
+     *    建索引时会抛出 illegal_argument_exception: Failed to build synonyms。
+     * 2. 带词典源码标记的脏词条,见 self::DIRTY_TERM_PATTERN。
+     *
+     * @param  array<int, string>  $terms
+     * @return array<int, string>
+     */
+    private function filterSynonymTerms(array $terms): array
+    {
+        $seen = [];
+        $filtered = [];
+        foreach ($terms as $term) {
+            $term = trim((string) $term);
+            if ($term === '' || preg_match('/[\p{L}\p{Nd}]/u', $term) !== 1) {
+                continue;
+            }
+            if (preg_match(self::DIRTY_TERM_PATTERN, $term) === 1) {
+                continue;
+            }
+            if (isset($seen[$term])) {
+                continue;
+            }
+            $seen[$term] = true;
+            $filtered[] = $term;
+        }
+
+        return $filtered;
+    }
+
+    /**
+     * 将给定文件路径的扩展名替换为 .json
+     *
+     * @param  string  $filePath  完整的文件路径
+     * @return string 新的文件路径
+     */
+    private function changeExtensionToJson(string $filePath): string
+    {
+        // 获取路径信息
+        $pathInfo = pathinfo($filePath);
+
+        // 提取目录、文件名(不含扩展名)
+        $dirname = $pathInfo['dirname'] ?? '';
+        $filename = $pathInfo['filename'] ?? '';
+
+        // 如果目录不是根目录,则添加目录分隔符
+        $dirname = rtrim($dirname, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
+
+        // 构建新路径
+        return $dirname.$filename.'.json';
+    }
 }

+ 62 - 0
api-v13/app/Http/Controllers/UpdatePaliSynonymsController.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Services\OpenSearchService;
+use Exception;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\Log;
+
+/**
+ * 更新 pali 同义词文件版本(运维接口)
+ *
+ * 鉴权:请求头 Authorization: Bearer <APP_OPS_TOKEN>,由 ops.token 中间件校验,
+ * 不匹配返回 403。
+ *
+ * 请求:
+ *   PUT /api/ops/update-pali-synonyms/20260731
+ *
+ * 返回:
+ * {
+ *   "ok": true,
+ *   "data": {
+ *     "index": "wikipali_20260516",
+ *     "version": "20260731",
+ *     "synonyms_path": "analysis/pali-synonyms-20260731.txt"
+ *   },
+ *   "message": ""
+ * }
+ */
+class UpdatePaliSynonymsController extends Controller
+{
+    public function __construct(protected OpenSearchService $searchService) {}
+
+    /**
+     * 更新索引使用的 pali 同义词文件版本
+     *
+     * 将 pali_synonyms filter 的 synonyms_path 指向
+     * analysis/pali-synonyms-{$version}.txt,该文件须已上传到
+     * OpenSearch 各节点的 config 目录。
+     *
+     * @param  string  $version  同义词文件版本号,例如 20260731
+     */
+    public function update(string $version): JsonResponse
+    {
+        try {
+            $result = $this->searchService->updatePaliSynonymsPath($version);
+
+            return $this->ok([
+                'index' => config('mint.opensearch.index'),
+                'version' => $version,
+                'synonyms_path' => $result['path'],
+            ]);
+        } catch (Exception $e) {
+            Log::error('UpdatePaliSynonymsController::update', [
+                'version' => $version,
+                'message' => $e->getMessage(),
+            ]);
+
+            return $this->error('更新同义词文件失败:'.$e->getMessage(), null, 500);
+        }
+    }
+}

+ 38 - 0
api-v13/app/Http/Middleware/OpsToken.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Closure;
+use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * 运维接口鉴权中间件
+ *
+ * 从请求头 Authorization: Bearer <token> 中解析 token,
+ * 与 config('mint.app.ops_token')(.env 中的 APP_OPS_TOKEN)比对,
+ * 不匹配时返回 403。
+ */
+class OpsToken
+{
+    /**
+     * Handle an incoming request.
+     *
+     * @param  Closure(Request): (Response)  $next
+     */
+    public function handle(Request $request, Closure $next): Response
+    {
+        $expected = (string) config('mint.app.ops_token');
+        $token = (string) $request->bearerToken();
+
+        if ($expected === '' || $token === '' || ! hash_equals($expected, $token)) {
+            return response()->json([
+                'ok' => false,
+                'data' => null,
+                'message' => 'Forbidden',
+            ], 403, [], JSON_UNESCAPED_UNICODE);
+        }
+
+        return $next($request);
+    }
+}

+ 78 - 0
api-v13/app/Services/OpenSearchService.php

@@ -470,6 +470,84 @@ class OpenSearchService
         return $response;
     }
 
+    /**
+     * 切换 pali 同义词文件版本
+     *
+     * 把 pali_synonyms filter 的 synonyms_path 指向
+     * analysis/pali-synonyms-{$version}.txt(文件须已存在于 OpenSearch
+     * 各节点的 config 目录中,否则索引无法重新打开)。
+     *
+     * synonyms_path 属于静态 settings,必须 close → putSettings → open。
+     * 无论 putSettings 成功与否都会尝试重新打开索引,避免索引停留在 close 状态。
+     *
+     * @param  string  $version  版本号,仅允许 [A-Za-z0-9._-]
+     * @return array{path: string, settings: array} 新路径与 OpenSearch 响应
+     *
+     * @throws Exception 版本号非法、索引不存在或 OpenSearch 拒绝时抛出
+     *
+     * @example
+     *   $service->updatePaliSynonymsPath('20260731');
+     */
+    public function updatePaliSynonymsPath(string $version): array
+    {
+        if (! preg_match('/^[A-Za-z0-9._-]+$/', $version)) {
+            throw new Exception("Invalid synonyms version [$version].");
+        }
+
+        $index = config('mint.opensearch.index');
+
+        if (! $this->client->indices()->exists(['index' => $index])) {
+            throw new Exception("Index [$index] does not exist.");
+        }
+
+        $path = "analysis/pali-synonyms-{$version}.txt";
+
+        // 以代码中的 analysis 定义为准,只替换同义词文件路径
+        $analysis = $this->indexDefinition['settings']['analysis'];
+        $analysis['filter']['pali_synonyms']['synonyms_path'] = $path;
+
+        $this->client->indices()->close(['index' => $index]);
+
+        try {
+            $response = $this->client->indices()->putSettings([
+                'index' => $index,
+                'body' => ['settings' => ['analysis' => $analysis]],
+            ]);
+        } finally {
+            $this->client->indices()->open(['index' => $index]);
+        }
+
+        Log::info('OpenSearchService::updatePaliSynonymsPath', [
+            'index' => $index,
+            'synonyms_path' => $path,
+        ]);
+
+        return ['path' => $path, 'settings' => $response];
+    }
+
+    /**
+     * 读取当前索引使用的 pali 同义词文件路径
+     *
+     * @return string|null 形如 "analysis/pali-synonyms-20260731.txt",未设置时返回 null
+     */
+    public function getPaliSynonymsPath(): ?string
+    {
+        return $this->getPaliSynonymsSetting()['synonyms_path'] ?? null;
+    }
+
+    /**
+     * 读取当前索引 pali_synonyms filter 的完整设置
+     *
+     * @return array{type?: string, synonyms_path?: string, updateable?: string}|null 未设置时返回 null
+     */
+    public function getPaliSynonymsSetting(): ?array
+    {
+        $index = config('mint.opensearch.index');
+        $settings = $this->client->indices()->getSettings(['index' => $index]);
+
+        return $settings[$index]['settings']['index']['analysis']['filter']['pali_synonyms'] ?? null;
+    }
+
     /**
      * 删除当前索引
      *

+ 12 - 5
api-v13/bootstrap/app.php

@@ -1,6 +1,9 @@
 <?php
 
 use App\Http\Middleware\HandleInertiaRequests;
+use App\Http\Middleware\OpsToken;
+use App\Http\Middleware\SetLocale;
+use App\Http\Middleware\UserOperation;
 use Illuminate\Foundation\Application;
 use Illuminate\Foundation\Configuration\Exceptions;
 use Illuminate\Foundation\Configuration\Middleware;
@@ -8,9 +11,9 @@ use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
 
 return Application::configure(basePath: dirname(__DIR__))
     ->withRouting(
-        web: __DIR__ . '/../routes/web.php',
-        api: __DIR__ . '/../routes/api.php',
-        commands: __DIR__ . '/../routes/console.php',
+        web: __DIR__.'/../routes/web.php',
+        api: __DIR__.'/../routes/api.php',
+        commands: __DIR__.'/../routes/console.php',
         health: '/up',
     )
     ->withMiddleware(function (Middleware $middleware): void {
@@ -21,12 +24,16 @@ return Application::configure(basePath: dirname(__DIR__))
 
         // API 组中间件(来自原 Laravel 8 Kernel)
         $middleware->api(append: [
-            \App\Http\Middleware\UserOperation::class,
+            UserOperation::class,
         ]);
 
         // web 组中间件
         $middleware->web(append: [
-            \App\Http\Middleware\SetLocale::class,
+            SetLocale::class,
+        ]);
+
+        $middleware->alias([
+            'ops.token' => OpsToken::class,
         ]);
     })
     ->withExceptions(function (Exceptions $exceptions): void {

+ 24 - 19
api-v13/config/mint.php

@@ -5,6 +5,12 @@ return [
         'icp_code' => env('APP_ICP_CODE', ''),
         'mps_code' => env('APP_MPS_CODE', ''),
         'jwt_secrets_key' => env('JWT_SECRETS_KEY', ''),
+        /*
+        |--------------------------------------------------------------------------
+        | 运维接口令牌(/api/ops/*),通过 Authorization: Bearer <token> 传递
+        |--------------------------------------------------------------------------
+        */
+        'ops_token' => env('APP_OPS_TOKEN', ''),
 
     ],
     'languages' => [
@@ -27,50 +33,49 @@ return [
         | snowflake id start date don't modify
         |--------------------------------------------------------------------------
         */
-        'start' => "2021-12-22",
+        'start' => '2021-12-22',
     ],
 
     'server' => [
         'rpc' => [
-            'grpc' =>  env('GRPC_WEB_SERVER', "http://localhost:9999"),
+            'grpc' => env('GRPC_WEB_SERVER', 'http://localhost:9999'),
 
             'morus' => [
-                'host' => env('MORUS_GRPC_HOST', "localhost"),
+                'host' => env('MORUS_GRPC_HOST', 'localhost'),
                 'port' => env('MORUS_GRPC_PORT', 9999),
             ],
 
             'lily' => [
-                'host' => env('LILY_GRPC_HOST', "localhost"),
+                'host' => env('LILY_GRPC_HOST', 'localhost'),
                 'port' => env('LILY_GRPC_PORT', 9000),
             ],
 
             'tulip' => [
-                'host' => env('TULIP_GRPC_HOST', "localhost"),
+                'host' => env('TULIP_GRPC_HOST', 'localhost'),
                 'port' => env('TULIP_GRPC_PORT', 9990),
             ],
         ],
         'api' => [
-            'default' => env('APP_API', "http://localhost:8000/api"),
-            'bamboo' => env('BAMBOO_API_HOST', env('APP_URL') . '/api'),
+            'default' => env('APP_API', 'http://localhost:8000/api'),
+            'bamboo' => env('BAMBOO_API_HOST', env('APP_URL').'/api'),
         ],
-        'assets' => env('ASSETS_SERVER', "localhost:9999"),
+        'assets' => env('ASSETS_SERVER', 'localhost:9999'),
 
-        'dashboard_base_path' => env('DASHBOARD_BASE_PATH', "http://127.0.0.1:3000/my"),
-
-        'cdn_urls' => explode(',', env('CDN_URLS', "https://www.wikipali.cc/downloads")),
+        'dashboard_base_path' => env('DASHBOARD_BASE_PATH', 'http://127.0.0.1:3000/my'),
 
+        'cdn_urls' => explode(',', env('CDN_URLS', 'https://www.wikipali.cc/downloads')),
 
     ],
 
     'attachments' => [
         'bucket_name' => [
-            'temporary' => env('ATTACHMENTS_TEMPORARY_BUCKET_NAME', "attachments-staging"),
-            'permanent' => env('ATTACHMENTS_PERMANENT_BUCKET_NAME', "attachments-staging"),
+            'temporary' => env('ATTACHMENTS_TEMPORARY_BUCKET_NAME', 'attachments-staging'),
+            'permanent' => env('ATTACHMENTS_PERMANENT_BUCKET_NAME', 'attachments-staging'),
         ],
     ],
 
     'cache' => [
-        //这个值prod,staging无需设置
+        // 这个值prod,staging无需设置
         'expire' => env('CACHE_EXPIRE', 36000),
     ],
 
@@ -123,13 +128,13 @@ return [
             'Claude' => 'claude-color.png',
             'api.openai.com' => 'openai.png',
             'qwen' => 'qwen-color.png',
-            'deepseek' => 'deepseek-color.png'
-        ]
+            'deepseek' => 'deepseek-color.png',
+        ],
     ],
     'mq' => [
         'loop_limit' => [
-            'ai_translate' => env('MQ_LOOP_LIMIT_AI_TRANSLATE', 0)
-        ]
+            'ai_translate' => env('MQ_LOOP_LIMIT_AI_TRANSLATE', 0),
+        ],
     ],
     'rabbitmq' => [
         'queues' => [
@@ -151,7 +156,7 @@ return [
             'heartbeat_queue' => [
                 'ttl' => 86400000, // 24小时 TTL (毫秒)
                 'max_length' => 10000,
-            ]
+            ],
         ],
 
         // 死信队列配置

+ 134 - 126
api-v13/routes/api.php

@@ -1,134 +1,132 @@
 <?php
 
-use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Route;
-
-use App\Http\Controllers\WbwTemplateController;
-use App\Http\Controllers\DhammaTermController;
-use App\Http\Controllers\SentenceController;
-use App\Http\Controllers\ProgressChapterController;
-use App\Http\Controllers\SentenceInfoController;
-use App\Http\Controllers\SentPrController;
-use App\Http\Controllers\TagController;
-use App\Http\Controllers\ViewController;
-use App\Http\Controllers\LikeController;
-use App\Http\Controllers\SentHistoryController;
-use App\Http\Controllers\PaliTextController;
+use App\Http\Controllers\AccessTokenController;
+use App\Http\Controllers\AiAssistantController;
+use App\Http\Controllers\AiModelController;
+use App\Http\Controllers\AiTranslateController;
+use App\Http\Controllers\ApiController;
+use App\Http\Controllers\ArticleController;
+use App\Http\Controllers\ArticleFtsController;
+use App\Http\Controllers\ArticleMapController;
+use App\Http\Controllers\ArticleNavController;
+use App\Http\Controllers\ArticleProgressController;
+use App\Http\Controllers\AttachmentController;
+use App\Http\Controllers\AuthController;
+use App\Http\Controllers\BookTitleController;
+use App\Http\Controllers\CaseController;
 use App\Http\Controllers\ChannelController;
-use App\Http\Controllers\UserDictController;
+use App\Http\Controllers\ChannelIOController;
+use App\Http\Controllers\ChapterContentController;
+use App\Http\Controllers\ChapterController;
+use App\Http\Controllers\ChapterIndexController;
+use App\Http\Controllers\ChapterIOController;
+use App\Http\Controllers\ChatController;
+use App\Http\Controllers\ChatMessageController;
 use App\Http\Controllers\CollectionController;
-use App\Http\Controllers\DictController;
-use App\Http\Controllers\AuthController;
-use App\Http\Controllers\ArticleController;
-use App\Http\Controllers\GroupController;
+use App\Http\Controllers\CommandController;
+use App\Http\Controllers\CompoundController;
 use App\Http\Controllers\CorpusController;
-use App\Http\Controllers\ArticleProgressController;
-use App\Http\Controllers\ExportWbwController;
-use App\Http\Controllers\WbwLookupController;
-use App\Http\Controllers\UploadController;
-use App\Http\Controllers\DiscussionController;
-use App\Http\Controllers\UserController;
-use App\Http\Controllers\GroupMemberController;
-use App\Http\Controllers\ShareController;
 use App\Http\Controllers\CourseController;
 use App\Http\Controllers\CourseMemberController;
-use App\Http\Controllers\ExerciseController;
-use App\Http\Controllers\ArticleMapController;
-use App\Http\Controllers\VocabularyController;
-use App\Http\Controllers\CaseController;
+use App\Http\Controllers\DhammaTermController;
+use App\Http\Controllers\DictController;
+use App\Http\Controllers\DictInfoController;
 use App\Http\Controllers\DictMeaningController;
-use App\Http\Controllers\UserOperationDailyController;
-use App\Http\Controllers\UserStatisticController;
-use App\Http\Controllers\SentSimController;
-use App\Http\Controllers\NissayaEndingController;
-use App\Http\Controllers\RelationController;
-use App\Http\Controllers\TermVocabularyController;
-use App\Http\Controllers\RelatedParagraphController;
-use App\Http\Controllers\SearchController;
-use App\Http\Controllers\WordIndexController;
-use App\Http\Controllers\StudioController;
+use App\Http\Controllers\DictPreferenceController;
+use App\Http\Controllers\DictStatisticController;
+use App\Http\Controllers\DictVocabularyController;
+use App\Http\Controllers\DiscussionController;
+use App\Http\Controllers\DiscussionCountController;
+use App\Http\Controllers\EditableSentenceController;
+use App\Http\Controllers\EmailCertificationController;
+use App\Http\Controllers\ExerciseController;
+use App\Http\Controllers\ExportController;
+use App\Http\Controllers\ExportWbwController;
+use App\Http\Controllers\ForgotPasswordController;
 use App\Http\Controllers\GrammarGuideController;
-use App\Http\Controllers\WbwController;
-use App\Http\Controllers\AttachmentController;
-use App\Http\Controllers\ApiController;
-use App\Http\Controllers\ProgressImgController;
-use App\Http\Controllers\RecentController;
-use App\Http\Controllers\MilestoneController;
-use App\Http\Controllers\ArticleNavController;
+use App\Http\Controllers\GroupController;
+use App\Http\Controllers\GroupMemberController;
+use App\Http\Controllers\HealthCheckController;
+use App\Http\Controllers\HeartbeatController;
+use App\Http\Controllers\InteractiveController;
 use App\Http\Controllers\InviteController;
-use App\Http\Controllers\SignUpController;
-use App\Http\Controllers\TermSummaryController;
+use App\Http\Controllers\LikeController;
+use App\Http\Controllers\MilestoneController;
+use App\Http\Controllers\MockOpenAIController;
+use App\Http\Controllers\ModelLogController;
+use App\Http\Controllers\NavArticleController;
+use App\Http\Controllers\NavCSParaController;
+use App\Http\Controllers\NavPageController;
 use App\Http\Controllers\NissayaCardController;
-use App\Http\Controllers\SentInChannelController;
-use App\Http\Controllers\ChannelIOController;
-use App\Http\Controllers\ChapterIOController;
-use App\Http\Controllers\SentenceIOController;
-use App\Http\Controllers\WebHookController;
-use App\Http\Controllers\DictStatisticController;
-use App\Http\Controllers\SearchTitleController;
-use App\Http\Controllers\TransferController;
-use App\Http\Controllers\HealthCheckController;
+use App\Http\Controllers\NissayaCoverController;
+use App\Http\Controllers\NissayaEndingController;
+use App\Http\Controllers\NotificationController;
 use App\Http\Controllers\OfflineIndexController;
-use App\Http\Controllers\TaskController;
-use App\Http\Controllers\ExportController;
-use App\Http\Controllers\DictVocabularyController;
-use App\Http\Controllers\DictInfoController;
+use App\Http\Controllers\PaliBookCategoryController;
+use App\Http\Controllers\PaliTextController;
+use App\Http\Controllers\ParagraphContentController;
 use App\Http\Controllers\PgPaliDictDownloadController;
+use App\Http\Controllers\ProgressChapterController;
+use App\Http\Controllers\ProgressController;
+use App\Http\Controllers\ProgressImgController;
+use App\Http\Controllers\ProjectController;
+use App\Http\Controllers\ProjectTreeController;
+use App\Http\Controllers\RecentController;
+use App\Http\Controllers\RelatedParagraphController;
+use App\Http\Controllers\RelationController;
+use App\Http\Controllers\ResetPasswordController;
+use App\Http\Controllers\SearchController;
+use App\Http\Controllers\SearchPageNumberController;
 use App\Http\Controllers\SearchPaliDataController;
 use App\Http\Controllers\SearchPaliWbwController;
-use App\Http\Controllers\SearchPageNumberController;
-use App\Http\Controllers\NavPageController;
-use App\Http\Controllers\BookTitleController;
-use App\Http\Controllers\SystemTermController;
-use App\Http\Controllers\TermExportController;
-use App\Http\Controllers\NavArticleController;
-use App\Http\Controllers\NavCSParaController;
+use App\Http\Controllers\SearchPlusController;
+use App\Http\Controllers\SearchSuggestController;
+use App\Http\Controllers\SearchTitleController;
+use App\Http\Controllers\SearchWordSliceController;
+use App\Http\Controllers\SentenceAttachmentController;
+use App\Http\Controllers\SentenceController;
+use App\Http\Controllers\SentenceInfoController;
+use App\Http\Controllers\SentenceIOController;
 use App\Http\Controllers\SentencesInChapterController;
-use App\Http\Controllers\CompoundController;
-use App\Http\Controllers\NotificationController;
-use App\Http\Controllers\InteractiveController;
-use App\Http\Controllers\ChapterIndexController;
-use App\Http\Controllers\WbwSentenceController;
+use App\Http\Controllers\SentHistoryController;
+use App\Http\Controllers\SentInChannelController;
+use App\Http\Controllers\SentPrController;
+use App\Http\Controllers\SentSimController;
+use App\Http\Controllers\ShareController;
+use App\Http\Controllers\SignUpController;
+use App\Http\Controllers\SiteInfoController;
 use App\Http\Controllers\SnowFlakeIdController;
-use App\Http\Controllers\ForgotPasswordController;
-use App\Http\Controllers\ResetPasswordController;
-use App\Http\Controllers\DiscussionCountController;
-use App\Http\Controllers\TagsInChapterCountController;
+use App\Http\Controllers\StudioController;
+use App\Http\Controllers\SysModelController;
+use App\Http\Controllers\SystemTermController;
+use App\Http\Controllers\TagController;
 use App\Http\Controllers\TagMapController;
-use App\Http\Controllers\EditableSentenceController;
-use App\Http\Controllers\ArticleFtsController;
-use App\Http\Controllers\NissayaCoverController;
-use App\Http\Controllers\AiTranslateController;
-use App\Http\Controllers\DictPreferenceController;
-use App\Http\Controllers\CommandController;
-use App\Http\Controllers\UserMilestoneController;
-use App\Http\Controllers\ProjectController;
-use App\Http\Controllers\TaskStatusController;
+use App\Http\Controllers\TagsInChapterCountController;
+use App\Http\Controllers\TaskController;
 use App\Http\Controllers\TaskGroupController;
-use App\Http\Controllers\ChapterController;
-use App\Http\Controllers\ProjectTreeController;
-use App\Http\Controllers\SiteInfoController;
-use App\Http\Controllers\PaliBookCategoryController;
-use App\Http\Controllers\AccessTokenController;
-use App\Http\Controllers\SearchWordSliceController;
-use App\Http\Controllers\AiModelController;
-use App\Http\Controllers\AiAssistantController;
-use App\Http\Controllers\ModelLogController;
-use App\Http\Controllers\SentenceAttachmentController;
-use App\Http\Controllers\EmailCertificationController;
-use App\Http\Controllers\MockOpenAIController;
-use App\Http\Controllers\SysModelController;
-use App\Http\Controllers\ChatController;
-use App\Http\Controllers\ChatMessageController;
-use App\Http\Controllers\SearchPlusController;
-use App\Http\Controllers\SearchSuggestController;
+use App\Http\Controllers\TaskStatusController;
+use App\Http\Controllers\TermExportController;
+use App\Http\Controllers\TermSummaryController;
+use App\Http\Controllers\TermVocabularyController;
+use App\Http\Controllers\TransferController;
+use App\Http\Controllers\UpdatePaliSynonymsController;
 use App\Http\Controllers\UpgradeController;
-use App\Http\Controllers\ChapterContentController;
-use App\Http\Controllers\ParagraphContentController;
-use App\Http\Controllers\HeartbeatController;
-use App\Http\Controllers\ProgressController;
-
-
+use App\Http\Controllers\UploadController;
+use App\Http\Controllers\UserController;
+use App\Http\Controllers\UserDictController;
+use App\Http\Controllers\UserMilestoneController;
+use App\Http\Controllers\UserOperationDailyController;
+use App\Http\Controllers\UserStatisticController;
+use App\Http\Controllers\ViewController;
+use App\Http\Controllers\VocabularyController;
+use App\Http\Controllers\WbwController;
+use App\Http\Controllers\WbwLookupController;
+use App\Http\Controllers\WbwSentenceController;
+use App\Http\Controllers\WbwTemplateController;
+use App\Http\Controllers\WebHookController;
+use App\Http\Controllers\WordIndexController;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Route;
 
 /*
 |--------------------------------------------------------------------------
@@ -148,22 +146,21 @@ Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
 Route::get('/api/sentence/progress/image', [SentenceInfoController::class, 'showprogress']);
 Route::get('/api/sentence/progress/daily/image', [SentenceInfoController::class, 'showprogressdaily']);
 
-
 Route::group([
     'prefix' => 'v2',
-    'as' => 'v2.'
+    'as' => 'v2.',
 ], function () {
     Route::apiResource('wbw_templates', WbwTemplateController::class);
 
     Route::apiResource('terms', DhammaTermController::class);
     Route::apiResource('terms-export', TermExportController::class);
     Route::get('terms-import', [TermExportController::class, 'import']);
-    Route::get('system-term/{lang}/{word}', [SystemTermController::class, "show"]);
+    Route::get('system-term/{lang}/{word}', [SystemTermController::class, 'show']);
 
     Route::apiResource('sentence', SentenceController::class);
     Route::apiResource('sent-in-channel', SentInChannelController::class);
     Route::apiResource('sentpr', SentPrController::class);
-    Route::post('sent-pr-tree', [SentPrController::class, "pr_tree"]);
+    Route::post('sent-pr-tree', [SentPrController::class, 'pr_tree']);
     Route::apiResource('progress', ProgressChapterController::class);
     Route::apiResource('tag', TagController::class);
     Route::apiResource('view', ViewController::class);
@@ -174,10 +171,10 @@ Route::group([
     Route::get('sent_history_contribution', [SentHistoryController::class, 'contribution']);
     Route::apiResource('palitext', PaliTextController::class);
     Route::apiResource('channel', ChannelController::class);
-    Route::patch('channel', [ChannelController::class, "patch"]);
-    Route::get('channel-name/{name}', [ChannelController::class, "showByName"]);
+    Route::patch('channel', [ChannelController::class, 'patch']);
+    Route::get('channel-name/{name}', [ChannelController::class, 'showByName']);
     Route::get('channel-my-number', [ChannelController::class, 'showMyNumber']);
-    Route::post('channel-progress', [ChannelController::class, "progress"]);
+    Route::post('channel-progress', [ChannelController::class, 'progress']);
     Route::delete('userdict', [UserDictController::class, 'delete']);
     Route::apiResource('userdict', UserDictController::class);
 
@@ -206,7 +203,7 @@ Route::group([
     Route::post('export_wbw', [ExportWbwController::class, 'index']);
     Route::apiResource('attachments', UploadController::class);
     Route::apiResource('discussion', DiscussionController::class);
-    Route::post('sent-discussion-tree', [DiscussionController::class, "discussion_tree"]);
+    Route::post('sent-discussion-tree', [DiscussionController::class, 'discussion_tree']);
     Route::get('discussion-anchor/{id}', [DiscussionController::class, 'anchor']);
     Route::apiResource('user', UserController::class);
     Route::apiResource('group-member', GroupMemberController::class);
@@ -217,7 +214,7 @@ Route::group([
     Route::put('course-member_set-channel', [CourseMemberController::class, 'set_channel']);
     Route::get('course-my-course', [CourseController::class, 'showMyCourseNumber']);
     Route::get('course-curr', [CourseMemberController::class, 'curr']);
-    Route::get('course-member-export', [CourseMemberController::class, "export"]);
+    Route::get('course-member-export', [CourseMemberController::class, 'export']);
 
     Route::apiResource('exercise', ExerciseController::class);
     Route::apiResource('article-map', ArticleMapController::class);
@@ -228,13 +225,13 @@ Route::group([
     Route::apiResource('user-statistic', UserStatisticController::class);
     Route::apiResource('sent-sim', SentSimController::class);
     Route::apiResource('nissaya-ending', NissayaEndingController::class);
-    Route::get('nissaya-ending-export', [NissayaEndingController::class, "export"]);
-    Route::get('nissaya-ending-import', [NissayaEndingController::class, "import"]);
-    Route::get('nissaya-ending-vocabulary', [NissayaEndingController::class, "vocabulary"]);
+    Route::get('nissaya-ending-export', [NissayaEndingController::class, 'export']);
+    Route::get('nissaya-ending-import', [NissayaEndingController::class, 'import']);
+    Route::get('nissaya-ending-vocabulary', [NissayaEndingController::class, 'vocabulary']);
     Route::apiResource('nissaya-card', NissayaCardController::class);
     Route::apiResource('relation', RelationController::class);
-    Route::get('relation-export', [RelationController::class, "export"]);
-    Route::get('relation-import', [RelationController::class, "import"]);
+    Route::get('relation-export', [RelationController::class, 'export']);
+    Route::get('relation-import', [RelationController::class, 'import']);
     Route::apiResource('term-vocabulary', TermVocabularyController::class);
     Route::apiResource('related-paragraph', RelatedParagraphController::class);
     Route::apiResource('search', SearchController::class);
@@ -319,10 +316,21 @@ Route::group([
     Route::get('mock/openai/models', [MockOpenAIController::class, 'models']);
 });
 
+/**
+ * 运维接口,Authorization: Bearer <APP_OPS_TOKEN>
+ */
+Route::group([
+    'prefix' => 'ops',
+    'as' => 'ops.',
+    'middleware' => 'ops.token',
+], function () {
+    Route::put('update-pali-synonyms/{version}', [UpdatePaliSynonymsController::class, 'update'])
+        ->where('version', '[A-Za-z0-9._-]+');
+});
 
 Route::group([
     'prefix' => 'v3',
-    'as' => 'v3.'
+    'as' => 'v3.',
 ], function () {
     Route::apiResource('search', SearchPlusController::class);
     Route::apiResource('search-suggest', SearchSuggestController::class);

+ 61 - 0
api-v13/tests/Feature/OpsUpdatePaliSynonymsTest.php

@@ -0,0 +1,61 @@
+<?php
+
+use App\Services\OpenSearchService;
+
+beforeEach(function () {
+    config(['mint.app.ops_token' => 'secret-ops-token']);
+});
+
+function updateSynonyms(?string $token, string $version = '20260731')
+{
+    $headers = $token === null ? [] : ['Authorization' => 'Bearer '.$token];
+
+    return test()->putJson("/api/ops/update-pali-synonyms/{$version}", [], $headers);
+}
+
+it('rejects a request without a token', function () {
+    updateSynonyms(null)->assertForbidden();
+});
+
+it('rejects a request with a wrong token', function () {
+    updateSynonyms('wrong-token')->assertForbidden();
+});
+
+it('rejects any token when APP_OPS_TOKEN is not configured', function () {
+    config(['mint.app.ops_token' => '']);
+
+    updateSynonyms('')->assertForbidden();
+});
+
+it('updates the synonyms path with a valid token', function () {
+    $this->mock(OpenSearchService::class)
+        ->shouldReceive('updatePaliSynonymsPath')
+        ->once()
+        ->with('20260731')
+        ->andReturn(['path' => 'analysis/pali-synonyms-20260731.txt', 'settings' => ['acknowledged' => true]]);
+
+    updateSynonyms('secret-ops-token')
+        ->assertOk()
+        ->assertJson([
+            'ok' => true,
+            'data' => [
+                'version' => '20260731',
+                'synonyms_path' => 'analysis/pali-synonyms-20260731.txt',
+            ],
+        ]);
+});
+
+it('returns 500 when OpenSearch rejects the update', function () {
+    $this->mock(OpenSearchService::class)
+        ->shouldReceive('updatePaliSynonymsPath')
+        ->once()
+        ->andThrow(new Exception('Index [wikipali] does not exist.'));
+
+    updateSynonyms('secret-ops-token')
+        ->assertStatus(500)
+        ->assertJson(['ok' => false]);
+});
+
+it('does not match a version containing path separators', function () {
+    updateSynonyms('secret-ops-token', '..%2Fetc')->assertNotFound();
+});

+ 65 - 0
api-v13/tests/Unit/ExportPaliSynonymsTest.php

@@ -0,0 +1,65 @@
+<?php
+
+use App\Console\Commands\ExportPaliSynonyms;
+
+/**
+ * 调用 ExportPaliSynonyms 的私有方法 filterSynonymTerms
+ *
+ * @param  array<int, string>  $terms
+ * @return array<int, string>
+ */
+function filterSynonymTerms(array $terms): array
+{
+    $method = new ReflectionMethod(ExportPaliSynonyms::class, 'filterSynonymTerms');
+
+    return $method->invoke(new ExportPaliSynonyms, $terms);
+}
+
+test('保留含字母或数字的 term', function () {
+    expect(filterSynonymTerms(['buddhavacana', 'buddhavacanaṃ', '心一境性', 'သမာဓိ', 'samādhi2']))
+        ->toBe(['buddhavacana', 'buddhavacanaṃ', '心一境性', 'သမာဓိ', 'samādhi2']);
+});
+
+test('丢弃会被 analyzer 完全消除的纯符号 term', function (string $term) {
+    expect(filterSynonymTerms(['khārika', $term]))->toBe(['khārika']);
+})->with([
+    '圈码数字' => '②',
+    '破折号' => '——',
+    '半个括号' => '?)',
+    '注释符号' => '(#=‹)',
+    '空字符串' => '',
+    '纯空白' => "  \t ",
+]);
+
+test('丢弃带词典源码标记的脏词条', function (string $term) {
+    expect(filterSynonymTerms([$term, 'ceteti']))->toBe(['ceteti']);
+})->with([
+    '# 开头会让整行变成注释' => '#=cetayati)',
+    '嵌套源码标记' => '(#=)(‹paññāpeti)',
+    '词源标记' => '(‹akiñcana无任何)',
+    '括号包裹的变格标注' => '(ku的离格)',
+    '方括号包裹的词根' => '[ava-hīḷanā<hīḍ]',
+    '半角尖括号' => '<upadheyya>',
+]);
+
+test('保留词中带括号的正常释义', function (string $term) {
+    expect(filterSynonymTerms(['arahant', $term]))->toBe(['arahant', $term]);
+})->with([
+    '中文补注' => '阿拉汉[果]',
+    '巴利变格补注' => 'bhesajja[ṃ]',
+    '中文夹注' => '互不分离的(俱生相应)法',
+]);
+
+test('去重并保持原顺序', function () {
+    expect(filterSynonymTerms(['eka', 'eko', 'eka', 'ekaṃ', 'eko']))
+        ->toBe(['eka', 'eko', 'ekaṃ']);
+});
+
+test('term 首尾空白被去掉', function () {
+    expect(filterSynonymTerms([' sati ', "jetavana\t"]))->toBe(['sati', 'jetavana']);
+});
+
+test('保留含空格的多词 term', function () {
+    expect(filterSynonymTerms(['jetavana', 'jetavana monastery']))
+        ->toBe(['jetavana', 'jetavana monastery']);
+});