search_add 限制與風控規則

文件狀態:草稿 / 已對 new API 主要 guard
最後驗證:2026-05-07
來源:Lib/SearchAdd/RiskControlGuard.php, Lib/SearchAdd/SameCategoryServiceConflictGuard.php, Lib/SearchAdd/QuoteRequestCreator.php, Lib/Validation/ApiValidation.php, search_add PHPUnit

目的

這份文件整理 quote_requests/search_add.json 在建立需求前後會遇到的次數限制與風控限制。

完整發案流程請看 業務流程/search_add;這裡只整理限制規則、環境差異與測試注意事項。

快速結論

規則類型預設環境差異對外結果
電話未驗證不可發案同步 guard無已知差異error=6
blocked user 不可發案同步 guardauto signup 新 user 會跳過 blocked user 判斷error=5
海外電話 12 小時內重複需求DB count limit無已知差異error=14
同 user / category 24 小時內第 6 筆需求DB count limit特定 user id 例外error=4
相同專業類別專家不可發案production-only guard非 production 預設跳過;測試可強制開啟error=10
block_provider_ids 排除近 30 天已報價 providerproduction-only side effect非 production 回空字串影響配對,不直接回 error
daily_access 每日紀錄daily marker測試需 snapshot / cleanup不是發案上限

判斷順序

SearchAddHandler::handle() 的限制相關主線:

category / form validation
-> actor resolve / auto signup
-> RiskControlGuard::guard()
-> SameCategoryServiceConflictGuard::guard()
-> QuoteRequestCreator::create()
-> downstream side effects

重點:

  • RiskControlGuard 在建立 quote_requests 前執行。
  • SameCategoryServiceConflictGuard 也在建立 quote_requests 前執行。
  • QuoteRequestCreator 建單時會依環境決定 block_provider_ids
  • daily_access 是 user access activity side effect,不是發案上限。

規則明細

電話未驗證

來源:

Lib/SearchAdd/RiskControlGuard.php::isPhoneVerified()

判斷:

users.is_phone_confirmed 必須是 1

失敗 response:

{
  "error": 6,
  "message": "您的電話尚未被驗證,請再次檢查"
}

測試:

tests/SearchAddStep3IntegrationTest.php::testRejectsTempUserPhoneNotVerified()

blocked user

來源:

Lib/SearchAdd/RiskControlGuard.php
PRO360\Model\BlockedUser::isBlocked()

判斷:

不是本次 auto signup 新 user
且 blocked user active

失敗 response:

{
  "error": 5,
  "message": "系統偵測您的帳號異常,請洽詢客服人員了解細節"
}

注意:

  • isNewUserSignup = true 時會跳過 blocked user 判斷。
  • 測試時若直接改 blocked_users,要 restore 原狀。

海外電話 12 小時限制

來源:

Lib/SearchAdd/RiskControlGuard.php::countRecentOverseasRequests()

判斷:

SELECT COUNT(*)
FROM quote_requests
WHERE (user_id = <user_id> OR phone_no = <phone>)
  AND created > DATE_SUB(NOW(), INTERVAL 12 HOUR);

規則:

  • +31 電話目前有直接擋下的特判。
  • 其他 + 開頭電話若 12 小時內有既有 request,也會擋。

失敗 response:

{
  "error": 14,
  "message": "系統無法接受您的需求,如有疑問請聯絡客服"
}

測試注意:

  • 這是用 quote_requests.createdNOW() 判斷。
  • live DB 重跑時,時間窗會漂移。

同分類 24 小時提出需求上限

來源:

Lib/SearchAdd/RiskControlGuard.php::countDuplicateRequests()

判斷:

SELECT COUNT(*)
FROM quote_requests
WHERE user_id = <user_id>
  AND quote_category_id = <quote_category_id>
  AND is_archived = 0
  AND is_closed = 0
  AND created > DATE_SUB(NOW(), INTERVAL 24 HOUR);

上限:

count >= 5 時,下一次 search_add 擋下
也就是同 user / category / active request 在 24 小時內第 6 筆會失敗

例外:

user_id = 92670 不套用這個限制

失敗 response:

{
  "error": 4,
  "message": "您在該項目所提出的需求次數已達今日上限"
}

測試:

tests/SearchAddStep3IntegrationTest.php::testRejectsDuplicateRequestLimit()

測試注意:

  • 測試會先 seed 5 筆同 user / category 的 request。
  • cleanup 時要刪掉測試建立的 quote_requests 和相依資料。
  • quote_request_limit_ids 不是這條同步 guard 的 count 來源;它是 search_add 成功後的附屬紀錄,常在測試 cleanup 一起清。

相同專業類別專家不可發案

來源:

Lib/SearchAdd/SameCategoryServiceConflictGuard.php

