Browse Source

fix: 检索接口拒绝空 key

空 key 不报错,而是匹配到「空值」那一类数据:wbw 检索 whereIn('real', [''])
命中 496,522 个段落,标题检索 like '%%' 命中 37,411 条。调用方拿到的是
ok:true 和满屏与查询无关的结果,比报错难发现得多。

- 新增 SearchRequest:key 必填,且至少有一个既非空白也非分隔符的字符。
  各接口有的按逗号切词、有的按分号切,只由分隔符组成的 key(`,,`、`;;`)
  在任何一边都是空的,所以判定不看具体分隔符。
- SearchController、SearchPaliWbwController 的 index 与 book_list 改用它。
- SearchPaliWbwController 另加 keywords():`dhammo,,` 有可检索的词、能过
  校验,但 explode 切出的空串一旦进 whereIn 照样会捞出那 49 万段。
- SearchEmptyKeyTest 32 例;其中 drops empty words 一例把过滤退回修复前
  会失败,确认它抓得住这个 bug。

两个控制器另有 Pint 的格式重排(import 排序、引号、elseif、docblock 等),
无行为变化。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 1 week ago
parent
commit
ca695c4bef

+ 74 - 75
api-v13/app/Http/Controllers/SearchController.php

@@ -2,32 +2,29 @@
 
 
 namespace App\Http\Controllers;
 namespace App\Http\Controllers;
 
 
-use Illuminate\Http\Request;
-use App\Models\BookTitle;
-use App\Models\FtsText;
-use App\Models\Tag;
-use App\Models\TagMap;
-use App\Models\PaliText;
-use Illuminate\Support\Facades\Http;
-use Illuminate\Support\Facades\DB;
+use App\Http\Requests\SearchRequest;
+use App\Http\Resources\SearchBookResource;
 use App\Http\Resources\SearchResource;
 use App\Http\Resources\SearchResource;
 use App\Http\Resources\SearchTitleResource;
 use App\Http\Resources\SearchTitleResource;
-use App\Http\Resources\SearchBookResource;
-use App\Tools\Tools;
-use App\Models\WbwTemplate;
+use App\Models\BookTitle;
 use App\Models\PageNumber;
 use App\Models\PageNumber;
+use App\Models\PaliText;
+use App\Models\Tag;
+use App\Models\TagMap;
 use App\Tools\PaliSearch;
 use App\Tools\PaliSearch;
-use Illuminate\Support\Facades\App;
-
+use App\Tools\Tools;
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
+use Illuminate\Support\Facades\DB;
 
 
 class SearchController extends Controller
 class SearchController extends Controller
 {
 {
     /**
     /**
      * Display a listing of the resource.
      * Display a listing of the resource.
      *
      *
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
      */
-    public function index(Request $request)
+    public function index(SearchRequest $request)
     {
     {
         switch ($request->input('view', 'pali')) {
         switch ($request->input('view', 'pali')) {
             case 'pali':
             case 'pali':
@@ -50,36 +47,38 @@ class SearchController extends Controller
                             ->orWhere('title', 'like', "%{$key}%");
                             ->orWhere('title', 'like', "%{$key}%");
                     });
                     });
                 if ($request->has('tags')) {
                 if ($request->has('tags')) {
-                    //查询搜索范围
+                    // 查询搜索范围
                     $tagItems = explode(';', $request->input('tags'));
                     $tagItems = explode(';', $request->input('tags'));
                     $bookId = [];
                     $bookId = [];
                     foreach ($tagItems as $tagItem) {
                     foreach ($tagItems as $tagItem) {
-                        # code...
+                        // code...
                         $bookId = array_merge($bookId, $this->getBookIdByTags(explode(',', $tagItem)));
                         $bookId = array_merge($bookId, $this->getBookIdByTags(explode(',', $tagItem)));
                     }
                     }
                     $table = $table->whereIn('pcd_book_id', $bookId);
                     $table = $table->whereIn('pcd_book_id', $bookId);
                 }
                 }
                 $count = $table->count();
                 $count = $table->count();
                 $table = $table->orderBy($request->input('orderby', 'book'), $request->input('dir', 'asc'));
                 $table = $table->orderBy($request->input('orderby', 'book'), $request->input('dir', 'asc'));
-                $table = $table->skip($request->input("offset", 0))
+                $table = $table->skip($request->input('offset', 0))
                     ->take($request->input('limit', 10));
                     ->take($request->input('limit', 10));
                 $result = $table->get();
                 $result = $table->get();
-                return $this->ok(["rows" => SearchTitleResource::collection($result), "count" => $count]);
+
+                return $this->ok(['rows' => SearchTitleResource::collection($result), 'count' => $count]);
                 break;
                 break;
             default:
             default:
-                # code...
+                // code...
                 break;
                 break;
         }
         }
     }
     }
