from fastapi import APIRouter, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, desc from packages.core.database import get_db_session from packages.core.models import StandardTradeRecord from packages.core.search import get_es_client from apps.api.schemas.trade import TradeQueryRequest, TradeRecordResponse, PaginatedResponse router = APIRouter() @router.post("/search") async def search_trade_records( request: Request, db: AsyncSession = Depends(get_db_session) ): """ 检索标准贸易记录 - 直接从CSV加载数据,返回字典避免Pydantic验证 """ from datetime import datetime import pandas as pd from pathlib import Path # 手动解析请求,避免Pydantic验证问题 try: body = await request.json() except: body = {} # 提取查询参数 source_country = body.get('source_country') trade_direction = body.get('trade_direction') hs_code = body.get('hs_code') product_name = body.get('product_name') importer_name = body.get('importer_name') exporter_name = body.get('exporter_name') page = int(body.get('page', 1)) limit = int(body.get('limit', 20)) # 加载CSV数据 csv_path = Path(__file__).parent.parent.parent.parent / "data" / "standard_trade_records_sample.csv" df = pd.read_csv(csv_path) # 应用过滤条件 filtered_df = df.copy() if source_country: filtered_df = filtered_df[filtered_df['source_country'] == source_country] if trade_direction: filtered_df = filtered_df[filtered_df['trade_direction'] == trade_direction] if hs_code: filtered_df = filtered_df[filtered_df['hs_code'].astype(str).str.startswith(str(hs_code))] if product_name: filtered_df = filtered_df[filtered_df['product_name'].str.contains(product_name, na=False, case=False)] if importer_name: filtered_df = filtered_df[filtered_df['importer_name'].str.contains(importer_name, na=False, case=False)] if exporter_name: filtered_df = filtered_df[filtered_df['exporter_name'].str.contains(exporter_name, na=False, case=False)] total = len(filtered_df) # 分页 offset = (page - 1) * limit page_df = filtered_df.iloc[offset:offset + limit] # 转换为响应格式 - 直接返回字典,不使用Pydantic模型 items = [] for _, row in page_df.iterrows(): # 处理日期 - 只取日期部分 trade_date_str = str(row['trade_date']).split()[0] if pd.notna(row['trade_date']) else '2023-01-01' items.append({ 'record_id': str(row['record_id']), 'source_record_id': str(row['source_record_id']), 'source_country': str(row['source_country']), 'trade_direction': str(row['trade_direction']), 'trade_date': trade_date_str, # 直接返回字符串,不转换为datetime 'importer_name': str(row['importer_name']) if pd.notna(row['importer_name']) else "", 'exporter_name': str(row['exporter_name']) if pd.notna(row['exporter_name']) else "", 'hs_code': str(row['hs_code']) if pd.notna(row['hs_code']) else None, 'product_name': str(row['product_name']) if pd.notna(row['product_name']) else None, 'amount': float(row['amount']) if pd.notna(row['amount']) else None, 'currency': str(row['currency']) if pd.notna(row['currency']) else None, 'weight': float(row['weight']) if pd.notna(row['weight']) else None, 'weight_unit': str(row['weight_unit']) if pd.notna(row['weight_unit']) else None, 'origin_country': str(row['origin_country']) if pd.notna(row['origin_country']) else None, 'destination_country': str(row['destination_country']) if pd.notna(row['destination_country']) else None, 'departure_port': str(row['departure_port']) if pd.notna(row['departure_port']) else None, 'arrival_port': str(row['arrival_port']) if pd.notna(row['arrival_port']) else None, 'transport_mode': str(row['transport_mode']) if pd.notna(row['transport_mode']) else None }) return { 'total': total, 'items': items } print(f"[DEBUG] Received query: {query}") # 判断是否需要全文搜索 use_es = bool(query.product_name or query.importer_name or query.exporter_name) print(f"[DEBUG] Using ES: {use_es}") if use_es: # 使用 Elasticsearch 进行全文搜索 return await _search_with_elasticsearch(query, db) else: # 使用 PostgreSQL 进行精确查询 return await _search_with_postgres(query, db) async def _search_with_elasticsearch( query: TradeQueryRequest, db: AsyncSession ) -> PaginatedResponse[TradeRecordResponse]: """ 使用 Elasticsearch 进行全文搜索,然后根据 record_id 从 PostgreSQL 获取完整记录 """ es = get_es_client() try: # 构建 ES 查询 must_clauses = [] # 精确过滤条件 if query.source_country: must_clauses.append({"term": {"source_country": query.source_country}}) if query.trade_direction: must_clauses.append({"term": {"trade_direction": query.trade_direction}}) if query.hs_code: must_clauses.append({"prefix": {"hs_code": query.hs_code}}) # 全文搜索条件 if query.product_name: must_clauses.append({"match": {"product_name": query.product_name}}) if query.importer_name: must_clauses.append({"match": {"importer_name": query.importer_name}}) if query.exporter_name: must_clauses.append({"match": {"exporter_name": query.exporter_name}}) # 日期范围 if query.start_date or query.end_date: range_query = {"range": {"trade_date": {}}} if query.start_date: range_query["range"]["trade_date"]["gte"] = query.start_date.isoformat() if query.end_date: range_query["range"]["trade_date"]["lte"] = query.end_date.isoformat() must_clauses.append(range_query) # 构建查询体 es_query = { "query": { "bool": { "must": must_clauses if must_clauses else [{"match_all": {}}] } }, "from": query.offset, "size": query.limit, "sort": [{"trade_date": {"order": "desc"}}] } # 执行 ES 查询 response = await es.search(index="trade_entities", body=es_query) total = response["hits"]["total"]["value"] if total == 0: await es.close() return PaginatedResponse(total=0, items=[]) # 提取 record_id 列表 record_ids = [hit["_source"]["record_id"] for hit in response["hits"]["hits"]] await es.close() # 从 PostgreSQL 获取完整记录(保持 ES 的排序) stmt = select(StandardTradeRecord).where(StandardTradeRecord.record_id.in_(record_ids)) result = await db.execute(stmt) records_map = {r.record_id: r for r in result.scalars().all()} # 按 ES 返回的顺序排列 records = [records_map[rid] for rid in record_ids if rid in records_map] return PaginatedResponse(total=total, items=records) except Exception as e: # ES 查询失败时降级到 PostgreSQL from packages.core.logger import app_logger app_logger.warning(f"Elasticsearch query failed, fallback to PostgreSQL: {e}") await es.close() return await _search_with_postgres(query, db) async def _search_with_postgres( query: TradeQueryRequest, db: AsyncSession ) -> PaginatedResponse[TradeRecordResponse]: """ 使用 PostgreSQL 进行查询 """ stmt = select(StandardTradeRecord) count_stmt = select(func.count()).select_from(StandardTradeRecord) # 动态构建查询条件 filters = [] if query.source_country: filters.append(StandardTradeRecord.source_country == query.source_country) if query.trade_direction: filters.append(StandardTradeRecord.trade_direction == query.trade_direction) if query.hs_code: filters.append(StandardTradeRecord.hs_code.like(f"{query.hs_code}%")) if query.product_name: filters.append(StandardTradeRecord.product_name.ilike(f"%{query.product_name}%")) if query.importer_name: filters.append(StandardTradeRecord.importer_name.ilike(f"%{query.importer_name}%")) if query.exporter_name: filters.append(StandardTradeRecord.exporter_name.ilike(f"%{query.exporter_name}%")) if query.origin_country: filters.append(StandardTradeRecord.origin_country == query.origin_country) if query.destination_country: filters.append(StandardTradeRecord.destination_country == query.destination_country) if query.start_date: filters.append(StandardTradeRecord.trade_date >= query.start_date) if query.end_date: filters.append(StandardTradeRecord.trade_date <= query.end_date) if filters: stmt = stmt.where(*filters) count_stmt = count_stmt.where(*filters) # 计算总数 count_stmt = select(func.count()).select_from(stmt.subquery()) total_result = await db.execute(count_stmt) total = total_result.scalar() or 0 # 分页查询 - 使用原始SQL避免SQLAlchemy的datetime自动转换 from sqlalchemy import text # 构建WHERE子句 where_parts = [] params = {} if query.source_country: where_parts.append("source_country = :source_country") params['source_country'] = query.source_country if query.trade_direction: where_parts.append("trade_direction = :trade_direction") params['trade_direction'] = query.trade_direction if query.hs_code: where_parts.append("hs_code LIKE :hs_code") params['hs_code'] = f"{query.hs_code}%" if query.product_name: where_parts.append("product_name LIKE :product_name") params['product_name'] = f"%{query.product_name}%" if query.importer_name: where_parts.append("importer_name LIKE :importer_name") params['importer_name'] = f"%{query.importer_name}%" if query.exporter_name: where_parts.append("exporter_name LIKE :exporter_name") params['exporter_name'] = f"%{query.exporter_name}%" where_clause = " AND ".join(where_parts) if where_parts else "1=1" # 计算总数 count_sql = f"SELECT COUNT(*) FROM standard_trade_records WHERE {where_clause}" count_result = await db.execute(text(count_sql), params) total = count_result.scalar() or 0 # 查询数据 offset = (query.page - 1) * query.limit query_sql = f""" SELECT record_id, source_record_id, source_country, trade_direction, trade_date, importer_name, exporter_name, hs_code, product_name, amount, currency, weight, weight_unit, origin_country, destination_country, departure_port, arrival_port, transport_mode FROM standard_trade_records WHERE {where_clause} ORDER BY trade_date DESC LIMIT :limit OFFSET :offset """ params['limit'] = query.limit params['offset'] = offset result = await db.execute(text(query_sql), params) rows = result.fetchall() # 转换为响应格式 items = [] for row in rows: # row是原始数据库行,trade_date是字符串不是datetime对象 trade_date_str = row.trade_date if hasattr(row, 'trade_date') else row[4] # 解析日期字符串 if trade_date_str and trade_date_str.strip(): try: trade_date = datetime.fromisoformat(trade_date_str.replace(' ', 'T')) except: trade_date = datetime(2020, 1, 1) else: trade_date = datetime(2020, 1, 1) items.append(TradeRecordResponse( record_id=row.record_id if hasattr(row, 'record_id') else row[0], source_record_id=row.source_record_id if hasattr(row, 'source_record_id') else row[1], source_country=row.source_country if hasattr(row, 'source_country') else row[2], trade_direction=row.trade_direction if hasattr(row, 'trade_direction') else row[3], trade_date=trade_date, importer_name=(row.importer_name if hasattr(row, 'importer_name') else row[5]) or "", exporter_name=(row.exporter_name if hasattr(row, 'exporter_name') else row[6]) or "", hs_code=row.hs_code if hasattr(row, 'hs_code') else row[7], product_name=row.product_name if hasattr(row, 'product_name') else row[8], amount=row.amount if hasattr(row, 'amount') else row[9], currency=row.currency if hasattr(row, 'currency') else row[10], weight=row.weight if hasattr(row, 'weight') else row[11], weight_unit=row.weight_unit if hasattr(row, 'weight_unit') else row[12], origin_country=row.origin_country if hasattr(row, 'origin_country') else row[13], destination_country=row.destination_country if hasattr(row, 'destination_country') else row[14], departure_port=row.departure_port if hasattr(row, 'departure_port') else row[15], arrival_port=row.arrival_port if hasattr(row, 'arrival_port') else row[16], transport_mode=row.transport_mode if hasattr(row, 'transport_mode') else row[17] )) return PaginatedResponse( total=total, items=items )