Procházet zdrojové kódy

feat: 新增 pali 同义词版本切换运维接口

PUT /api/ops/update-pali-synonyms/{version},把索引的 synonyms_path
切到 analysis/pali-synonyms-<VERSION>.txt。

- app/Http/Middleware/OpsToken.php: 新增。解析 Authorization: Bearer <token>,
  用 hash_equals 与 APP_OPS_TOKEN 比对,不匹配或未配置令牌返回 403。
- bootstrap/app.php: 注册中间件别名 ops.token。
- config/mint.php: 新增 app.ops_token,读取 .env 的 APP_OPS_TOKEN。
- .env.example: 补上 APP_OPS_TOKEN 键。
- app/Services/OpenSearchService.php: 新增 updatePaliSynonymsPath(),校验版本号字符集、
  close → putSettings → open(open 放在 finally,避免索引停在 close 状态);
  新增 getPaliSynonymsPath() 便于核对当前版本。
- app/Http/Controllers/UpdatePaliSynonymsController.php: 新增。update() 调用服务并用
  基类 ok()/error() 返回,失败写 Log::error 并返回 500。
- routes/api.php: 新增 ops 路由组(middleware ops.token),版本号约束 [A-Za-z0-9._-]+
  防止路径穿越。
- tests/Feature/OpsUpdatePaliSynonymsTest.php: 新增。覆盖缺失/错误/未配置令牌的 403、
  成功更新、OpenSearch 异常返回 500、非法版本号 404。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda před 2 týdny
rodič
revize
1bd248825f

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

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

+ 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);
+    }
+}

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

@@ -470,6 +470,74 @@ class OpenSearchService
         return $response;
         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
+    {
+        $index = config('mint.opensearch.index');
+        $settings = $this->client->indices()->getSettings(['index' => $index]);
+
+        return $settings[$index]['settings']['index']['analysis']['filter']['pali_synonyms']['synonyms_path'] ?? null;
+    }
+
     /**
     /**
      * 删除当前索引
      * 删除当前索引
      *
      *

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

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

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

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

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

@@ -1,134 +1,132 @@
 <?php
 <?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\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\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\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\CourseController;
 use App\Http\Controllers\CourseMemberController;
 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\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\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\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\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\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\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\SearchPaliDataController;
 use App\Http\Controllers\SearchPaliWbwController;
 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\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\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\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\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\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/image', [SentenceInfoController::class, 'showprogress']);
 Route::get('/api/sentence/progress/daily/image', [SentenceInfoController::class, 'showprogressdaily']);
 Route::get('/api/sentence/progress/daily/image', [SentenceInfoController::class, 'showprogressdaily']);
 
 
-
 Route::group([
 Route::group([
     'prefix' => 'v2',
     'prefix' => 'v2',
-    'as' => 'v2.'
+    'as' => 'v2.',
 ], function () {
 ], function () {
     Route::apiResource('wbw_templates', WbwTemplateController::class);
     Route::apiResource('wbw_templates', WbwTemplateController::class);
 
 
     Route::apiResource('terms', DhammaTermController::class);
     Route::apiResource('terms', DhammaTermController::class);
     Route::apiResource('terms-export', TermExportController::class);
     Route::apiResource('terms-export', TermExportController::class);
     Route::get('terms-import', [TermExportController::class, 'import']);
     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('sentence', SentenceController::class);
     Route::apiResource('sent-in-channel', SentInChannelController::class);
     Route::apiResource('sent-in-channel', SentInChannelController::class);
     Route::apiResource('sentpr', SentPrController::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('progress', ProgressChapterController::class);
     Route::apiResource('tag', TagController::class);
     Route::apiResource('tag', TagController::class);
     Route::apiResource('view', ViewController::class);
     Route::apiResource('view', ViewController::class);
@@ -174,10 +171,10 @@ Route::group([
     Route::get('sent_history_contribution', [SentHistoryController::class, 'contribution']);
     Route::get('sent_history_contribution', [SentHistoryController::class, 'contribution']);
     Route::apiResource('palitext', PaliTextController::class);
     Route::apiResource('palitext', PaliTextController::class);
     Route::apiResource('channel', ChannelController::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::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::delete('userdict', [UserDictController::class, 'delete']);
     Route::apiResource('userdict', UserDictController::class);
     Route::apiResource('userdict', UserDictController::class);
 
 
@@ -206,7 +203,7 @@ Route::group([
     Route::post('export_wbw', [ExportWbwController::class, 'index']);
     Route::post('export_wbw', [ExportWbwController::class, 'index']);
     Route::apiResource('attachments', UploadController::class);
     Route::apiResource('attachments', UploadController::class);
     Route::apiResource('discussion', DiscussionController::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::get('discussion-anchor/{id}', [DiscussionController::class, 'anchor']);
     Route::apiResource('user', UserController::class);
     Route::apiResource('user', UserController::class);
     Route::apiResource('group-member', GroupMemberController::class);
     Route::apiResource('group-member', GroupMemberController::class);
@@ -217,7 +214,7 @@ Route::group([
     Route::put('course-member_set-channel', [CourseMemberController::class, 'set_channel']);
     Route::put('course-member_set-channel', [CourseMemberController::class, 'set_channel']);
     Route::get('course-my-course', [CourseController::class, 'showMyCourseNumber']);
     Route::get('course-my-course', [CourseController::class, 'showMyCourseNumber']);
     Route::get('course-curr', [CourseMemberController::class, 'curr']);
     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('exercise', ExerciseController::class);
     Route::apiResource('article-map', ArticleMapController::class);
     Route::apiResource('article-map', ArticleMapController::class);
@@ -228,13 +225,13 @@ Route::group([
     Route::apiResource('user-statistic', UserStatisticController::class);
     Route::apiResource('user-statistic', UserStatisticController::class);
     Route::apiResource('sent-sim', SentSimController::class);
     Route::apiResource('sent-sim', SentSimController::class);
     Route::apiResource('nissaya-ending', NissayaEndingController::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('nissaya-card', NissayaCardController::class);
     Route::apiResource('relation', RelationController::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('term-vocabulary', TermVocabularyController::class);
     Route::apiResource('related-paragraph', RelatedParagraphController::class);
     Route::apiResource('related-paragraph', RelatedParagraphController::class);
     Route::apiResource('search', SearchController::class);
     Route::apiResource('search', SearchController::class);
@@ -319,10 +316,21 @@ Route::group([
     Route::get('mock/openai/models', [MockOpenAIController::class, 'models']);
     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([
 Route::group([
     'prefix' => 'v3',
     'prefix' => 'v3',
-    'as' => 'v3.'
+    'as' => 'v3.',
 ], function () {
 ], function () {
     Route::apiResource('search', SearchPlusController::class);
     Route::apiResource('search', SearchPlusController::class);
     Route::apiResource('search-suggest', SearchSuggestController::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();
+});