+
     public function pali(Request $request)
     public function pali(Request $request)
     {
     {
         //
         //
         $bookId = [];
         $bookId = [];
         if ($request->has('book')) {
         if ($request->has('book')) {
-            $bookId = [(int)$request->input('book')];
-        } else if ($request->has('tags')) {
-            //查询搜索范围
-            //查询搜索范围
+            $bookId = [(int) $request->input('book')];
+        } elseif ($request->has('tags')) {
+            // 查询搜索范围
+            // 查询搜索范围
             $tagItems = explode(';', $request->input('tags'));
             $tagItems = explode(';', $request->input('tags'));
 
 
             foreach ($tagItems as $tagItem) {
             foreach ($tagItems as $tagItem) {
@@ -92,7 +91,7 @@ class SearchController extends Controller
         $searchBookId = [];
         $searchBookId = [];
         $queryBookId = '';
         $queryBookId = '';
         if (count($bookId) > 0) {
         if (count($bookId) > 0) {
-            $queryBookId = ' AND pcd_book_id in (' . implode(',', $bookId) . ') ';
+            $queryBookId = ' AND pcd_book_id in ('.implode(',', $bookId).') ';
         }
         }
         $key = explode(';', $request->input('key'));
         $key = explode(';', $request->input('key'));
         $param = [];
         $param = [];
@@ -100,7 +99,7 @@ class SearchController extends Controller
         switch ($request->input('match', 'case')) {
         switch ($request->input('match', 'case')) {
             case 'complete':
             case 'complete':
             case 'case':
             case 'case':
-                # code...
+                // code...
                 $querySelect_rank_base = " ts_rank('{0.1, 1, 0.3, 0.2}',
                 $querySelect_rank_base = " ts_rank('{0.1, 1, 0.3, 0.2}',
                                                 full_text_search_weighted,
                                                 full_text_search_weighted,
                                                 websearch_to_tsquery('pali', ?)) ";
                                                 websearch_to_tsquery('pali', ?)) ";
@@ -114,7 +113,7 @@ class SearchController extends Controller
                 array_push($param, implode(' ', $key));
                 array_push($param, implode(' ', $key));
                 break;
                 break;
             case 'similar':
             case 'similar':
-                # 形似,去掉变音符号
+                // 形似,去掉变音符号
                 $key = Tools::getWordEn($key[0]);
                 $key = Tools::getWordEn($key[0]);
                 $querySelect_rank = "
                 $querySelect_rank = "
                     ts_rank('{0.1, 1, 0.3, 0.2}',
                     ts_rank('{0.1, 1, 0.3, 0.2}',
@@ -133,24 +132,24 @@ class SearchController extends Controller
         $queryWhere = $_queryWhere['query'];
         $queryWhere = $_queryWhere['query'];
         $param = array_merge($param, $_queryWhere['param']);
         $param = array_merge($param, $_queryWhere['param']);
 
 
-        $querySelect_2 = "  book,paragraph,content ";
+        $querySelect_2 = '  book,paragraph,content ';
 
 
         $queryCount = "SELECT count(*) as co FROM fts_texts WHERE {$queryWhere} {$queryBookId};";
         $queryCount = "SELECT count(*) as co FROM fts_texts WHERE {$queryWhere} {$queryBookId};";
         $resultCount = DB::select($queryCount, $_queryWhere['param']);
         $resultCount = DB::select($queryCount, $_queryWhere['param']);
 
 
         $limit = $request->input('limit', 10);
         $limit = $request->input('limit', 10);
         $offset = $request->input('offset', 0);
         $offset = $request->input('offset', 0);
-        switch ($request->input('orderby', "rank")) {
+        switch ($request->input('orderby', 'rank')) {
             case 'rank':
             case 'rank':
-                $orderby = " ORDER BY rank DESC ";
+                $orderby = ' ORDER BY rank DESC ';
                 break;
                 break;
             case 'paragraph':
             case 'paragraph':
-                $orderby = " ORDER BY book,paragraph ";
+                $orderby = ' ORDER BY book,paragraph ';
                 break;
                 break;
             default:
             default:
-                $orderby = "";
+                $orderby = '';
                 break;
                 break;
-        };
+        }
         $query = "SELECT
         $query = "SELECT
             {$querySelect_rank}
             {$querySelect_rank}
             {$querySelect_highlight}
             {$querySelect_highlight}
@@ -166,17 +165,18 @@ class SearchController extends Controller
 
 
         $result = DB::select($query, $param);
         $result = DB::select($query, $param);
 
 
-        return $this->ok(["rows" => SearchResource::collection($result), "count" => $resultCount[0]->co]);
+        return $this->ok(['rows' => SearchResource::collection($result), 'count' => $resultCount[0]->co]);
     }
     }
+
     public function pali_rpc(Request $request)
     public function pali_rpc(Request $request)
     {
     {
         //
         //
         $bookId = [];
         $bookId = [];
         if ($request->has('book')) {
         if ($request->has('book')) {
-            $bookId = [(int)$request->input('book')];
-        } else if ($request->has('tags')) {
-            //查询搜索范围
-            //查询搜索范围
+            $bookId = [(int) $request->input('book')];
+        } elseif ($request->has('tags')) {
+            // 查询搜索范围
+            // 查询搜索范围
             $tagItems = explode(';', $request->input('tags'));
             $tagItems = explode(';', $request->input('tags'));
 
 
             foreach ($tagItems as $tagItem) {
             foreach ($tagItems as $tagItem) {
@@ -189,7 +189,8 @@ class SearchController extends Controller
         $offset = $request->input('offset', 0);
         $offset = $request->input('offset', 0);
         $matchMode = $request->input('match', 'case');
         $matchMode = $request->input('match', 'case');
         $result = PaliSearch::search($key, $bookId, $matchMode, $offset, $limit);
         $result = PaliSearch::search($key, $bookId, $matchMode, $offset, $limit);
-        return $this->ok(["rows" => SearchResource::collection(collect($result['rows'])), "count" => $result['total']]);
+
+        return $this->ok(['rows' => SearchResource::collection(collect($result['rows'])), 'count' => $result['total']]);
     }
     }
 
 
     public function page(Request $request)
     public function page(Request $request)
@@ -202,12 +203,12 @@ class SearchController extends Controller
         $bookId = [];
         $bookId = [];
         if ($request->has('book')) {
         if ($request->has('book')) {
             $bookId[] = $request->input('book');
             $bookId[] = $request->input('book');
-        } else if ($request->has('tags')) {
-            //查询搜索范围
-            //查询搜索范围
+        } elseif ($request->has('tags')) {
+            // 查询搜索范围
+            // 查询搜索范围
             $tagItems = explode(';', $request->input('tags'));
             $tagItems = explode(';', $request->input('tags'));
             foreach ($tagItems as $tagItem) {
             foreach ($tagItems as $tagItem) {
-                # code...
+                // code...
                 $bookId = array_merge($bookId, $this->getBookIdByTags(explode(',', $tagItem)));
                 $bookId = array_merge($bookId, $this->getBookIdByTags(explode(',', $tagItem)));
             }
             }
         }
         }
@@ -217,30 +218,28 @@ class SearchController extends Controller
         $page = explode('.', $key);
         $page = explode('.', $key);
         if (count($page) === 2) {
         if (count($page) === 2) {
             $table = PageNumber::where('type', $request->input('type'))
             $table = PageNumber::where('type', $request->input('type'))
-                ->where('volume', (int)$page[0])
-                ->where('page', (int)$page[1]);
+                ->where('volume', (int) $page[0])
+                ->where('page', (int) $page[1]);
         } else {
         } else {
             if (is_numeric($key)) {
             if (is_numeric($key)) {
                 $table = PageNumber::where('type', $request->input('type'))->where('page', $key);
                 $table = PageNumber::where('type', $request->input('type'))->where('page', $key);
             } else {
             } else {
-                $table = PageNumber::where('type', $request->input('type'))->where('page', (int)$key);
+                $table = PageNumber::where('type', $request->input('type'))->where('page', (int) $key);
             }
             }
         }
         }
 
 
-
-
         if (count($bookId) > 0) {
         if (count($bookId) > 0) {
             $table = $table->whereIn('pcd_book_id', $bookId);
             $table = $table->whereIn('pcd_book_id', $bookId);
         }
         }
         $count = $table->count();
         $count = $table->count();
         $table = $table->select(['book', 'paragraph']);
         $table = $table->select(['book', 'paragraph']);
-        $table->skip($request->input("offset", 0))->take($request->input('limit', 10));
+        $table->skip($request->input('offset', 0))->take($request->input('limit', 10));
         $result = $table->get();
         $result = $table->get();
 
 
-        return $this->ok(["rows" => SearchResource::collection($result), "count" => $count]);
+        return $this->ok(['rows' => SearchResource::collection($result), 'count' => $count]);
     }
     }
 
 
-    public function book_list(Request $request)
+    public function book_list(SearchRequest $request)
     {
     {
         $searchChapters = [];
         $searchChapters = [];
         $searchBooks = [];
         $searchBooks = [];
@@ -248,19 +247,19 @@ class SearchController extends Controller
 
 
         $bookId = [];
         $bookId = [];
         if ($request->has('tags')) {
         if ($request->has('tags')) {
-            //查询搜索范围
+            // 查询搜索范围
             $tagItems = explode(';', $request->input('tags'));
             $tagItems = explode(';', $request->input('tags'));
 
 
             foreach ($tagItems as $tagItem) {
             foreach ($tagItems as $tagItem) {
-                # code...
+                // code...
                 $bookId = array_merge($bookId, $this->getBookIdByTags(explode(',', $tagItem)));
                 $bookId = array_merge($bookId, $this->getBookIdByTags(explode(',', $tagItem)));
             }
             }
-            $queryBookId = ' AND pcd_book_id in (' . implode(',', $bookId) . ') ';
+            $queryBookId = ' AND pcd_book_id in ('.implode(',', $bookId).') ';
         }
         }
         $key = $request->input('key');
         $key = $request->input('key');
         switch ($request->input('view', 'pali')) {
         switch ($request->input('view', 'pali')) {
             case 'pali':
             case 'pali':
-                # code...
+                // code...
                 $pageHead = ['M', 'P', 'T', 'V', 'O'];
                 $pageHead = ['M', 'P', 'T', 'V', 'O'];
                 if (substr($key, 0, 4) === 'para' || in_array(substr($key, 0, 1), $pageHead)) {
                 if (substr($key, 0, 4) === 'para' || in_array(substr($key, 0, 1), $pageHead)) {
                     $queryWhere = "type='.ctl.' AND word = ?";
                     $queryWhere = "type='.ctl.' AND word = ?";
@@ -289,21 +288,21 @@ class SearchController extends Controller
                 $result = DB::select($query, [$word]);
                 $result = DB::select($query, [$word]);
                 break;
                 break;
             case 'title':
             case 'title':
-                $keyLike = '%' . $key . '%';
-                $queryWhere = "\"level\" < 8 and (\"title_en\"::text like ? or \"title\"::text like ?)";
+                $keyLike = '%'.$key.'%';
+                $queryWhere = '"level" < 8 and ("title_en"::text like ? or "title"::text like ?)';
                 $query = "SELECT pcd_book_id, count(*) as co FROM pali_texts WHERE {$queryWhere} {$queryBookId} GROUP BY pcd_book_id ORDER BY co DESC;";
                 $query = "SELECT pcd_book_id, count(*) as co FROM pali_texts WHERE {$queryWhere} {$queryBookId} GROUP BY pcd_book_id ORDER BY co DESC;";
                 $result = DB::select($query, [$keyLike, $keyLike]);
                 $result = DB::select($query, [$keyLike, $keyLike]);
                 break;
                 break;
             default:
             default:
-                # code...
+                // code...
                 return $this->error('unknown view');
                 return $this->error('unknown view');
                 break;
                 break;
         }
         }
 
 
         if ($result) {
         if ($result) {
-            return $this->ok(["rows" => SearchBookResource::collection($result), "count" => count($result)]);
+            return $this->ok(['rows' => SearchBookResource::collection($result), 'count' => count($result)]);
         } else {
         } else {
-            return $this->ok(["rows" => [], "count" => 0]);
+            return $this->ok(['rows' => [], 'count' => 0]);
         }
         }
     }
     }
 
 
@@ -315,20 +314,21 @@ class SearchController extends Controller
         switch ($match) {
         switch ($match) {
             case 'complete':
             case 'complete':
             case 'case':
             case 'case':
-                # code...
+                // code...
                 $queryWhereBase = " full_text_search_weighted @@ websearch_to_tsquery('pali', ?) ";
                 $queryWhereBase = " full_text_search_weighted @@ websearch_to_tsquery('pali', ?) ";
                 $queryWhereBody = implode(' or ', array_fill(0, count($key), $queryWhereBase));
                 $queryWhereBody = implode(' or ', array_fill(0, count($key), $queryWhereBase));
                 $queryWhere = " ({$queryWhereBody}) ";
                 $queryWhere = " ({$queryWhereBody}) ";
                 $param = array_merge($param, $key);
                 $param = array_merge($param, $key);
                 break;
                 break;
             case 'similar':
             case 'similar':
-                # 形似,去掉变音符号
+                // 形似,去掉变音符号
                 $queryWhere = " full_text_search_weighted_unaccent @@ websearch_to_tsquery('pali_unaccent', ?) ";
                 $queryWhere = " full_text_search_weighted_unaccent @@ websearch_to_tsquery('pali_unaccent', ?) ";
                 $key = Tools::getWordEn($key[0]);
                 $key = Tools::getWordEn($key[0]);
                 $param = [$key];
                 $param = [$key];
                 break;
                 break;
-        };
-        return (['query' => $queryWhere, 'param' => $param]);
+        }
+
+        return ['query' => $queryWhere, 'param' => $param];
     }
     }
 
 
     public function getBookIdByTags($tags)
     public function getBookIdByTags($tags)
@@ -338,12 +338,12 @@ class SearchController extends Controller
             return $searchBookId;
             return $searchBookId;
         }
         }
 
 
-        //查询搜索范围
+        // 查询搜索范围
         $tagIds = Tag::whereIn('name', $tags)->select('id')->get();
         $tagIds = Tag::whereIn('name', $tags)->select('id')->get();
         $paliTextIds = TagMap::where('table_name', 'pali_texts')->whereIn('tag_id', $tagIds)->select('anchor_id')->get();
         $paliTextIds = TagMap::where('table_name', 'pali_texts')->whereIn('tag_id', $tagIds)->select('anchor_id')->get();
         $paliPara = [];
         $paliPara = [];
         foreach ($paliTextIds as $key => $value) {
         foreach ($paliTextIds as $key => $value) {
-            # code...
+            // code...
             if (isset($paliPara[$value->anchor_id])) {
             if (isset($paliPara[$value->anchor_id])) {
                 $paliPara[$value->anchor_id]++;
                 $paliPara[$value->anchor_id]++;
             } else {
             } else {
@@ -352,7 +352,7 @@ class SearchController extends Controller
         }
         }
         $paliId = [];
         $paliId = [];
         foreach ($paliPara as $key => $value) {
         foreach ($paliPara as $key => $value) {
-            # code...
+            // code...
             if ($value === count($tags)) {
             if ($value === count($tags)) {
                 $paliId[] = $key;
                 $paliId[] = $key;
             }
             }
@@ -361,23 +361,23 @@ class SearchController extends Controller
 
 
         if (count($para) > 0) {
         if (count($para) > 0) {
             foreach ($para as $key => $value) {
             foreach ($para as $key => $value) {
-                # code...
+                // code...
                 $book_id = BookTitle::where('book', $value['book'])
                 $book_id = BookTitle::where('book', $value['book'])
                     ->where('paragraph', $value['paragraph'])
                     ->where('paragraph', $value['paragraph'])
                     ->value('sn');
                     ->value('sn');
-                if (!empty($book_id)) {
+                if (! empty($book_id)) {
                     $searchBookId[] = $book_id;
                     $searchBookId[] = $book_id;
                 }
                 }
             }
             }
         }
         }
+
         return $searchBookId;
         return $searchBookId;
     }
     }
 
 
     /**
     /**
      * Store a newly created resource in storage.
      * Store a newly created resource in storage.
      *
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
      */
     public function store(Request $request)
     public function store(Request $request)
     {
     {
@@ -388,7 +388,7 @@ class SearchController extends Controller
      * Display the specified resource.
      * Display the specified resource.
      *
      *
      * @param  int  $id
      * @param  int  $id
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
      */
     public function show($id)
     public function show($id)
     {
     {
@@ -398,9 +398,8 @@ class SearchController extends Controller
     /**
     /**
      * Update the specified resource in storage.
      * Update the specified resource in storage.
      *
      *
-     * @param  \Illuminate\Http\Request  $request
      * @param  int  $id
      * @param  int  $id
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
      */
     public function update(Request $request, $id)
     public function update(Request $request, $id)
     {
     {
@@ -411,7 +410,7 @@ class SearchController extends Controller
      * Remove the specified resource from storage.
      * Remove the specified resource from storage.
      *
      *
      * @param  int  $id
      * @param  int  $id
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
      */
     public function destroy($id)
     public function destroy($id)
     {
     {

+ 50 - 35
api-v13/app/Http/Controllers/SearchPaliWbwController.php

@@ -2,32 +2,49 @@
 
 
 namespace App\Http\Controllers;
 namespace App\Http\Controllers;
 
 
-
+use App\Http\Requests\SearchRequest;
+use App\Http\Resources\SearchBookResource;
+use App\Http\Resources\SearchPaliWbwResource;
+use App\Models\WbwTemplate;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
+use Illuminate\Http\Response;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
-use App\Models\WbwTemplate;
-use App\Http\Resources\SearchPaliWbwResource;
-use App\Http\Resources\SearchBookResource;
 
 
 class SearchPaliWbwController extends Controller
 class SearchPaliWbwController extends Controller
 {
 {
+    /**
+     * 把 key 拆成词表,丢掉切出来的空串。
+     *
+     * SearchRequest 只保证 key 里有内容,`dhammo,,` 照样能通过;而空串一旦进了
+     * whereIn('real', ...),就会命中 real 为空的那四百多万行、覆盖 49 万个段落,
+     * 把无关结果混进命中里。
+     *
+     * @return string[]
+     */
+    private function keywords(?string $key): array
+    {
+        $words = array_map('trim', explode(',', (string) $key));
+
+        return array_values(array_filter($words, fn (string $word): bool => $word !== ''));
+    }
+
     /**
     /**
      * Display a listing of the resource.
      * Display a listing of the resource.
      *
      *
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
      */
-    public function index(Request $request)
+    public function index(SearchRequest $request)
     {
     {
-        //获取书的范围
+        // 获取书的范围
         $bookId = [];
         $bookId = [];
         $search = new SearchController;
         $search = new SearchController;
         if ($request->has('book')) {
         if ($request->has('book')) {
             foreach (explode(',', $request->input('book')) as $key => $id) {
             foreach (explode(',', $request->input('book')) as $key => $id) {
-                $bookId[] = (int)$id;
+                $bookId[] = (int) $id;
             }
             }
-        } else if ($request->has('tags')) {
-            //查询搜索范围
-            //查询搜索范围
+        } elseif ($request->has('tags')) {
+            // 查询搜索范围
+            // 查询搜索范围
             $tagItems = explode(';', $request->input('tags'));
             $tagItems = explode(';', $request->input('tags'));
 
 
             foreach ($tagItems as $tagItem) {
             foreach ($tagItems as $tagItem) {
@@ -35,7 +52,7 @@ class SearchPaliWbwController extends Controller
             }
             }
         }
         }
 
 
-        $keyWords = explode(',', $request->input('key'));
+        $keyWords = $this->keywords($request->input('key'));
         $table = WbwTemplate::whereIn('real', $keyWords)
         $table = WbwTemplate::whereIn('real', $keyWords)
             ->groupBy(['book', 'paragraph'])
             ->groupBy(['book', 'paragraph'])
             ->selectRaw('book,paragraph,sum(weight) as rank');
             ->selectRaw('book,paragraph,sum(weight) as rank');
@@ -43,47 +60,48 @@ class SearchPaliWbwController extends Controller
         if ($request->input('bold') === 'on') {
         if ($request->input('bold') === 'on') {
             $table = $table->where('style', 'bld');
             $table = $table->where('style', 'bld');
             $whereBold = " and style='bld'";
             $whereBold = " and style='bld'";
-        } else if ($request->input('bold') === 'off') {
+        } elseif ($request->input('bold') === 'off') {
             $table = $table->where('style', '<>', 'bld');
             $table = $table->where('style', '<>', 'bld');
             $whereBold = " and style <> 'bld'";
             $whereBold = " and style <> 'bld'";
         }
         }
-        $placeholderWord = implode(",", array_fill(0, count($keyWords), '?'));
+        $placeholderWord = implode(',', array_fill(0, count($keyWords), '?'));
         $whereWord = "real in ({$placeholderWord})";
         $whereWord = "real in ({$placeholderWord})";
         $whereBookId = '';
         $whereBookId = '';
         if (count($bookId) > 0) {
         if (count($bookId) > 0) {
-            $table =  $table->whereIn('pcd_book_id', $bookId);
-            $placeholderBookId = implode(",", array_fill(0, count($bookId), '?'));
+            $table = $table->whereIn('pcd_book_id', $bookId);
+            $placeholderBookId = implode(',', array_fill(0, count($bookId), '?'));
             $whereBookId = " and pcd_book_id in ({$placeholderBookId}) ";
             $whereBookId = " and pcd_book_id in ({$placeholderBookId}) ";
         }
         }
         $queryCount = "SELECT count(*) FROM ( SELECT book,paragraph FROM wbw_templates WHERE $whereWord $whereBookId $whereBold  GROUP BY book,paragraph) T;";
         $queryCount = "SELECT count(*) FROM ( SELECT book,paragraph FROM wbw_templates WHERE $whereWord $whereBookId $whereBold  GROUP BY book,paragraph) T;";
         $count = DB::select($queryCount, array_merge($keyWords, $bookId));
         $count = DB::select($queryCount, array_merge($keyWords, $bookId));
 
 
-        $table =  $table->orderBy('rank', 'desc');
-        $table =  $table->skip($request->input("offset", 0))
+        $table = $table->orderBy('rank', 'desc');
+        $table = $table->skip($request->input('offset', 0))
             ->take($request->input('limit', 10));
             ->take($request->input('limit', 10));
 
 
         $result = $table->get();
         $result = $table->get();
+
         return $this->ok([
         return $this->ok([
-            "rows" => SearchPaliWbwResource::collection($result),
-            "count" => $count[0]->count,
+            'rows' => SearchPaliWbwResource::collection($result),
+            'count' => $count[0]->count,
         ]);
         ]);
     }
     }
 
 
-    public function book_list(Request $request)
+    public function book_list(SearchRequest $request)
     {
     {
-        //获取书的范围
+        // 获取书的范围
         $bookId = [];
         $bookId = [];
         $search = new SearchController;
         $search = new SearchController;
         if ($request->has('tags')) {
         if ($request->has('tags')) {
-            //查询搜索范围
-            //查询搜索范围
+            // 查询搜索范围
+            // 查询搜索范围
             $tagItems = explode(';', $request->input('tags'));
             $tagItems = explode(';', $request->input('tags'));
 
 
             foreach ($tagItems as $tagItem) {
             foreach ($tagItems as $tagItem) {
                 $bookId = array_merge($bookId, $search->getBookIdByTags(explode(',', $tagItem)));
                 $bookId = array_merge($bookId, $search->getBookIdByTags(explode(',', $tagItem)));
             }
             }
         }
         }
-        $keyWords = explode(',', $request->input('key'));
+        $keyWords = $this->keywords($request->input('key'));
         $table = WbwTemplate::whereIn('real', $keyWords);
         $table = WbwTemplate::whereIn('real', $keyWords);
 
 
         if (count($bookId) > 0) {
         if (count($bookId) > 0) {
@@ -93,13 +111,14 @@ class SearchPaliWbwController extends Controller
             ->selectRaw('pcd_book_id,count(*) as co')
             ->selectRaw('pcd_book_id,count(*) as co')
             ->orderBy('co', 'desc');
             ->orderBy('co', 'desc');
         $result = $table->get();
         $result = $table->get();
-        return $this->ok(["rows" => SearchBookResource::collection($result), "count" => count($result)]);
+
+        return $this->ok(['rows' => SearchBookResource::collection($result), 'count' => count($result)]);
     }
     }
+
     /**
     /**
      * Store a newly created resource in storage.
      * Store a newly created resource in storage.
      *
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
      */
     public function store(Request $request)
     public function store(Request $request)
     {
     {
@@ -109,8 +128,7 @@ class SearchPaliWbwController extends Controller
     /**
     /**
      * Display the specified resource.
      * Display the specified resource.
      *
      *
-     * @param  \App\Models\WbwTemplate  $wbwTemplate
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
      */
     public function show(WbwTemplate $wbwTemplate)
     public function show(WbwTemplate $wbwTemplate)
     {
     {
@@ -120,9 +138,7 @@ class SearchPaliWbwController extends Controller
     /**
     /**
      * Update the specified resource in storage.
      * Update the specified resource in storage.
      *
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @param  \App\Models\WbwTemplate  $wbwTemplate
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
      */
     public function update(Request $request, WbwTemplate $wbwTemplate)
     public function update(Request $request, WbwTemplate $wbwTemplate)
     {
     {
@@ -132,8 +148,7 @@ class SearchPaliWbwController extends Controller
     /**
     /**
      * Remove the specified resource from storage.
      * Remove the specified resource from storage.
      *
      *
-     * @param  \App\Models\WbwTemplate  $wbwTemplate
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
      */
     public function destroy(WbwTemplate $wbwTemplate)
     public function destroy(WbwTemplate $wbwTemplate)
     {
     {

+ 44 - 0
api-v13/app/Http/Requests/SearchRequest.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace App\Http\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+class SearchRequest extends FormRequest
+{
+    /**
+     * 检索是公开接口,不需要鉴权。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * 空 key 必须拦在查询之前。它不会报错,而是悄悄匹配到「空值」那一类数据——
+     * wbw 检索里 real 为空串的行有四百多万条、覆盖 49 万个段落,标题检索里
+     * like '%%' 命中全部三万多条。调用方拿到的是一个成功的响应和满屏结果,
+     * 却与查询无关;这比直接报错难发现得多。
+     *
+     * regex 要求 key 里至少有一个字符既不是空白也不是分隔符:各接口有的按逗号
+     * 切词、有的按分号切,只由分隔符组成的 key(`,,`、`;;`)在任何一边都是空的。
+     *
+     * @return array<string, array<int, string>>
+     */
+    public function rules(): array
+    {
+        return [
+            'key' => ['required', 'string', 'regex:/[^\s,;]/'],
+        ];
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    public function messages(): array
+    {
+        return [
+            'key.regex' => 'The key field must contain at least one searchable word.',
+        ];
+    }
+}

+ 78 - 0
api-v13/tests/Feature/SearchEmptyKeyTest.php

@@ -0,0 +1,78 @@
+<?php
+
+/**
+ * 空 key 必须被 SearchRequest 拦下。
+ *
+ * 修复前它不报错,而是匹配到「空值」那一类数据:wbw 检索 whereIn('real', [''])
+ * 命中 49 万个段落,标题检索 like '%%' 命中全部三万多条。调用方看到的是成功的
+ * 响应和满屏结果,却与查询无关——AI agent 曾据此把整个语料库当成命中。
+ *
+ * 校验在进控制器之前就失败,所以这些用例不需要造数据。
+ */
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;
+
+uses(RefreshDatabase::class);
+
+$emptyKeys = [
+    '空字符串' => '',
+    '只有空白' => '   ',
+    '只有逗号' => ',,',
+    '只有分号' => ';;',
+];
+
+$endpoints = [
+    'search view=title' => '/api/v2/search?view=title',
+    'search view=page' => '/api/v2/search?view=page',
+    'search view=pali' => '/api/v2/search?view=pali',
+    'search-book-list' => '/api/v2/search-book-list',
+    'search-pali-wbw' => '/api/v2/search-pali-wbw',
+    'search-pali-wbw-books' => '/api/v2/search-pali-wbw-books',
+];
+
+foreach ($endpoints as $name => $url) {
+    $glue = str_contains($url, '?') ? '&' : '?';
+
+    foreach ($emptyKeys as $label => $key) {
+        it("rejects {$label} on {$name}", function () use ($url, $glue, $key) {
+            $this->getJson($url.$glue.'key='.urlencode($key))
+                ->assertStatus(422)
+                ->assertJsonValidationErrors('key');
+        });
+    }
+
+    it("rejects a missing key on {$name}", function () use ($url) {
+        $this->getJson($url)
+            ->assertStatus(422)
+            ->assertJsonValidationErrors('key');
+    });
+}
+
+it('drops empty words split out of a valid key', function () {
+    // `dhammo,,` 能过校验——它确实有一个可检索的词。但 explode 切出的空串若进了
+    // whereIn('real', ...),就会把 real 为空的段落一并捞出来;线上那批有 49 万个
+    $row = fn (int $paragraph, string $real, string $word) => [
+        'book' => 1, 'paragraph' => $paragraph, 'wid' => 1,
+        'word' => $word, 'real' => $real,
+        'type' => '', 'gramma' => '', 'part' => '', 'style' => '',
+        'pcd_book_id' => 1, 'weight' => 1,
+    ];
+    DB::table('wbw_templates')->insert([
+        $row(1, 'dhammo', 'dhammo'),
+        $row(2, '', '.'),   // 线上这类空词元有四百多万行
+    ]);
+
+    $response = $this->getJson('/api/v2/search-pali-wbw?key='.urlencode('dhammo,,'))
+        ->assertOk();
+
+    expect($response->json('data.count'))->toBe(1)
+        ->and($response->json('data.rows.0.paragraph'))->toBe(1);
+});
+
+it('rejects an empty key without an Accept header too', function () {
+    // 默认配置下校验失败会 302 跳首页,客户端跟随重定向就拿到一张 HTML 首页;
+    // bootstrap/app.php 里的 shouldRenderJsonWhen 让 api/* 一律回 JSON
+    $this->get('/api/v2/search?view=title&key=')
+        ->assertStatus(422)
+        ->assertJsonValidationErrors('key');
+});