判斷:

目前 user 或 related user
已有同 category active quote_service

環境差異:

production 才啟用
非 production 預設跳過
PHPUnit 可用 SEARCH_ADD_TEST_FORCE_PRODUCTION=1 強制覆蓋這條分支

例外:

category_id = 493 跳過
official_users 命中時跳過

失敗 response:

{
  "error": 10,
  "message": "為確保案件來自消費者的真實需求以及維護接案之公平權益,恕不提供相同專業類別專家發案。如有疑問請洽詢客服人員。"
}

測試:

tests/SearchAddStep7IntegrationTest.php::testReturnsErrorTenWhenRequesterAlreadyProvidesSameCategory()

判讀重點:

  • staging / local 成功,不代表 production 也會成功。
  • 若要驗這條規則,必須確認實際環境或測試 env flag。

block_provider_ids 近 30 天排除 provider

來源:

Lib/SearchAdd/QuoteRequestCreator.php::resolveBlockProviderIds()

目的:

建新 request 時,排除近 30 天已向同 user 報過價的 provider

判斷:

SELECT DISTINCT b.provider_user_id
FROM quote_requests r
INNER JOIN quote_bids b ON r.id = b.quote_request_id
WHERE r.user_id = <user_id>
  AND b.quote_sent_on IS NOT NULL
  AND r.created >= CURRENT_TIMESTAMP - INTERVAL 30 DAY;

環境差異:

production 才會寫入排除名單
非 production 直接回空字串

例外:

match_type = Authorize 時跳過
quote_categories.is_allow_repeated_quote = 1 時跳過

影響:

  • 不直接回 API error。
  • 影響後續 provider matching。
  • 如果 staging 跟 production 配對結果不同,這是第一個要確認的 production-only side effect。

daily_access 不是提出需求上限

來源:

Lib/Validation/ApiValidation.php::updateUserAccessActivity()

行為:

Redis key access_act_<user_id> TTL 10 秒內只處理一次
每天寫一筆 daily_access
並建立 UserAccess activity

注意:

  • daily_access 是 user access activity marker。
  • 它不是 search_add 提出需求次數上限。
  • search_add 測試常 snapshot / cleanup daily_access,是為了測試可重現,不代表產品規則用它擋發案。

環境差異總表

規則local / stagingproduction測試補充
同分類 24 小時提出需求上限預期一致預期一致受 live DB 24 小時資料影響
海外電話 12 小時限制預期一致預期一致受 live DB 12 小時資料影響
相同專業類別專家不可發案預設跳過啟用SEARCH_ADD_TEST_FORCE_PRODUCTION=1 可強制測
block_provider_ids 近 30 天排除 provider空字串依近 30 天 quote bids 寫入staging 配對結果可能和 production 不同
Redis 防連點 / activity TTLuse_redis 與 Redis 連線依 production RedisRedis 關閉時相關限制可能不生效
daily_access會被測試 snapshot / cleanup每日 activity marker不可當發案上限

第一輪排查 SQL

查 24 小時同分類 request count:

SELECT
  COUNT(*) AS request_count
FROM quote_requests
WHERE user_id = <user_id>
  AND quote_category_id = <quote_category_id>
  AND is_archived = 0
  AND is_closed = 0
  AND created > DATE_SUB(NOW(), INTERVAL 24 HOUR);

查 12 小時海外電話 request count:

SELECT
  COUNT(*) AS request_count
FROM quote_requests
WHERE (user_id = <user_id> OR phone_no = '<phone>')
  AND created > DATE_SUB(NOW(), INTERVAL 12 HOUR);

daily_access

SELECT
  id,
  user_id,
  stamp
FROM daily_access
WHERE user_id = <user_id>
ORDER BY stamp DESC
LIMIT 10;

查 production-only block_provider_ids 來源:

SELECT DISTINCT
  b.provider_user_id
FROM quote_requests r
INNER JOIN quote_bids b ON r.id = b.quote_request_id
WHERE r.user_id = <user_id>
  AND b.quote_sent_on IS NOT NULL
  AND r.created >= CURRENT_TIMESTAMP - INTERVAL 30 DAY
ORDER BY b.provider_user_id ASC;

測試注意事項

  • 測試同分類上限時,seed request 要符合:
same user_id
same quote_category_id
is_archived = 0
is_closed = 0
created within 24 hours
  • 測試 production-only guard 時,要明確標示 env flag。
  • 清測試資料時,除了 quote_requests,也常要清:
quote_request_limit_ids
quote_form_submission_fields
quote_request_match_infos
quote_bids
quote_activities
daily_access
queue / log side effect
Redis keys
  • 不要把測試 cleanup 清掉的 table 誤認為限制來源;先看 code 的 count SQL。