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 不可發案 | 同步 guard | auto 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 天已報價 provider | production-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.created與NOW()判斷。 - 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 / staging | production | 測試補充 |
|---|---|---|---|
| 同分類 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 TTL | 依 use_redis 與 Redis 連線 | 依 production Redis | Redis 關閉時相關限制可能不生效 |
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。