refactor deployment config and exchange integrations

Simplify runtime configuration and remove legacy database and settings surface so new installs are easier to operate.
Refresh deployment assets, docs, and order execution behavior to keep the packaged app aligned with the current backend.

Made-with: Cursor
This commit is contained in:
Dinger
2026-03-21 18:32:04 +08:00
parent 9473e50d59
commit 05f07ee544
138 changed files with 1530 additions and 860 deletions
@@ -71,7 +71,6 @@ class AICalibrationService:
sell_threshold DECIMAL(10,4) NOT NULL,
min_consensus_abs_override DECIMAL(10,4) NOT NULL,
quality_hold_threshold DECIMAL(10,4) NOT NULL,
sample_count INT NOT NULL DEFAULT 0,
validated_at TIMESTAMP DEFAULT NOW(),
created_at TIMESTAMP DEFAULT NOW()
);
@@ -281,9 +280,9 @@ class AICalibrationService:
INSERT INTO qd_ai_calibration
(market, buy_threshold, sell_threshold,
min_consensus_abs_override, quality_hold_threshold,
sample_count, validated_at, created_at)
validated_at, created_at)
VALUES
(%s, %s, %s, %s, %s, %s, NOW(), NOW())
(%s, %s, %s, %s, %s, NOW(), NOW())
""",
(
market,
@@ -291,7 +290,6 @@ class AICalibrationService:
sell_threshold,
min_consensus_abs_override,
quality_hold_threshold,
sample_count,
),
)
db.commit()
@@ -58,16 +58,11 @@ class AnalysisMemory:
decision VARCHAR(10) NOT NULL,
confidence INT DEFAULT 50,
price_at_analysis DECIMAL(24, 8),
entry_price DECIMAL(24, 8),
stop_loss DECIMAL(24, 8),
take_profit DECIMAL(24, 8),
summary TEXT,
reasons JSONB,
risks JSONB,
scores JSONB,
indicators_snapshot JSONB,
raw_result JSONB,
consensus_decision VARCHAR(10),
consensus_score DECIMAL(24, 8),
consensus_abs DECIMAL(24, 8),
agreement_ratio DECIMAL(10, 6),
@@ -102,14 +97,6 @@ class AnalysisMemory:
ALTER TABLE qd_analysis_memory ADD COLUMN raw_result JSONB;
END IF;
-- 添加多周期共识列(如果不存在)
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_analysis_memory' AND column_name = 'consensus_decision'
) THEN
ALTER TABLE qd_analysis_memory ADD COLUMN consensus_decision VARCHAR(10);
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_analysis_memory' AND column_name = 'consensus_score'
@@ -182,18 +169,13 @@ class AnalysisMemory:
decision = analysis_result.get("decision")
confidence = analysis_result.get("confidence")
price = analysis_result.get("market_data", {}).get("current_price")
entry = analysis_result.get("trading_plan", {}).get("entry_price")
stop = analysis_result.get("trading_plan", {}).get("stop_loss")
take = analysis_result.get("trading_plan", {}).get("take_profit")
summary = analysis_result.get("summary")
reasons = json.dumps(analysis_result.get("reasons", []))
risks = json.dumps(analysis_result.get("risks", []))
scores = json.dumps(analysis_result.get("scores", {}))
indicators = json.dumps(analysis_result.get("indicators", {}))
raw = json.dumps(analysis_result)
consensus = analysis_result.get("consensus") or {}
consensus_decision = consensus.get("consensus_decision")
consensus_score = consensus.get("consensus_score")
consensus_abs = consensus.get("consensus_abs")
agreement_ratio = consensus.get("agreement_ratio")
@@ -202,15 +184,16 @@ class AnalysisMemory:
cur.execute("""
INSERT INTO qd_analysis_memory (
user_id, market, symbol, decision, confidence,
price_at_analysis, entry_price, stop_loss, take_profit,
summary, reasons, risks, scores, indicators_snapshot, raw_result,
consensus_decision, consensus_score, consensus_abs, agreement_ratio, quality_multiplier
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s)
price_at_analysis, summary, reasons, scores, indicators_snapshot, raw_result,
consensus_score, consensus_abs, agreement_ratio, quality_multiplier
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s)
RETURNING id
""", (user_id, market, symbol, decision, confidence, price, entry, stop, take,
summary, reasons, risks, scores, indicators, raw,
consensus_decision, consensus_score, consensus_abs, agreement_ratio, quality_multiplier))
""", (
user_id, market, symbol, decision, confidence,
price, summary, reasons, scores, indicators, raw,
consensus_score, consensus_abs, agreement_ratio, quality_multiplier,
))
# 使用 lastrowid 属性获取 IDexecute 内部已经处理了 RETURNING
memory_id = cur.lastrowid
@@ -1,12 +1,13 @@
"""
Billing Service - 统一计费服务
管理用户积分消费、VIP状态检查、计费配置等功能
支持两种计费模
1. 积分消耗模式:每次使用功能扣除相应积分
2. VIP免费模式:VIP用户在有效期内免费使用
负责用户积分余额、功能扣费、会员状态与套餐发放
当前计费模型为
1. 是否扣费由 `BILLING_ENABLED` 与各功能 cost 配置决定
2. VIP/会员状态用于会员套餐与权益展示
3. 社区指标的 `vip_free` 逻辑在社区购买流程中单独处理,不在这里做全局旁路
计费配置存储在 .env 文件中,可通过系统设置界面配置。
计费配置存储在 `.env` 文件中,可通过系统设置界面配置。
"""
import os
import time
@@ -27,9 +28,7 @@ BILLING_CONFIG_PREFIX = 'BILLING_'
DEFAULT_BILLING_CONFIG = {
# 全局开关
'enabled': False, # 是否启用计费
# IMPORTANT: VIP 不再默认免扣积分(VIP 仅对“VIP免费指标”生效)
'vip_bypass': False, # VIP用户是否免费(功能计费层面的旁路,默认关闭)
# 各功能积分消耗(0表示免费)
'cost_ai_analysis': 10, # AI分析 每次消耗积分
'cost_strategy_run': 5, # 策略运行 每次消耗积分(启动时)
@@ -96,7 +95,7 @@ class BillingService:
return config.get('enabled', False)
def get_feature_cost(self, feature: str) -> int:
"""获取功能消耗积分"""
"""获取指定功能的积分消耗,0 表示免费"""
config = self.get_billing_config()
cost_key = f'cost_{feature}'
return config.get(cost_key, 0)
@@ -475,13 +474,10 @@ class BillingService:
# 免费功能
if cost <= 0:
return True, 'free_feature'
# 检查VIP状态
if config.get('vip_bypass', True):
is_vip, _ = self.get_user_vip_status(user_id)
if is_vip:
return True, 'vip_free'
# 说明:这里不再根据 VIP 做全局免扣积分旁路。
# VIP / membership 仅保留为套餐、到期时间和社区 vip_free 指标权益。
# 检查积分余额
credits = self.get_user_credits(user_id)
if credits < cost:
@@ -710,7 +706,7 @@ class BillingService:
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
def get_user_billing_info(self, user_id: int) -> Dict[str, Any]:
"""获取用户计费信息(供前端显示)"""
"""获取用户计费与会员信息快照(供前端显示)"""
credits = self.get_user_credits(user_id)
is_vip, vip_expires_at = self.get_user_vip_status(user_id)
config = self.get_billing_config()
@@ -720,9 +716,6 @@ class BillingService:
'is_vip': is_vip,
'vip_expires_at': vip_expires_at.isoformat() if vip_expires_at else None,
'billing_enabled': config.get('enabled', False),
'vip_bypass': config.get('vip_bypass', False),
# Public support link for credits recharge / VIP purchase
'recharge_telegram_url': os.getenv('RECHARGE_TELEGRAM_URL', '').strip() or 'https://t.me/your_support_bot',
# 功能费用(供前端显示)
'feature_costs': {
'ai_analysis': config.get('cost_ai_analysis', 0),
@@ -20,6 +20,17 @@ from app.services.live_trading.symbols import to_bitget_um_symbol
class BitgetMixClient(BaseRestClient):
_CHANNEL_API_CODE_ORDER_PATHS = {
"/api/v2/mix/order/place-order",
"/api/v2/mix/order/batch-place-order",
"/api/v2/mix/order/modify-order",
"/api/v2/mix/order/place-plan-order",
"/api/v2/mix/order/place-tpsl-order",
"/api/v3/trade/place-order",
"/api/v3/trade/place-batch",
"/api/v3/trade/modify-order",
}
def __init__(
self,
*,
@@ -28,11 +39,13 @@ class BitgetMixClient(BaseRestClient):
passphrase: str,
base_url: str = "https://api.bitget.com",
timeout_sec: float = 15.0,
channel_api_code: str = "qvz9x",
):
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.passphrase = (passphrase or "").strip()
self.channel_api_code = (channel_api_code or "").strip()
if not self.api_key or not self.secret_key or not self.passphrase:
raise LiveTradingError("Missing Bitget api_key/secret_key/passphrase")
@@ -172,14 +185,18 @@ class BitgetMixClient(BaseRestClient):
mac = hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).digest()
return base64.b64encode(mac).decode("utf-8")
def _headers(self, ts_ms: str, sign: str) -> Dict[str, str]:
return {
def _headers(self, ts_ms: str, sign: str, request_path: str = "") -> Dict[str, str]:
headers = {
"ACCESS-KEY": self.api_key,
"ACCESS-SIGN": sign,
"ACCESS-TIMESTAMP": ts_ms,
"ACCESS-PASSPHRASE": self.passphrase,
"Content-Type": "application/json",
}
clean_path = str(request_path or "").split("?", 1)[0]
if self.channel_api_code and clean_path in self._CHANNEL_API_CODE_ORDER_PATHS:
headers["X-CHANNEL-API-CODE"] = self.channel_api_code
return headers
def _signed_request(
self,
@@ -210,7 +227,7 @@ class BitgetMixClient(BaseRestClient):
path,
params=params,
data=body_str if body_str else None,
headers=self._headers(ts_ms, sign),
headers=self._headers(ts_ms, sign, path),
)
if code >= 400:
raise LiveTradingError(f"Bitget HTTP {code}: {text[:500]}")
@@ -23,6 +23,15 @@ from app.services.live_trading.symbols import to_bitget_um_symbol
class BitgetSpotClient(BaseRestClient):
_CHANNEL_API_CODE_ORDER_PATHS = {
"/api/v2/spot/trade/place-order",
"/api/v2/spot/trade/batch-orders",
"/api/v2/spot/trade/place-plan-order",
"/api/v3/trade/place-order",
"/api/v3/trade/place-batch",
"/api/v3/trade/modify-order",
}
def __init__(
self,
*,
@@ -31,7 +40,7 @@ class BitgetSpotClient(BaseRestClient):
passphrase: str,
base_url: str = "https://api.bitget.com",
timeout_sec: float = 15.0,
channel_api_code: str = "bntva",
channel_api_code: str = "qvz9x",
):
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
@@ -150,7 +159,7 @@ class BitgetSpotClient(BaseRestClient):
mac = hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).digest()
return base64.b64encode(mac).decode("utf-8")
def _headers(self, ts_ms: str, sign: str) -> Dict[str, str]:
def _headers(self, ts_ms: str, sign: str, request_path: str = "") -> Dict[str, str]:
h = {
"ACCESS-KEY": self.api_key,
"ACCESS-SIGN": sign,
@@ -158,7 +167,8 @@ class BitgetSpotClient(BaseRestClient):
"ACCESS-PASSPHRASE": self.passphrase,
"Content-Type": "application/json",
}
if self.channel_api_code:
clean_path = str(request_path or "").split("?", 1)[0]
if self.channel_api_code and clean_path in self._CHANNEL_API_CODE_ORDER_PATHS:
h["X-CHANNEL-API-CODE"] = self.channel_api_code
return h
@@ -188,7 +198,7 @@ class BitgetSpotClient(BaseRestClient):
path,
params=params,
data=body_str if body_str else None,
headers=self._headers(ts_ms, sign),
headers=self._headers(ts_ms, sign, path),
)
if code >= 400:
raise LiveTradingError(f"BitgetSpot HTTP {code}: {text[:500]}")
@@ -22,6 +22,8 @@ from app.services.live_trading.symbols import to_bybit_symbol
class BybitClient(BaseRestClient):
_DEFAULT_BROKER_REFERER = "Ri001020"
def __init__(
self,
*,
@@ -36,6 +38,7 @@ class BybitClient(BaseRestClient):
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.category = (category or "linear").strip().lower()
self.broker_referer = self._DEFAULT_BROKER_REFERER
if self.category not in ("linear", "spot"):
self.category = "linear"
try:
@@ -155,8 +158,17 @@ class BybitClient(BaseRestClient):
def _sign(self, prehash: str) -> str:
return hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).hexdigest()
@staticmethod
def _resolve_position_idx(pos_side: str) -> Optional[int]:
ps = str(pos_side or "").strip().lower()
if ps == "long":
return 1
if ps == "short":
return 2
return None
def _headers(self, ts_ms: str, sign: str) -> Dict[str, str]:
return {
headers = {
"X-BAPI-API-KEY": self.api_key,
"X-BAPI-SIGN": sign,
"X-BAPI-TIMESTAMP": ts_ms,
@@ -164,6 +176,9 @@ class BybitClient(BaseRestClient):
"X-BAPI-SIGN-TYPE": "2",
"Content-Type": "application/json",
}
if self.broker_referer:
headers["Referer"] = self.broker_referer
return headers
def _signed_request(
self,
@@ -277,6 +292,37 @@ class BybitClient(BaseRestClient):
return (Decimal("0"), qty_precision)
return (q, qty_precision)
def _normalize_price(self, *, symbol: str, price: float) -> Tuple[Decimal, Optional[int]]:
p = self._to_dec(price)
if p <= 0:
return (Decimal("0"), None)
sym = to_bybit_symbol(symbol)
try:
info = self.get_instrument_info(category=self.category, symbol=sym) or {}
except Exception:
info = {}
pf = (info.get("priceFilter") if isinstance(info, dict) else None) or {}
tick = self._to_dec((pf or {}).get("tickSize") or "0")
if tick > 0:
p = self._floor_to_step(p, tick)
price_precision = None
if tick > 0:
try:
tick_normalized = tick.normalize()
tick_str = str(tick_normalized)
if "." in tick_str:
price_precision = len(tick_str.split(".")[1])
if price_precision < 0:
price_precision = 0
if price_precision > 18:
price_precision = 18
else:
price_precision = 0
except Exception:
pass
return (p, price_precision)
def place_market_order(
self,
*,
@@ -284,6 +330,7 @@ class BybitClient(BaseRestClient):
side: str,
qty: float,
reduce_only: bool = False,
pos_side: str = "",
client_order_id: Optional[str] = None,
) -> LiveOrderResult:
sym = to_bybit_symbol(symbol)
@@ -300,8 +347,13 @@ class BybitClient(BaseRestClient):
"side": "Buy" if sd == "buy" else "Sell",
"orderType": "Market",
"qty": self._dec_str(q_dec, strict_precision=qty_precision),
"timeInForce": "GTC",
"timeInForce": "IOC",
}
if self.category == "spot":
body["marketUnit"] = "baseCoin"
pos_idx = self._resolve_position_idx(pos_side) if self.category == "linear" else None
if pos_idx is not None:
body["positionIdx"] = pos_idx
if reduce_only and self.category == "linear":
body["reduceOnly"] = True
if client_order_id:
@@ -319,6 +371,7 @@ class BybitClient(BaseRestClient):
qty: float,
price: float,
reduce_only: bool = False,
pos_side: str = "",
client_order_id: Optional[str] = None,
) -> LiveOrderResult:
sym = to_bybit_symbol(symbol)
@@ -326,21 +379,27 @@ class BybitClient(BaseRestClient):
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
q_req = float(qty or 0.0)
px = float(price or 0.0)
if q_req <= 0 or px <= 0:
px_req = float(price or 0.0)
if q_req <= 0 or px_req <= 0:
raise LiveTradingError("Invalid qty/price")
q_dec, qty_precision = self._normalize_qty(symbol=symbol, qty=q_req)
px_dec, price_precision = self._normalize_price(symbol=symbol, price=px_req)
if float(q_dec or 0) <= 0:
raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}")
if float(px_dec or 0) <= 0:
raise LiveTradingError(f"Invalid price (below tick/min): requested={px_req}")
body: Dict[str, Any] = {
"category": self.category,
"symbol": sym,
"side": "Buy" if sd == "buy" else "Sell",
"orderType": "Limit",
"qty": self._dec_str(q_dec, strict_precision=qty_precision),
"price": str(px),
"price": self._dec_str(px_dec, strict_precision=price_precision),
"timeInForce": "GTC",
}
pos_idx = self._resolve_position_idx(pos_side) if self.category == "linear" else None
if pos_idx is not None:
body["positionIdx"] = pos_idx
if reduce_only and self.category == "linear":
body["reduceOnly"] = True
if client_order_id:
@@ -404,13 +463,28 @@ class BybitClient(BaseRestClient):
# Extract fee from cumExecFee (Bybit API field for cumulative execution fee)
fee = 0.0
fee_ccy = ""
try:
fee = abs(float(last.get("cumExecFee") or 0.0))
except Exception:
fee = 0.0
# Bybit linear contracts are settled in USDT
if fee > 0:
fee_ccy = "USDT"
fee_detail = last.get("cumFeeDetail") if isinstance(last, dict) else None
if isinstance(fee_detail, dict) and fee_detail:
total_fee = 0.0
fee_keys = []
for k, v in fee_detail.items():
try:
fv = abs(float(v or 0.0))
except Exception:
fv = 0.0
if fv > 0:
total_fee += fv
fee_keys.append(str(k))
fee = total_fee
if len(fee_keys) == 1:
fee_ccy = fee_keys[0]
if fee <= 0:
try:
fee = abs(float(last.get("cumExecFee") or 0.0))
except Exception:
fee = 0.0
if fee > 0 and self.category == "linear":
fee_ccy = "USDT"
if filled > 0 and avg_price > 0:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
if status.lower() in ("filled", "cancelled", "canceled", "rejected"):
@@ -184,6 +184,7 @@ def place_order_from_signal(
side=side,
qty=qty,
reduce_only=reduce_only,
pos_side=pos_side,
client_order_id=client_order_id,
)
if isinstance(client, CoinbaseExchangeClient):
@@ -84,15 +84,22 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
if exchange_id == "bitget":
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bitget.com"
if mt == "spot":
channel_api_code = _get(exchange_config, "channel_api_code", "channelApiCode") or "bntva"
channel_api_code = _get(exchange_config, "channel_api_code", "channelApiCode") or "qvz9x"
return BitgetSpotClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url, channel_api_code=channel_api_code)
return BitgetMixClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url)
channel_api_code = _get(exchange_config, "channel_api_code", "channelApiCode") or "qvz9x"
return BitgetMixClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url, channel_api_code=channel_api_code)
if exchange_id == "bybit":
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bybit.com"
category = "spot" if mt == "spot" else "linear"
recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 5000)
return BybitClient(api_key=api_key, secret_key=secret_key, base_url=base_url, category=category, recv_window_ms=recv_window_ms)
return BybitClient(
api_key=api_key,
secret_key=secret_key,
base_url=base_url,
category=category,
recv_window_ms=recv_window_ms,
)
if exchange_id in ("coinbaseexchange", "coinbase_exchange"):
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.exchange.coinbase.com"
@@ -1006,7 +1006,7 @@ class MarketDataCollector:
策略按优先级
1. 结构化API (Finnhub) - 美股首选
2. 搜索引擎 (Bocha/Tavily) - 补充搜索
2. 搜索引擎 (Tavily/Google/Bing/SerpAPI) - 补充搜索
3. 情绪分析 - Finnhub 社交媒体情绪
"""
news_list = []
@@ -1090,7 +1090,7 @@ class MarketDataCollector:
"""
从搜索引擎获取新闻
使用增强的搜索服务 (Bocha/Tavily/SerpAPI)
使用增强的搜索服务 (Tavily/Google/Bing/SerpAPI)
"""
news_list = []
@@ -1386,6 +1386,7 @@ class PendingOrderWorker:
qty=remaining,
price=limit_price,
reduce_only=reduce_only,
pos_side=pos_side,
client_order_id=limit_client_oid,
)
elif isinstance(client, CoinbaseExchangeClient):
@@ -1718,6 +1719,7 @@ class PendingOrderWorker:
side=side,
qty=remaining,
reduce_only=reduce_only,
pos_side=pos_side,
client_order_id=market_client_oid,
)
elif isinstance(client, CoinbaseExchangeClient):
+10 -141
View File
@@ -3,12 +3,11 @@ Search service v2.0 - 增强版搜索服务
整合多个搜索引擎支持 API Key 轮换和故障转移
支持的搜索引擎按优先级
1. Bocha (博查) - 搜索优化
2. Tavily - 专为AI设计免费1000次/
3. SerpAPI - Google/Bing 结果抓取
4. Google CSE - 自定义搜索引擎
5. Bing Search API
6. DuckDuckGo - 免费兜底
1. Tavily - 专为AI设计免费1000次/
2. SerpAPI - Google/Bing 结果抓取
3. Google CSE - 自定义搜索引擎
4. Bing Search API
5. DuckDuckGo - 免费兜底
参考daily_stock_analysis-main/src/search_service.py
"""
@@ -331,130 +330,6 @@ class TavilySearchProvider(BaseSearchProvider):
)
class BochaSearchProvider(BaseSearchProvider):
"""
博查搜索引擎
特点
- 专为AI优化的中文搜索API
- 结果准确摘要完整
- 支持时间范围过滤和AI摘要
文档https://bocha-ai.feishu.cn/wiki/RXEOw02rFiwzGSkd9mUcqoeAnNK
"""
def __init__(self, api_keys: List[str]):
super().__init__(api_keys, "Bocha")
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
"""执行博查搜索"""
try:
url = "https://api.bochaai.com/v1/web-search"
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json'
}
# 确定时间范围
freshness = "oneWeek"
if days <= 1:
freshness = "oneDay"
elif days <= 7:
freshness = "oneWeek"
elif days <= 30:
freshness = "oneMonth"
else:
freshness = "oneYear"
payload = {
"query": query,
"freshness": freshness,
"summary": True,
"count": min(max_results, 50)
}
response = requests.post(url, headers=headers, json=payload, timeout=15)
if response.status_code != 200:
error_message = response.text
try:
if response.headers.get('content-type', '').startswith('application/json'):
error_data = response.json()
error_message = error_data.get('message', response.text)
except:
pass
if response.status_code == 403:
error_msg = f"余额不足: {error_message}"
elif response.status_code == 401:
error_msg = f"API KEY无效: {error_message}"
elif response.status_code == 429:
error_msg = f"请求频率达到限制: {error_message}"
else:
error_msg = f"HTTP {response.status_code}: {error_message}"
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=error_msg
)
data = response.json()
if data.get('code') != 200:
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=data.get('msg') or f"API返回错误码: {data.get('code')}"
)
results = []
web_pages = data.get('data', {}).get('webPages', {})
value_list = web_pages.get('value', [])
for item in value_list[:max_results]:
snippet = item.get('summary') or item.get('snippet', '')
if snippet:
snippet = snippet[:500]
results.append(SearchResult(
title=item.get('name', ''),
snippet=snippet,
url=item.get('url', ''),
source=item.get('siteName') or self._extract_domain(item.get('url', '')),
published_date=item.get('datePublished'),
))
return SearchResponse(
query=query,
results=results,
provider=self.name,
success=True,
)
except requests.exceptions.Timeout:
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message="请求超时"
)
except Exception as e:
return SearchResponse(
query=query,
results=[],
provider=self.name,
success=False,
error_message=str(e)
)
class SerpAPISearchProvider(BaseSearchProvider):
"""
SerpAPI 搜索引擎
@@ -865,38 +740,32 @@ class SearchService:
"""初始化搜索引擎(按优先级排序)"""
from app.config import APIKeys
# 1. Bocha 优先(国内搜索优化
bocha_keys = APIKeys.BOCHA_API_KEYS
if bocha_keys:
self._providers.append(BochaSearchProvider(bocha_keys))
logger.info(f"已配置 Bocha 搜索,共 {len(bocha_keys)} 个 API Key")
# 2. TavilyAI优化搜索)
# 1. TavilyAI优化搜索
tavily_keys = APIKeys.TAVILY_API_KEYS
if tavily_keys:
self._providers.append(TavilySearchProvider(tavily_keys))
logger.info(f"已配置 Tavily 搜索,共 {len(tavily_keys)} 个 API Key")
# 3. SerpAPI
# 2. SerpAPI
serpapi_keys = APIKeys.SERPAPI_KEYS
if serpapi_keys:
self._providers.append(SerpAPISearchProvider(serpapi_keys))
logger.info(f"已配置 SerpAPI 搜索,共 {len(serpapi_keys)} 个 API Key")
# 4. Google CSE
# 3. Google CSE
google_api_key = self._config.get('google', {}).get('api_key')
google_cx = self._config.get('google', {}).get('cx')
if google_api_key and google_cx:
self._providers.append(GoogleSearchProvider(google_api_key, google_cx))
logger.info("已配置 Google CSE 搜索")
# 5. Bing
# 4. Bing
bing_api_key = self._config.get('bing', {}).get('api_key')
if bing_api_key:
self._providers.append(BingSearchProvider(bing_api_key))
logger.info("已配置 Bing 搜索")
# 6. DuckDuckGo(免费兜底)
# 5. DuckDuckGo(免费兜底)
self._providers.append(DuckDuckGoSearchProvider())
logger.info("已配置 DuckDuckGo 搜索(免费兜底)")