Spaces:
Runtime error
Runtime error
Commit ·
3834ddb
0
Parent(s):
Initial commit for Hugging Face Space
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +22 -0
- apps/__init__.py +0 -0
- apps/api/__pycache__/main.cpython-311.pyc +0 -0
- apps/api/__pycache__/main.cpython-314.pyc +0 -0
- apps/api/main.py +45 -0
- apps/api/routers/__pycache__/trade.cpython-311.pyc +0 -0
- apps/api/routers/trade.py +55 -0
- apps/api/schemas/__pycache__/trade.cpython-311.pyc +0 -0
- apps/api/schemas/trade.py +46 -0
- apps/api/static/index.html +219 -0
- apps/worker/__init__.py +0 -0
- apps/worker/__pycache__/__init__.cpython-311.pyc +0 -0
- apps/worker/__pycache__/run_brazil.cpython-311.pyc +0 -0
- apps/worker/__pycache__/run_extended.cpython-311.pyc +0 -0
- apps/worker/__pycache__/run_mock.cpython-311.pyc +0 -0
- apps/worker/run_backfill.py +70 -0
- apps/worker/run_brazil.py +21 -0
- apps/worker/run_extended.py +24 -0
- apps/worker/run_mock.py +35 -0
- docker-compose.yml +46 -0
- docs/国家与数据源接入清单.md +159 -0
- docs/海关数据项目-MVP方案.md +525 -0
- infrastructure/monitoring/__init__.py +0 -0
- infrastructure/monitoring/__pycache__/__init__.cpython-311.pyc +0 -0
- infrastructure/monitoring/__pycache__/alert.cpython-311.pyc +0 -0
- infrastructure/monitoring/__pycache__/quality.cpython-311.pyc +0 -0
- infrastructure/monitoring/alert.py +16 -0
- infrastructure/monitoring/quality.py +43 -0
- infrastructure/scheduler/__init__.py +0 -0
- infrastructure/scheduler/main.py +59 -0
- init_db.py +22 -0
- logs/app_2026-05-29.log +0 -0
- logs/app_2026-05-30.log +0 -0
- logs/app_2026-05-31.log +0 -0
- logs/app_2026-06-01.log +0 -0
- logs/app_2026-06-02.log +0 -0
- packages/__init__.py +0 -0
- packages/__pycache__/__init__.cpython-311.pyc +0 -0
- packages/connectors/__pycache__/base.cpython-311.pyc +0 -0
- packages/connectors/__pycache__/brazil.cpython-311.pyc +0 -0
- packages/connectors/base.py +162 -0
- packages/connectors/brazil.py +131 -0
- packages/connectors/mock/__pycache__/extended_mock.cpython-311.pyc +0 -0
- packages/connectors/mock/__pycache__/us_mock.cpython-311.pyc +0 -0
- packages/connectors/mock/extended_mock.py +87 -0
- packages/connectors/mock/us_mock.py +87 -0
- packages/core/__init__.py +0 -0
- packages/core/__pycache__/__init__.cpython-311.pyc +0 -0
- packages/core/__pycache__/config.cpython-311.pyc +0 -0
- packages/core/__pycache__/database.cpython-311.pyc +0 -0
Dockerfile
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# 安装必要的系统依赖(用于编译 pg 驱动等)
|
| 6 |
+
RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/*
|
| 7 |
+
|
| 8 |
+
# 安装 Python 依赖
|
| 9 |
+
COPY requirements.txt .
|
| 10 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
+
|
| 12 |
+
# 复制代码
|
| 13 |
+
COPY . .
|
| 14 |
+
|
| 15 |
+
# 暴露端口
|
| 16 |
+
EXPOSE 8000
|
| 17 |
+
|
| 18 |
+
# 设置环境变量,使用容器内的数据库地址
|
| 19 |
+
ENV DATABASE_URL="postgresql+asyncpg://postgres:postgres@db:5432/customs_data"
|
| 20 |
+
|
| 21 |
+
# 启动命令:先运行 mock 脚本初始化库和数据,再运行巴西的测试数据,最后启动 API 服务
|
| 22 |
+
CMD ["bash", "-c", "python apps/worker/run_mock.py && python apps/worker/run_brazil.py && uvicorn apps.api.main:app --host 0.0.0.0 --port 8000"]
|
apps/__init__.py
ADDED
|
File without changes
|
apps/api/__pycache__/main.cpython-311.pyc
ADDED
|
Binary file (2.26 kB). View file
|
|
|
apps/api/__pycache__/main.cpython-314.pyc
ADDED
|
Binary file (5.22 kB). View file
|
|
|
apps/api/main.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from contextlib import asynccontextmanager
|
| 2 |
+
from fastapi import FastAPI
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
from fastapi.staticfiles import StaticFiles
|
| 5 |
+
from fastapi.responses import FileResponse
|
| 6 |
+
from packages.core.logger import app_logger
|
| 7 |
+
|
| 8 |
+
# 我们将之前写好的 trade 接口引入
|
| 9 |
+
from apps.api.routers import trade
|
| 10 |
+
|
| 11 |
+
@asynccontextmanager
|
| 12 |
+
async def lifespan(app: FastAPI):
|
| 13 |
+
# 启动时初始化
|
| 14 |
+
app_logger.info("Initializing API Service...")
|
| 15 |
+
yield
|
| 16 |
+
# 关闭时
|
| 17 |
+
app_logger.info("Closing API Service...")
|
| 18 |
+
|
| 19 |
+
app = FastAPI(
|
| 20 |
+
title="Customs Data API",
|
| 21 |
+
description="海关数据查询服务 API",
|
| 22 |
+
version="1.0.0",
|
| 23 |
+
lifespan=lifespan
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
app.add_middleware(
|
| 27 |
+
CORSMiddleware,
|
| 28 |
+
allow_origins=["*"],
|
| 29 |
+
allow_credentials=True,
|
| 30 |
+
allow_methods=["*"],
|
| 31 |
+
allow_headers=["*"],
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# 挂载静态文件
|
| 35 |
+
app.mount("/static", StaticFiles(directory="apps/api/static"), name="static")
|
| 36 |
+
|
| 37 |
+
@app.get("/", tags=["UI"])
|
| 38 |
+
async def serve_ui():
|
| 39 |
+
return FileResponse("apps/api/static/index.html")
|
| 40 |
+
|
| 41 |
+
@app.get("/api/v1/health", tags=["System"])
|
| 42 |
+
async def health_check():
|
| 43 |
+
return {"status": "ok", "service": "Customs Data API"}
|
| 44 |
+
|
| 45 |
+
app.include_router(trade.router, prefix="/api/v1/trade", tags=["Trade Search"])
|
apps/api/routers/__pycache__/trade.cpython-311.pyc
ADDED
|
Binary file (3.55 kB). View file
|
|
|
apps/api/routers/trade.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, Query, HTTPException
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy import select, func, desc
|
| 4 |
+
|
| 5 |
+
from packages.core.database import get_db_session
|
| 6 |
+
from packages.core.models import StandardTradeRecord
|
| 7 |
+
from apps.api.schemas.trade import TradeQueryRequest, TradeRecordResponse, PaginatedResponse
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
@router.post("/search", response_model=PaginatedResponse[TradeRecordResponse])
|
| 12 |
+
async def search_trade_records(
|
| 13 |
+
query: TradeQueryRequest,
|
| 14 |
+
db: AsyncSession = Depends(get_db_session)
|
| 15 |
+
):
|
| 16 |
+
"""
|
| 17 |
+
检索标准贸易记录
|
| 18 |
+
支持按国家、HS 编码、进口商模糊查询及日期范围筛选
|
| 19 |
+
"""
|
| 20 |
+
stmt = select(StandardTradeRecord)
|
| 21 |
+
count_stmt = select(func.count()).select_from(StandardTradeRecord)
|
| 22 |
+
|
| 23 |
+
# 动态构建查询条件
|
| 24 |
+
filters = []
|
| 25 |
+
if query.source_country:
|
| 26 |
+
filters.append(StandardTradeRecord.source_country == query.source_country)
|
| 27 |
+
if query.hs_code:
|
| 28 |
+
filters.append(StandardTradeRecord.hs_code.like(f"{query.hs_code}%"))
|
| 29 |
+
if query.importer_name:
|
| 30 |
+
filters.append(StandardTradeRecord.importer_name.ilike(f"%{query.importer_name}%"))
|
| 31 |
+
if query.start_date:
|
| 32 |
+
filters.append(StandardTradeRecord.trade_date >= query.start_date)
|
| 33 |
+
if query.end_date:
|
| 34 |
+
filters.append(StandardTradeRecord.trade_date <= query.end_date)
|
| 35 |
+
|
| 36 |
+
if filters:
|
| 37 |
+
stmt = stmt.where(*filters)
|
| 38 |
+
count_stmt = count_stmt.where(*filters)
|
| 39 |
+
|
| 40 |
+
# 计算总数
|
| 41 |
+
count_stmt = select(func.count()).select_from(stmt.subquery())
|
| 42 |
+
total_result = await db.execute(count_stmt)
|
| 43 |
+
total = total_result.scalar() or 0
|
| 44 |
+
|
| 45 |
+
# 分页查询
|
| 46 |
+
stmt = stmt.order_by(desc(StandardTradeRecord.trade_date))
|
| 47 |
+
stmt = stmt.offset((query.page - 1) * query.limit).limit(query.limit)
|
| 48 |
+
|
| 49 |
+
result = await db.execute(stmt)
|
| 50 |
+
records = result.scalars().all()
|
| 51 |
+
|
| 52 |
+
return PaginatedResponse(
|
| 53 |
+
total=total,
|
| 54 |
+
items=records
|
| 55 |
+
)
|
apps/api/schemas/__pycache__/trade.cpython-311.pyc
ADDED
|
Binary file (3.65 kB). View file
|
|
|
apps/api/schemas/trade.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import List, Optional, TypeVar, Generic
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
class TradeQueryRequest(BaseModel):
|
| 6 |
+
source_country: Optional[str] = Field(None, description="来源国 (如 US, BR)")
|
| 7 |
+
hs_code: Optional[str] = Field(None, description="HS编码 (模糊前缀匹配)")
|
| 8 |
+
importer_name: Optional[str] = Field(None, description="进口商名称 (模糊匹配)")
|
| 9 |
+
exporter_name: Optional[str] = Field(None, description="出口商名称 (模糊匹配)")
|
| 10 |
+
start_date: Optional[datetime] = Field(None, description="起始时间")
|
| 11 |
+
end_date: Optional[datetime] = Field(None, description="结束时间")
|
| 12 |
+
page: int = Field(1, ge=1, description="页码")
|
| 13 |
+
limit: int = Field(20, ge=1, le=100, description="每页数量")
|
| 14 |
+
|
| 15 |
+
@property
|
| 16 |
+
def offset(self) -> int:
|
| 17 |
+
return (self.page - 1) * self.limit
|
| 18 |
+
|
| 19 |
+
class TradeRecordResponse(BaseModel):
|
| 20 |
+
record_id: str
|
| 21 |
+
source_record_id: str
|
| 22 |
+
source_country: str
|
| 23 |
+
trade_direction: str
|
| 24 |
+
trade_date: datetime
|
| 25 |
+
importer_name: Optional[str]
|
| 26 |
+
exporter_name: Optional[str]
|
| 27 |
+
hs_code: Optional[str]
|
| 28 |
+
product_name: Optional[str]
|
| 29 |
+
amount: Optional[float]
|
| 30 |
+
currency: Optional[str]
|
| 31 |
+
weight: Optional[float]
|
| 32 |
+
weight_unit: Optional[str]
|
| 33 |
+
origin_country: Optional[str]
|
| 34 |
+
destination_country: Optional[str]
|
| 35 |
+
departure_port: Optional[str]
|
| 36 |
+
arrival_port: Optional[str]
|
| 37 |
+
transport_mode: Optional[str]
|
| 38 |
+
|
| 39 |
+
class Config:
|
| 40 |
+
from_attributes = True
|
| 41 |
+
|
| 42 |
+
T = TypeVar('T')
|
| 43 |
+
|
| 44 |
+
class PaginatedResponse(BaseModel, Generic[T]):
|
| 45 |
+
total: int
|
| 46 |
+
items: List[T]
|
apps/api/static/index.html
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html>
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<title>海关数据查询系统 - MVP</title>
|
| 6 |
+
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
|
| 7 |
+
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
|
| 8 |
+
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
|
| 9 |
+
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
| 10 |
+
<style>
|
| 11 |
+
body { margin: 0; padding: 20px; font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif; background-color: #f5f7fa; }
|
| 12 |
+
.app-container { max-width: 1400px; margin: 0 auto; }
|
| 13 |
+
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
| 14 |
+
.header h1 { margin: 0; color: #303133; font-size: 24px; }
|
| 15 |
+
.search-card { margin-bottom: 20px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,.1); }
|
| 16 |
+
.result-card { border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,.1); }
|
| 17 |
+
.el-table th { background-color: #f5f7fa; color: #606266; }
|
| 18 |
+
.pagination-container { margin-top: 20px; text-align: right; }
|
| 19 |
+
.tag-country { font-weight: bold; }
|
| 20 |
+
.amount-text { color: #f56c6c; font-weight: bold; }
|
| 21 |
+
</style>
|
| 22 |
+
</head>
|
| 23 |
+
<body>
|
| 24 |
+
<div id="app" class="app-container">
|
| 25 |
+
<div class="header">
|
| 26 |
+
<h1>🌐 海关数据查询系统 - 实时看板</h1>
|
| 27 |
+
<el-tag type="success">实时数据抓取运行中</el-tag>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<!-- 搜索条件区 -->
|
| 31 |
+
<el-card class="search-card">
|
| 32 |
+
<el-form :inline="true" :model="searchForm" size="small" @submit.native.prevent="handleSearch">
|
| 33 |
+
<el-form-item label="国家/地区">
|
| 34 |
+
<el-select v-model="searchForm.source_country" placeholder="选择国家" clearable style="width: 120px;">
|
| 35 |
+
<el-option label="全部" value=""></el-option>
|
| 36 |
+
<el-option label="美国 (US)" value="US"></el-option>
|
| 37 |
+
<el-option label="巴西 (BR)" value="BR"></el-option>
|
| 38 |
+
<el-option label="印度尼西亚 (ID)" value="ID"></el-option>
|
| 39 |
+
<el-option label="泰国 (TH)" value="TH"></el-option>
|
| 40 |
+
<el-option label="越南 (VN)" value="VN"></el-option>
|
| 41 |
+
<el-option label="马来西亚 (MY)" value="MY"></el-option>
|
| 42 |
+
<el-option label="新西兰 (NZ)" value="NZ"></el-option>
|
| 43 |
+
<el-option label="阿联酋 (AE)" value="AE"></el-option>
|
| 44 |
+
<el-option label="德国 (DE)" value="DE"></el-option>
|
| 45 |
+
</el-select>
|
| 46 |
+
</el-form-item>
|
| 47 |
+
<el-form-item label="进口商">
|
| 48 |
+
<el-input v-model="searchForm.importer_name" placeholder="支持模糊搜索" clearable></el-input>
|
| 49 |
+
</el-form-item>
|
| 50 |
+
<el-form-item label="出口商">
|
| 51 |
+
<el-input v-model="searchForm.exporter_name" placeholder="支持模糊搜索" clearable></el-input>
|
| 52 |
+
</el-form-item>
|
| 53 |
+
<el-form-item label="HS编码">
|
| 54 |
+
<el-input v-model="searchForm.hs_code" placeholder="如: 8471" clearable style="width: 120px;"></el-input>
|
| 55 |
+
</el-form-item>
|
| 56 |
+
<el-form-item>
|
| 57 |
+
<el-button type="primary" icon="el-icon-search" @click="handleSearch" :loading="loading">查询</el-button>
|
| 58 |
+
<el-button icon="el-icon-refresh" @click="resetSearch">重置</el-button>
|
| 59 |
+
</el-form-item>
|
| 60 |
+
</el-form>
|
| 61 |
+
</el-card>
|
| 62 |
+
|
| 63 |
+
<!-- 数据表格区 -->
|
| 64 |
+
<el-card class="result-card">
|
| 65 |
+
<div slot="header" class="clearfix">
|
| 66 |
+
<span>共找到 <b>{{ total }}</b> 条清洗后的标准提单记录</span>
|
| 67 |
+
<el-button style="float: right; padding: 3px 0" type="text" icon="el-icon-download">导出当前数据</el-button>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<el-table :data="tableData" v-loading="loading" style="width: 100%" border size="small" stripe>
|
| 71 |
+
<el-table-column prop="trade_date" label="交易日期" width="100" align="center">
|
| 72 |
+
<template slot-scope="scope">{{ formatDate(scope.row.trade_date) }}</template>
|
| 73 |
+
</el-table-column>
|
| 74 |
+
|
| 75 |
+
<el-table-column label="方向/国家" width="110" align="center">
|
| 76 |
+
<template slot-scope="scope">
|
| 77 |
+
<el-tag :type="scope.row.trade_direction === 'import' ? 'primary' : 'warning'" size="mini" effect="dark" style="margin-bottom: 4px;">
|
| 78 |
+
{{ scope.row.trade_direction === 'import' ? '进口' : '出口' }}
|
| 79 |
+
</el-tag><br>
|
| 80 |
+
<span class="tag-country">{{ scope.row.source_country }}</span>
|
| 81 |
+
</template>
|
| 82 |
+
</el-table-column>
|
| 83 |
+
|
| 84 |
+
<el-table-column label="交易双方" min-width="220">
|
| 85 |
+
<template slot-scope="scope">
|
| 86 |
+
<div style="font-size: 12px; color: #909399;">进口商:</div>
|
| 87 |
+
<div style="font-weight: bold; margin-bottom: 8px;">{{ scope.row.importer_name || '-' }}</div>
|
| 88 |
+
<div style="font-size: 12px; color: #909399;">出口商:</div>
|
| 89 |
+
<div style="font-weight: bold;">{{ scope.row.exporter_name || '-' }}</div>
|
| 90 |
+
</template>
|
| 91 |
+
</el-table-column>
|
| 92 |
+
|
| 93 |
+
<el-table-column label="商品信息" min-width="200">
|
| 94 |
+
<template slot-scope="scope">
|
| 95 |
+
<el-tag size="mini" type="info" style="margin-bottom: 4px;">HS: {{ scope.row.hs_code || '-' }}</el-tag>
|
| 96 |
+
<div style="font-size: 12px; line-height: 1.4; color: #606266;">
|
| 97 |
+
{{ scope.row.product_name || '-' }}
|
| 98 |
+
</div>
|
| 99 |
+
</template>
|
| 100 |
+
</el-table-column>
|
| 101 |
+
|
| 102 |
+
<el-table-column label="金额/重量" width="160" align="right">
|
| 103 |
+
<template slot-scope="scope">
|
| 104 |
+
<div class="amount-text" v-if="scope.row.amount">
|
| 105 |
+
{{ formatNumber(scope.row.amount) }} {{ scope.row.currency }}
|
| 106 |
+
</div>
|
| 107 |
+
<div v-else>-</div>
|
| 108 |
+
<div style="font-size: 12px; color: #909399; margin-top: 4px;" v-if="scope.row.weight">
|
| 109 |
+
{{ formatNumber(scope.row.weight) }} {{ scope.row.weight_unit }}
|
| 110 |
+
</div>
|
| 111 |
+
</template>
|
| 112 |
+
</el-table-column>
|
| 113 |
+
|
| 114 |
+
<el-table-column label="航线信息" width="160">
|
| 115 |
+
<template slot-scope="scope">
|
| 116 |
+
<div style="font-size: 12px;"><i class="el-icon-location-outline"></i> 原产: {{ scope.row.origin_country || '-' }}</div>
|
| 117 |
+
<div style="font-size: 12px;"><i class="el-icon-place"></i> 目的: {{ scope.row.destination_country || '-' }}</div>
|
| 118 |
+
<div style="font-size: 12px; margin-top: 4px; color: #909399;">
|
| 119 |
+
<i class="el-icon-ship"></i> 方式: {{ scope.row.transport_mode || '-' }}
|
| 120 |
+
</div>
|
| 121 |
+
</template>
|
| 122 |
+
</el-table-column>
|
| 123 |
+
</el-table>
|
| 124 |
+
|
| 125 |
+
<div class="pagination-container">
|
| 126 |
+
<el-pagination
|
| 127 |
+
background
|
| 128 |
+
@size-change="handleSizeChange"
|
| 129 |
+
@current-change="handleCurrentChange"
|
| 130 |
+
:current-page="searchForm.page"
|
| 131 |
+
:page-sizes="[10, 20, 50, 100]"
|
| 132 |
+
:page-size="searchForm.limit"
|
| 133 |
+
layout="total, sizes, prev, pager, next, jumper"
|
| 134 |
+
:total="total">
|
| 135 |
+
</el-pagination>
|
| 136 |
+
</div>
|
| 137 |
+
</el-card>
|
| 138 |
+
</div>
|
| 139 |
+
|
| 140 |
+
<script>
|
| 141 |
+
new Vue({
|
| 142 |
+
el: '#app',
|
| 143 |
+
data: function() {
|
| 144 |
+
return {
|
| 145 |
+
loading: false,
|
| 146 |
+
tableData: [],
|
| 147 |
+
total: 0,
|
| 148 |
+
searchForm: {
|
| 149 |
+
source_country: '',
|
| 150 |
+
importer_name: '',
|
| 151 |
+
exporter_name: '',
|
| 152 |
+
hs_code: '',
|
| 153 |
+
page: 1,
|
| 154 |
+
limit: 10
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
},
|
| 158 |
+
mounted() {
|
| 159 |
+
this.fetchData();
|
| 160 |
+
},
|
| 161 |
+
methods: {
|
| 162 |
+
async fetchData() {
|
| 163 |
+
this.loading = true;
|
| 164 |
+
try {
|
| 165 |
+
// 清理空参数
|
| 166 |
+
const payload = {};
|
| 167 |
+
for (const key in this.searchForm) {
|
| 168 |
+
if (this.searchForm[key] !== '' && this.searchForm[key] !== null) {
|
| 169 |
+
payload[key] = this.searchForm[key];
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
const response = await axios.post('/api/v1/trade/search', payload);
|
| 174 |
+
this.tableData = response.data.items;
|
| 175 |
+
this.total = response.data.total;
|
| 176 |
+
} catch (error) {
|
| 177 |
+
this.$message.error('获取数据失败,请检查网络或刷新重试');
|
| 178 |
+
console.error(error);
|
| 179 |
+
} finally {
|
| 180 |
+
this.loading = false;
|
| 181 |
+
}
|
| 182 |
+
},
|
| 183 |
+
handleSearch() {
|
| 184 |
+
this.searchForm.page = 1;
|
| 185 |
+
this.fetchData();
|
| 186 |
+
},
|
| 187 |
+
resetSearch() {
|
| 188 |
+
this.searchForm = {
|
| 189 |
+
source_country: '',
|
| 190 |
+
importer_name: '',
|
| 191 |
+
exporter_name: '',
|
| 192 |
+
hs_code: '',
|
| 193 |
+
page: 1,
|
| 194 |
+
limit: 10
|
| 195 |
+
};
|
| 196 |
+
this.fetchData();
|
| 197 |
+
},
|
| 198 |
+
handleSizeChange(val) {
|
| 199 |
+
this.searchForm.limit = val;
|
| 200 |
+
this.fetchData();
|
| 201 |
+
},
|
| 202 |
+
handleCurrentChange(val) {
|
| 203 |
+
this.searchForm.page = val;
|
| 204 |
+
this.fetchData();
|
| 205 |
+
},
|
| 206 |
+
formatDate(dateString) {
|
| 207 |
+
if (!dateString) return '-';
|
| 208 |
+
const date = new Date(dateString);
|
| 209 |
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
| 210 |
+
},
|
| 211 |
+
formatNumber(num) {
|
| 212 |
+
if (num === null || num === undefined) return '-';
|
| 213 |
+
return Number(num).toLocaleString('en-US');
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
})
|
| 217 |
+
</script>
|
| 218 |
+
</body>
|
| 219 |
+
</html>
|
apps/worker/__init__.py
ADDED
|
File without changes
|
apps/worker/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (137 Bytes). View file
|
|
|
apps/worker/__pycache__/run_brazil.cpython-311.pyc
ADDED
|
Binary file (1.63 kB). View file
|
|
|
apps/worker/__pycache__/run_extended.cpython-311.pyc
ADDED
|
Binary file (2 kB). View file
|
|
|
apps/worker/__pycache__/run_mock.cpython-311.pyc
ADDED
|
Binary file (3 kB). View file
|
|
|
apps/worker/run_backfill.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import sys
|
| 3 |
+
import os
|
| 4 |
+
import argparse
|
| 5 |
+
from datetime import datetime, timedelta
|
| 6 |
+
|
| 7 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
| 8 |
+
|
| 9 |
+
from packages.core.logger import app_logger
|
| 10 |
+
|
| 11 |
+
async def run_backfill(country_code: str, start_date: str, end_date: str):
|
| 12 |
+
"""
|
| 13 |
+
历史数据回溯脚本 (Backfill)
|
| 14 |
+
允许指定国家和时间范围,批量补录过去的数据。
|
| 15 |
+
"""
|
| 16 |
+
app_logger.info(f"--- Starting BACKFILL Job for {country_code} from {start_date} to {end_date} ---")
|
| 17 |
+
|
| 18 |
+
# 如果环境变量中没有 DATABASE_URL,则设置一个默认值(兼容宿主机直接运行)
|
| 19 |
+
if "DATABASE_URL" not in os.environ:
|
| 20 |
+
os.environ["DATABASE_URL"] = "postgresql+asyncpg://postgres:postgres@localhost:5433/customs_data"
|
| 21 |
+
|
| 22 |
+
from packages.core.database import AsyncSessionLocal
|
| 23 |
+
from packages.connectors.mock.us_mock import MockUSConnector
|
| 24 |
+
from packages.connectors.brazil import BrazilComexStatConnector
|
| 25 |
+
from packages.connectors.mock.extended_mock import ExtendedMockConnector, COUNTRY_CONFIGS
|
| 26 |
+
|
| 27 |
+
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
| 28 |
+
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
| 29 |
+
|
| 30 |
+
async with AsyncSessionLocal() as session:
|
| 31 |
+
# 1. 实例化对应的 Connector
|
| 32 |
+
if country_code == "US":
|
| 33 |
+
connector = MockUSConnector(session=session)
|
| 34 |
+
elif country_code == "BR":
|
| 35 |
+
connector = BrazilComexStatConnector(session=session)
|
| 36 |
+
elif country_code in COUNTRY_CONFIGS:
|
| 37 |
+
connector = ExtendedMockConnector(country_code=country_code, session=session)
|
| 38 |
+
else:
|
| 39 |
+
app_logger.error(f"Unsupported country code for backfill: {country_code}")
|
| 40 |
+
return
|
| 41 |
+
|
| 42 |
+
# 2. 模拟按月拆分任务进行回溯
|
| 43 |
+
# 真实场景下,海关接口通常限制单次查询跨度,比如只能查一个月
|
| 44 |
+
current_dt = start_dt
|
| 45 |
+
while current_dt <= end_dt:
|
| 46 |
+
next_month = current_dt.replace(day=28) + timedelta(days=4)
|
| 47 |
+
next_month_start = next_month.replace(day=1)
|
| 48 |
+
batch_end_dt = min(next_month_start - timedelta(days=1), end_dt)
|
| 49 |
+
|
| 50 |
+
app_logger.info(f"[{country_code}] Backfilling for period: {current_dt.strftime('%Y-%m-%d')} to {batch_end_dt.strftime('%Y-%m-%d')}")
|
| 51 |
+
|
| 52 |
+
# TODO: 真实场景下,这里要将 current_dt 和 batch_end_dt 传给 fetch/discover
|
| 53 |
+
# 目前 MVP 阶段调用 run() 模拟执行一轮抓取
|
| 54 |
+
# 为了防止冲突,强行注入一个特定的 batch_no 前缀
|
| 55 |
+
connector.batch_no = f"BACKFILL_{country_code}_{current_dt.strftime('%Y%m')}"
|
| 56 |
+
await connector.run()
|
| 57 |
+
|
| 58 |
+
current_dt = next_month_start
|
| 59 |
+
|
| 60 |
+
app_logger.info(f"--- Finished BACKFILL Job for {country_code} ---")
|
| 61 |
+
|
| 62 |
+
if __name__ == "__main__":
|
| 63 |
+
parser = argparse.ArgumentParser(description="Customs Data Backfill Script")
|
| 64 |
+
parser.add_argument("--country", type=str, required=True, help="Country Code (e.g., US, BR, ID)")
|
| 65 |
+
parser.add_argument("--start", type=str, required=True, help="Start Date (YYYY-MM-DD)")
|
| 66 |
+
parser.add_argument("--end", type=str, required=True, help="End Date (YYYY-MM-DD)")
|
| 67 |
+
|
| 68 |
+
args = parser.parse_args()
|
| 69 |
+
|
| 70 |
+
asyncio.run(run_backfill(country_code=args.country, start_date=args.start, end_date=args.end))
|
apps/worker/run_brazil.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import sys
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
# 将项目根目录加入 sys.path
|
| 6 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
| 7 |
+
|
| 8 |
+
from packages.core.logger import app_logger
|
| 9 |
+
from packages.core.database import AsyncSessionLocal
|
| 10 |
+
from packages.connectors.brazil import BrazilComexStatConnector
|
| 11 |
+
|
| 12 |
+
async def run_brazil_job():
|
| 13 |
+
"""运行巴西海关数据采集链路"""
|
| 14 |
+
app_logger.info("Starting Brazil data sync job...")
|
| 15 |
+
|
| 16 |
+
async with AsyncSessionLocal() as session:
|
| 17 |
+
connector = BrazilComexStatConnector(session)
|
| 18 |
+
await connector.run()
|
| 19 |
+
|
| 20 |
+
if __name__ == "__main__":
|
| 21 |
+
asyncio.run(run_brazil_job())
|
apps/worker/run_extended.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import sys
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
| 6 |
+
|
| 7 |
+
from packages.core.logger import app_logger
|
| 8 |
+
from packages.connectors.mock.extended_mock import ExtendedMockConnector, COUNTRY_CONFIGS
|
| 9 |
+
|
| 10 |
+
from packages.core.database import AsyncSessionLocal
|
| 11 |
+
|
| 12 |
+
async def run_extended_mock_jobs():
|
| 13 |
+
"""
|
| 14 |
+
循环跑批处理 7 个新国家的采集任务
|
| 15 |
+
"""
|
| 16 |
+
async with AsyncSessionLocal() as session:
|
| 17 |
+
for country_code in COUNTRY_CONFIGS.keys():
|
| 18 |
+
app_logger.info(f"--- Starting Sync Job for {country_code} ---")
|
| 19 |
+
connector = ExtendedMockConnector(country_code=country_code, session=session)
|
| 20 |
+
await connector.run()
|
| 21 |
+
app_logger.info(f"--- Finished Sync Job for {country_code} ---")
|
| 22 |
+
|
| 23 |
+
if __name__ == "__main__":
|
| 24 |
+
asyncio.run(run_extended_mock_jobs())
|
apps/worker/run_mock.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import sys
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
# 将项目根目录加入 sys.path,以便直接运行该脚本
|
| 6 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
| 7 |
+
|
| 8 |
+
from packages.core.logger import app_logger
|
| 9 |
+
from packages.core.database import engine, Base, AsyncSessionLocal
|
| 10 |
+
from packages.connectors.mock.us_mock import MockUSConnector
|
| 11 |
+
|
| 12 |
+
async def init_db():
|
| 13 |
+
"""初始化数据库表结构"""
|
| 14 |
+
app_logger.info("Initializing database tables...")
|
| 15 |
+
async with engine.begin() as conn:
|
| 16 |
+
await conn.run_sync(Base.metadata.create_all)
|
| 17 |
+
app_logger.info("Database tables initialized.")
|
| 18 |
+
|
| 19 |
+
async def run_mock_job():
|
| 20 |
+
"""运行 Mock Connector 验证链路"""
|
| 21 |
+
app_logger.info("Starting mock job...")
|
| 22 |
+
|
| 23 |
+
async with AsyncSessionLocal() as session:
|
| 24 |
+
connector = MockUSConnector(session)
|
| 25 |
+
await connector.run()
|
| 26 |
+
|
| 27 |
+
async def main():
|
| 28 |
+
try:
|
| 29 |
+
await init_db()
|
| 30 |
+
await run_mock_job()
|
| 31 |
+
except Exception as e:
|
| 32 |
+
app_logger.error(f"Execution failed: {e}")
|
| 33 |
+
|
| 34 |
+
if __name__ == "__main__":
|
| 35 |
+
asyncio.run(main())
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
db:
|
| 5 |
+
image: postgres:15
|
| 6 |
+
container_name: customs_db
|
| 7 |
+
environment:
|
| 8 |
+
POSTGRES_USER: postgres
|
| 9 |
+
POSTGRES_PASSWORD: postgres
|
| 10 |
+
POSTGRES_DB: customs_data
|
| 11 |
+
ports:
|
| 12 |
+
- "5433:5432"
|
| 13 |
+
volumes:
|
| 14 |
+
- customs_pgdata:/var/lib/postgresql/data
|
| 15 |
+
healthcheck:
|
| 16 |
+
test: ["CMD-SHELL", "pg_isready -U postgres -d customs_data"]
|
| 17 |
+
interval: 5s
|
| 18 |
+
timeout: 5s
|
| 19 |
+
retries: 5
|
| 20 |
+
restart: always
|
| 21 |
+
|
| 22 |
+
api:
|
| 23 |
+
build: .
|
| 24 |
+
ports:
|
| 25 |
+
- "8000:8000"
|
| 26 |
+
depends_on:
|
| 27 |
+
db:
|
| 28 |
+
condition: service_healthy
|
| 29 |
+
volumes:
|
| 30 |
+
- .:/app
|
| 31 |
+
environment:
|
| 32 |
+
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/customs_data
|
| 33 |
+
|
| 34 |
+
scheduler:
|
| 35 |
+
build: .
|
| 36 |
+
depends_on:
|
| 37 |
+
db:
|
| 38 |
+
condition: service_healthy
|
| 39 |
+
volumes:
|
| 40 |
+
- .:/app
|
| 41 |
+
environment:
|
| 42 |
+
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/customs_data
|
| 43 |
+
command: ["bash", "-c", "python infrastructure/scheduler/main.py"]
|
| 44 |
+
|
| 45 |
+
volumes:
|
| 46 |
+
customs_pgdata:
|
docs/国家与数据源接入清单.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 国家与数据源接入清单
|
| 2 |
+
|
| 3 |
+
## 1. 目标
|
| 4 |
+
|
| 5 |
+
这份文档用于管理“国家覆盖是否全、更新是否及时、接入优先级如何排”的实际落地清单。
|
| 6 |
+
|
| 7 |
+
规则:
|
| 8 |
+
|
| 9 |
+
- 一个国家可对应多个数据源。
|
| 10 |
+
- 必须同时记录“原始来源、合规状态、更新频率、字段完整度、接入难度”。
|
| 11 |
+
- 未确认合法性的数据源,不进入正式商用。
|
| 12 |
+
|
| 13 |
+
## 2. 数据源分级
|
| 14 |
+
|
| 15 |
+
### A 类:优先
|
| 16 |
+
|
| 17 |
+
- 官方 API
|
| 18 |
+
- 官方开放数据下载
|
| 19 |
+
- 官方统计接口
|
| 20 |
+
|
| 21 |
+
特点:
|
| 22 |
+
|
| 23 |
+
- 合规风险相对低
|
| 24 |
+
- 稳定性相对高
|
| 25 |
+
- 更适合长期运行
|
| 26 |
+
|
| 27 |
+
### B 类:可用
|
| 28 |
+
|
| 29 |
+
- 政府网页公开信息抓取
|
| 30 |
+
- 半结构化文件下载
|
| 31 |
+
- 公共统计平台
|
| 32 |
+
|
| 33 |
+
特点:
|
| 34 |
+
|
| 35 |
+
- 可做 MVP
|
| 36 |
+
- 但页面变动风险更高
|
| 37 |
+
|
| 38 |
+
### C 类:谨慎
|
| 39 |
+
|
| 40 |
+
- 第三方聚合平台
|
| 41 |
+
- 需账号、订阅或授权的数据源
|
| 42 |
+
- 授权边界不清晰的数据源
|
| 43 |
+
|
| 44 |
+
特点:
|
| 45 |
+
|
| 46 |
+
- 可用于补齐覆盖
|
| 47 |
+
- 但必须先过合规审查
|
| 48 |
+
|
| 49 |
+
## 3. 国家优先级建议
|
| 50 |
+
|
| 51 |
+
### P0:MVP 首批
|
| 52 |
+
|
| 53 |
+
- [ ] 美国
|
| 54 |
+
- [ ] 印度
|
| 55 |
+
- [ ] 越南
|
| 56 |
+
- [ ] 巴西
|
| 57 |
+
- [ ] 墨西哥
|
| 58 |
+
- [ ] 印度尼西亚
|
| 59 |
+
- [ ] 土耳其
|
| 60 |
+
- [ ] 菲律宾
|
| 61 |
+
- [ ] 巴基斯坦
|
| 62 |
+
- [ ] 欧盟公开统计源
|
| 63 |
+
|
| 64 |
+
### P1:第二批
|
| 65 |
+
|
| 66 |
+
- [ ] 马来西亚
|
| 67 |
+
- [ ] 泰国
|
| 68 |
+
- [ ] 阿联酋
|
| 69 |
+
- [ ] 南非
|
| 70 |
+
- [ ] 智利
|
| 71 |
+
- [ ] 哥伦比亚
|
| 72 |
+
- [ ] 阿根廷
|
| 73 |
+
- [ ] 埃及
|
| 74 |
+
|
| 75 |
+
### P2:后续补强
|
| 76 |
+
|
| 77 |
+
- [ ] 日本
|
| 78 |
+
- [ ] 韩国
|
| 79 |
+
- [ ] 加拿大
|
| 80 |
+
- [ ] 澳大利亚
|
| 81 |
+
- [ ] 俄罗斯
|
| 82 |
+
- [ ] 中东与非洲更多国家
|
| 83 |
+
|
| 84 |
+
## 4. 首批国家(P0)初步数据源调研清单
|
| 85 |
+
|
| 86 |
+
每接入一个国家,按下面模板补充,不要只记在代码里。
|
| 87 |
+
|
| 88 |
+
### 4.1 美国 (United States)
|
| 89 |
+
- 优先级:P0
|
| 90 |
+
- 推荐数据源类型:CBP 官方 / Bill of Lading (提单数据) / PIERS 等第三方商业整合源
|
| 91 |
+
- 难点与特点:美国官方海关(CBP)提供的是高度汇总数据,明细提单数据(B/L)需要走商业渠道或付费订阅。
|
| 92 |
+
- 更新频率:日更(提单级别)/ 月更(宏观统计)
|
| 93 |
+
- 字段完整度:高(包含真实企业名、重量、数量)
|
| 94 |
+
- 合规状态:可用(提单数据属于公开记录,但需注意加州等隐私法对个人收件人的脱敏要求)
|
| 95 |
+
- 适配器状态:未开始
|
| 96 |
+
|
| 97 |
+
### 4.2 印度 (India)
|
| 98 |
+
- 优先级:P0
|
| 99 |
+
- 推荐数据源类型:Niryat Mitra / Zauba Trade (或同类第三方进出口平台)
|
| 100 |
+
- 难点与特点:印度官方曾关闭过企业名级别的公开,部分第三方平台通过港口内部渠道获取,数据结构复杂,且常常存在拼写错误。
|
| 101 |
+
- 更新频率:周更 / 月更
|
| 102 |
+
- 字段完整度:中/高(HS 编码细致,企业名称需要强力清洗)
|
| 103 |
+
- 合规状态:需排查第三方平台抓取风险
|
| 104 |
+
- 适配器状态:未开始
|
| 105 |
+
|
| 106 |
+
### 4.3 越南 (Vietnam)
|
| 107 |
+
- 优先级:P0
|
| 108 |
+
- 推荐数据源类型:越南海关总署 (General Department of Vietnam Customs) / GSO
|
| 109 |
+
- 难点与特点:官方接口常变且有验证码,多数情况为汇总数据;明细需对接当地数据供应商。
|
| 110 |
+
- 更新频率:月更
|
| 111 |
+
- 字段完整度:中
|
| 112 |
+
- 适配器状态:未开始
|
| 113 |
+
|
| 114 |
+
### 4.4 巴西 (Brazil)
|
| 115 |
+
- 优先级:P0
|
| 116 |
+
- 推荐数据源类型:Siscomex / Comex Stat (官方外贸统计网)
|
| 117 |
+
- 难点与特点:官方数据开放度极高,提供详细的 CSV/API 下载,但是企业信息(CNPJ)可能被哈希处理或部分隐藏。
|
| 118 |
+
- 更新频率:月更
|
| 119 |
+
- 字段完整度:高(金额、港口非常清晰)
|
| 120 |
+
- 适配器状态:未开始
|
| 121 |
+
|
| 122 |
+
### 4.5 墨西哥 (Mexico)
|
| 123 |
+
- 优先级:P0
|
| 124 |
+
- 推荐数据源类型:SAT (Servicio de Administración Tributaria) / SIAVI
|
| 125 |
+
- 难点与特点:官方数据定期发布压缩包,解析难度中等,语言为西班牙语,需处理编码和字典翻译。
|
| 126 |
+
- 适配器状态:未开始
|
| 127 |
+
|
| 128 |
+
*(其他首批国家印尼、土耳其、菲律宾、巴基斯坦、欧盟在具体接入时按此模板补充)*
|
| 129 |
+
|
| 130 |
+
## 5. 字段完整度标准
|
| 131 |
+
|
| 132 |
+
### 高
|
| 133 |
+
|
| 134 |
+
- 企业、商品、数量、金额、时间、国家、港口等核心字段大部分都有。
|
| 135 |
+
|
| 136 |
+
### 中
|
| 137 |
+
|
| 138 |
+
- 有主体交易信息,但部分字段缺失,仍可支持基础查询。
|
| 139 |
+
|
| 140 |
+
### 低
|
| 141 |
+
|
| 142 |
+
- 只能提供宏观统计或字段缺失严重,只能作为补充源。
|
| 143 |
+
|
| 144 |
+
## 6. 更新优先级规则
|
| 145 |
+
|
| 146 |
+
- [ ] 日更以上的数据源,优先做增量自动化。
|
| 147 |
+
- [ ] 周更和月更的数据源,优先保证稳定同步,不追求过高轮询。
|
| 148 |
+
- [ ] 公开源不稳定时,必须有备用源或人工补采预案。
|
| 149 |
+
- [ ] 每个国家至少保留一个主源和一个备选源。
|
| 150 |
+
|
| 151 |
+
## 7. 当前接入建议
|
| 152 |
+
|
| 153 |
+
MVP 先做到以下状态:
|
| 154 |
+
|
| 155 |
+
- [ ] 首批 10 个重点国家完成来源确认
|
| 156 |
+
- [ ] 每个国家至少确定 1 个主源
|
| 157 |
+
- [ ] 其中至少 5 个国家完成自动增量
|
| 158 |
+
- [ ] 所有已接入国家都完成字段映射表
|
| 159 |
+
- [ ] 所有已接入国家都完成合规标记
|
docs/海关数据项目-MVP方案.md
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 海关数据项目 MVP 方案
|
| 2 |
+
|
| 3 |
+
## 1. 项目目标
|
| 4 |
+
|
| 5 |
+
### 1.1 目标
|
| 6 |
+
|
| 7 |
+
- 做一个可持续运营的全球海关数据采集与查询平台。
|
| 8 |
+
- 第一阶段先做 MVP,但底层架构按“后续可扩国家、可扩数据源、可扩更新频率”设计。
|
| 9 |
+
- 核心要求:
|
| 10 |
+
- 数据覆盖尽可能全。
|
| 11 |
+
- 更新尽可能及时。
|
| 12 |
+
- 数据可追溯、可重跑、可纠错。
|
| 13 |
+
- 后续可支持商业查询、订阅预警、企业画像。
|
| 14 |
+
|
| 15 |
+
### 1.2 MVP 定义
|
| 16 |
+
|
| 17 |
+
MVP 不是一次把所有国家全部深度做完,而是先把平台骨架、统一数据模型、接入规范、调度体系、查询接口、质量控制体系搭起来,再优先接入高价值国家和公开可持续获取的数据源。
|
| 18 |
+
|
| 19 |
+
MVP 的完成标准:
|
| 20 |
+
|
| 21 |
+
- 有统一数据标准。
|
| 22 |
+
- 有标准化采集流程。
|
| 23 |
+
- 有定时更新机制。
|
| 24 |
+
- 有去重、纠错、补采能力。
|
| 25 |
+
- 有查询接口和基础后台。
|
| 26 |
+
- 有国家接入扩展机制。
|
| 27 |
+
|
| 28 |
+
## 2. 业务判断
|
| 29 |
+
|
| 30 |
+
### 2.1 “数据要全”的现实定义
|
| 31 |
+
|
| 32 |
+
全球海关数据不存在单一、完整、官方统一开放源。不同国家的数据开放程度、字段范围、更新频率、合规要求差异很大。
|
| 33 |
+
|
| 34 |
+
所以“全”要拆成三层:
|
| 35 |
+
|
| 36 |
+
- 国家覆盖全:尽可能覆盖更多国家。
|
| 37 |
+
- 字段覆盖全:对每个国家尽可能保留原始字段,并映射到统一标准字段。
|
| 38 |
+
- 时间覆盖全:尽可能做历史补采和持续增量更新。
|
| 39 |
+
|
| 40 |
+
MVP 建议先实现:
|
| 41 |
+
|
| 42 |
+
- 优先覆盖贸易数据价值高、公开度高、更新稳定的国家。
|
| 43 |
+
- 每个国家保留“原始字段 + 标准字段 + 元数据”三套信息。
|
| 44 |
+
- 所有采集任务必须支持历史回补与断点续跑。
|
| 45 |
+
|
| 46 |
+
### 2.2 “及时更新”的现实定义
|
| 47 |
+
|
| 48 |
+
不同国家海关数据不是实时流式开放,通常是日更、周更、月更,少数来源还会延迟发布。
|
| 49 |
+
|
| 50 |
+
所以时效目标要按数据源分层:
|
| 51 |
+
|
| 52 |
+
- T1:日更或准实时源,目标 1 小时到 6 小时内完成增量入库。
|
| 53 |
+
- T2:周更源,目标当天完成同步。
|
| 54 |
+
- T3:月更源,目标发布后 24 小时内完成同步。
|
| 55 |
+
|
| 56 |
+
结论:产品层面强调“数据发布后最快同步”,不要承诺所有国家实时。
|
| 57 |
+
|
| 58 |
+
## 3. 总体架构
|
| 59 |
+
|
| 60 |
+
## 3.1 架构原则
|
| 61 |
+
|
| 62 |
+
- 采集与清洗解耦。
|
| 63 |
+
- 国家接入插件化。
|
| 64 |
+
- 原始数据永远保留。
|
| 65 |
+
- 标准模型统一,对外查询稳定。
|
| 66 |
+
- 调度、重试、告警、审计独立。
|
| 67 |
+
- 先保证稳定和可扩展,再逐步提高更新频率。
|
| 68 |
+
|
| 69 |
+
### 3.2 推荐分层
|
| 70 |
+
|
| 71 |
+
#### A. 数据源接入层
|
| 72 |
+
|
| 73 |
+
负责从不同国家或第三方源采集原始数据:
|
| 74 |
+
|
| 75 |
+
- 官方开放 API
|
| 76 |
+
- 官方下载文件
|
| 77 |
+
- 政府公开页面抓取
|
| 78 |
+
- 第三方商业数据源
|
| 79 |
+
- 手工导入源
|
| 80 |
+
|
| 81 |
+
每个来源都实现统一接口:
|
| 82 |
+
|
| 83 |
+
- `discover()`:发现新批次、新文件、新日期分片
|
| 84 |
+
- `fetch()`:下载原始数据
|
| 85 |
+
- `parse()`:解析成结构化记录
|
| 86 |
+
- `checkpoint()`:记录进度
|
| 87 |
+
|
| 88 |
+
#### B. 原始数据层
|
| 89 |
+
|
| 90 |
+
保存未经破坏的原始结果:
|
| 91 |
+
|
| 92 |
+
- 原始文件
|
| 93 |
+
- 原始响应体
|
| 94 |
+
- 原始记录 JSON
|
| 95 |
+
- 来源元信息
|
| 96 |
+
|
| 97 |
+
用途:
|
| 98 |
+
|
| 99 |
+
- 出问题可回放
|
| 100 |
+
- 后续字段扩展不用重新采集
|
| 101 |
+
- 合规审计有依据
|
| 102 |
+
|
| 103 |
+
#### C. 标准化处理层
|
| 104 |
+
|
| 105 |
+
把不同国家的字段映射成统一结构:
|
| 106 |
+
|
| 107 |
+
- 贸易时间
|
| 108 |
+
- 进口/出口
|
| 109 |
+
- 申报国
|
| 110 |
+
- 起运国/目的国
|
| 111 |
+
- HS 编码
|
| 112 |
+
- 商品描述
|
| 113 |
+
- 数量
|
| 114 |
+
- 单位
|
| 115 |
+
- 金额
|
| 116 |
+
- 币种
|
| 117 |
+
- 企业名称
|
| 118 |
+
- 港口
|
| 119 |
+
- 运输方式
|
| 120 |
+
- 来源系统
|
| 121 |
+
- 抓取批次
|
| 122 |
+
|
| 123 |
+
这里要做:
|
| 124 |
+
|
| 125 |
+
- 字段标准化
|
| 126 |
+
- 币种处理
|
| 127 |
+
- 时间格式统一
|
| 128 |
+
- 国家/港口/单位字典映射
|
| 129 |
+
- 企业名清洗
|
| 130 |
+
- HS 编码规范化
|
| 131 |
+
|
| 132 |
+
#### D. 数据质量层
|
| 133 |
+
|
| 134 |
+
负责:
|
| 135 |
+
|
| 136 |
+
- 去重
|
| 137 |
+
- 缺失值识别
|
| 138 |
+
- 字段异常检测
|
| 139 |
+
- 同批次波动监控
|
| 140 |
+
- 国家级采集成功率统计
|
| 141 |
+
|
| 142 |
+
#### E. 服务层
|
| 143 |
+
|
| 144 |
+
对内对外提供:
|
| 145 |
+
|
| 146 |
+
- 查询 API
|
| 147 |
+
- 后台任务管理
|
| 148 |
+
- 数据源管理
|
| 149 |
+
- 数据质量看板
|
| 150 |
+
- 更新状态看板
|
| 151 |
+
|
| 152 |
+
#### F. 调度与运维层
|
| 153 |
+
|
| 154 |
+
负责:
|
| 155 |
+
|
| 156 |
+
- 定时任务
|
| 157 |
+
- 增量抓取
|
| 158 |
+
- 历史补采
|
| 159 |
+
- 失败重试
|
| 160 |
+
- 死信任务
|
| 161 |
+
- 告警通知
|
| 162 |
+
|
| 163 |
+
## 4. 技术选型建议
|
| 164 |
+
|
| 165 |
+
### 4.1 后端
|
| 166 |
+
|
| 167 |
+
MVP 建议:
|
| 168 |
+
|
| 169 |
+
- 采集任务:Python
|
| 170 |
+
- API 服务:Python FastAPI
|
| 171 |
+
- 调度:Prefect 或 Celery
|
| 172 |
+
- 消息队列:Redis 或 RabbitMQ
|
| 173 |
+
|
| 174 |
+
原因:
|
| 175 |
+
|
| 176 |
+
- Python 生态适合抓取、解析、数据清洗。
|
| 177 |
+
- FastAPI 适合快速出管理接口和查询接口。
|
| 178 |
+
- Prefect 更适合数据工作流管理;如果更重视成熟队列消费,也可以用 Celery。
|
| 179 |
+
|
| 180 |
+
### 4.2 数据存储
|
| 181 |
+
|
| 182 |
+
- 结构化主库:PostgreSQL
|
| 183 |
+
- 检索增强:OpenSearch 或 Elasticsearch
|
| 184 |
+
- 原始文件存储:对象存储
|
| 185 |
+
- 缓存:Redis
|
| 186 |
+
|
| 187 |
+
建议:
|
| 188 |
+
|
| 189 |
+
- PostgreSQL 存标准记录、任务、元数据、字典表。
|
| 190 |
+
- OpenSearch 做商品描述、企业名、模糊搜索。
|
| 191 |
+
- 对象存储保留原始文件和原始响应。
|
| 192 |
+
|
| 193 |
+
### 4.3 前端
|
| 194 |
+
|
| 195 |
+
MVP 前端建议拆两部分:
|
| 196 |
+
|
| 197 |
+
- 管理后台:任务监控、国家接入管理、质量看板
|
| 198 |
+
- 查询前台:企业、商品、国家、时间维度查询
|
| 199 |
+
|
| 200 |
+
技术可选:
|
| 201 |
+
|
| 202 |
+
- Web:Next.js 或 React
|
| 203 |
+
- 组件体系:统一一套后台组件和表格筛选能力
|
| 204 |
+
|
| 205 |
+
## 5. 数据模型设计
|
| 206 |
+
|
| 207 |
+
### 5.1 原始记录表
|
| 208 |
+
|
| 209 |
+
建议保留:
|
| 210 |
+
|
| 211 |
+
- 来源国家
|
| 212 |
+
- 来源系统
|
| 213 |
+
- 采集时间
|
| 214 |
+
- 原始主键
|
| 215 |
+
- 原始 JSON
|
| 216 |
+
- 文件地址
|
| 217 |
+
- 批次号
|
| 218 |
+
- 哈希指纹
|
| 219 |
+
- 解析状态
|
| 220 |
+
|
| 221 |
+
### 5.2 标准贸易记录表
|
| 222 |
+
|
| 223 |
+
核心字段建议:
|
| 224 |
+
|
| 225 |
+
- `record_id`
|
| 226 |
+
- `source_country`
|
| 227 |
+
- `trade_direction`
|
| 228 |
+
- `trade_date`
|
| 229 |
+
- `importer_name`
|
| 230 |
+
- `exporter_name`
|
| 231 |
+
- `shipper_name`
|
| 232 |
+
- `consignee_name`
|
| 233 |
+
- `origin_country`
|
| 234 |
+
- `destination_country`
|
| 235 |
+
- `departure_port`
|
| 236 |
+
- `arrival_port`
|
| 237 |
+
- `hs_code`
|
| 238 |
+
- `product_name`
|
| 239 |
+
- `quantity`
|
| 240 |
+
- `quantity_unit`
|
| 241 |
+
- `amount`
|
| 242 |
+
- `currency`
|
| 243 |
+
- `weight`
|
| 244 |
+
- `weight_unit`
|
| 245 |
+
- `transport_mode`
|
| 246 |
+
- `data_source`
|
| 247 |
+
- `source_record_id`
|
| 248 |
+
- `first_seen_at`
|
| 249 |
+
- `last_seen_at`
|
| 250 |
+
- `batch_no`
|
| 251 |
+
|
| 252 |
+
### 5.3 去重策略
|
| 253 |
+
|
| 254 |
+
不能只按单一字段去重,建议做组合指纹:
|
| 255 |
+
|
| 256 |
+
- 国家
|
| 257 |
+
- 日期
|
| 258 |
+
- 企业名
|
| 259 |
+
- HS 编码
|
| 260 |
+
- 商品描述
|
| 261 |
+
- 数量
|
| 262 |
+
- 金额
|
| 263 |
+
- 港口
|
| 264 |
+
|
| 265 |
+
对无法完全确定重复的数据,保留多版本并打疑似重复标记,不直接硬删。
|
| 266 |
+
|
| 267 |
+
## 6. 国家接入策略
|
| 268 |
+
|
| 269 |
+
### 6.1 接入优先级
|
| 270 |
+
|
| 271 |
+
MVP 不建议一上来做全球同时深挖,建议按三层推进:
|
| 272 |
+
|
| 273 |
+
- P0:高价值、高公开度、高稳定性国家
|
| 274 |
+
- P1:有公开源但解析复杂的国家
|
| 275 |
+
- P2:公开性差、需商业采购或特殊合规处理的国家
|
| 276 |
+
|
| 277 |
+
### 6.2 推荐第一批国家
|
| 278 |
+
|
| 279 |
+
优先考虑:
|
| 280 |
+
|
| 281 |
+
- 美国
|
| 282 |
+
- 印度
|
| 283 |
+
- 越南
|
| 284 |
+
- 巴西
|
| 285 |
+
- 墨西哥
|
| 286 |
+
- 印度尼西亚
|
| 287 |
+
- 土耳其
|
| 288 |
+
- 菲律宾
|
| 289 |
+
- 巴基斯坦
|
| 290 |
+
- 部分欧盟国家公开贸易统计源
|
| 291 |
+
|
| 292 |
+
选择标准:
|
| 293 |
+
|
| 294 |
+
- 对外贸业务价值高
|
| 295 |
+
- 数据有公开入口或稳定来源
|
| 296 |
+
- 更新频率可接受
|
| 297 |
+
- 字段较完整
|
| 298 |
+
|
| 299 |
+
### 6.3 国家适配器机制
|
| 300 |
+
|
| 301 |
+
每个国家一个独立适配器目录,包含:
|
| 302 |
+
|
| 303 |
+
- 源配置
|
| 304 |
+
- 抓取逻辑
|
| 305 |
+
- 解析逻辑
|
| 306 |
+
- 字段映射
|
| 307 |
+
- 增量规则
|
| 308 |
+
- 限流规则
|
| 309 |
+
- 异常处理
|
| 310 |
+
|
| 311 |
+
这样后续新增国家不会破坏主流程。
|
| 312 |
+
|
| 313 |
+
## 7. 更新机制设计
|
| 314 |
+
|
| 315 |
+
### 7.1 三类任务
|
| 316 |
+
|
| 317 |
+
- 历史回补任务
|
| 318 |
+
- 日常增量任务
|
| 319 |
+
- 修复重跑任务
|
| 320 |
+
|
| 321 |
+
### 7.2 更新策略
|
| 322 |
+
|
| 323 |
+
每个国家配置独立更新策略:
|
| 324 |
+
|
| 325 |
+
- 拉取周期
|
| 326 |
+
- 可补采窗口
|
| 327 |
+
- 单次抓取日期跨度
|
| 328 |
+
- 并发数
|
| 329 |
+
- 限速
|
| 330 |
+
- 失败重试次数
|
| 331 |
+
- 是否需要人工校验
|
| 332 |
+
|
| 333 |
+
### 7.3 及时更新的关键点
|
| 334 |
+
|
| 335 |
+
- 用“源发现器”先判断是否有新批次,避免盲目全量拉取。
|
| 336 |
+
- 对高频国家使用小时级轮询。
|
| 337 |
+
- 对低频国家用日级或周级调度。
|
| 338 |
+
- 增量失败时只重跑失败分片,不重跑整国全量。
|
| 339 |
+
- 每次入库都写入批次号和时间戳,便于校验延迟。
|
| 340 |
+
|
| 341 |
+
## 8. 合规与风控
|
| 342 |
+
|
| 343 |
+
必须单独设计,不要等上线前再补。
|
| 344 |
+
|
| 345 |
+
重点包括:
|
| 346 |
+
|
| 347 |
+
- 各国家数据源的使用条款
|
| 348 |
+
- robots 与访问频率限制
|
| 349 |
+
- 是否允许商业使用
|
| 350 |
+
- 是否包含个人敏感信息
|
| 351 |
+
- 是否需要脱敏展示
|
| 352 |
+
- 是否需要地区合规隔离
|
| 353 |
+
|
| 354 |
+
原则:
|
| 355 |
+
|
| 356 |
+
- 原始数据保留,但展示和对外提供要按合规规则做权限控制。
|
| 357 |
+
- 对来源不明确或授权不清晰的数据源,先标红,不进入正式商用流程。
|
| 358 |
+
|
| 359 |
+
## 9. MVP 范围建议
|
| 360 |
+
|
| 361 |
+
### 9.1 必做
|
| 362 |
+
|
| 363 |
+
- 国家适配器框架
|
| 364 |
+
- 10 个左右重点国家接入
|
| 365 |
+
- 原始数据存储
|
| 366 |
+
- 标准字段映射
|
| 367 |
+
- 去重机制
|
| 368 |
+
- 定时增量更新
|
| 369 |
+
- 查询 API
|
| 370 |
+
- 后台任务管理
|
| 371 |
+
- 更新状态看板
|
| 372 |
+
- 失败告警
|
| 373 |
+
|
| 374 |
+
### 9.2 暂缓
|
| 375 |
+
|
| 376 |
+
- 全量全球深度覆盖
|
| 377 |
+
- 复杂画像算法
|
| 378 |
+
- 智能推荐
|
| 379 |
+
- 高级 BI 分析
|
| 380 |
+
- 客户级权限体系
|
| 381 |
+
- 大规模多租户计费
|
| 382 |
+
|
| 383 |
+
## 10. 实施阶段建议
|
| 384 |
+
|
| 385 |
+
### 阶段 1:底座
|
| 386 |
+
|
| 387 |
+
- 统一数据模型
|
| 388 |
+
- 任务框架
|
| 389 |
+
- 原始数据存储
|
| 390 |
+
- 调度中心
|
| 391 |
+
- 查询 API 骨架
|
| 392 |
+
|
| 393 |
+
### 阶段 2:首批国家
|
| 394 |
+
|
| 395 |
+
- 接入 5 到 10 个高价值国家
|
| 396 |
+
- 打通历史补采与日常增量
|
| 397 |
+
- 建立质量看板
|
| 398 |
+
|
| 399 |
+
### 阶段 3:产品可用
|
| 400 |
+
|
| 401 |
+
- 搜索查询
|
| 402 |
+
- 企业维度筛选
|
| 403 |
+
- 商品维度筛选
|
| 404 |
+
- 时间维度筛选
|
| 405 |
+
- 导出能力
|
| 406 |
+
|
| 407 |
+
### 阶段 4:扩国家
|
| 408 |
+
|
| 409 |
+
- 按模板快速接入更多国家
|
| 410 |
+
- 按国家差异优化时效和质量
|
| 411 |
+
|
| 412 |
+
## 11. 推荐目录结构
|
| 413 |
+
|
| 414 |
+
```text
|
| 415 |
+
project/
|
| 416 |
+
apps/
|
| 417 |
+
api/
|
| 418 |
+
admin/
|
| 419 |
+
worker/
|
| 420 |
+
packages/
|
| 421 |
+
core/
|
| 422 |
+
connectors/
|
| 423 |
+
normalizers/
|
| 424 |
+
dictionaries/
|
| 425 |
+
shared/
|
| 426 |
+
infrastructure/
|
| 427 |
+
scheduler/
|
| 428 |
+
monitoring/
|
| 429 |
+
docs/
|
| 430 |
+
```
|
| 431 |
+
|
| 432 |
+
说明:
|
| 433 |
+
|
| 434 |
+
- `api`:查询接口与管理接口
|
| 435 |
+
- `admin`:后台
|
| 436 |
+
- `worker`:采集、清洗、入库任务
|
| 437 |
+
- `connectors`:国家适配器
|
| 438 |
+
- `normalizers`:标准化逻辑
|
| 439 |
+
- `dictionaries`:国家、港口、币种、单位、HS 映射
|
| 440 |
+
|
| 441 |
+
## 12. 最终建议
|
| 442 |
+
|
| 443 |
+
这个项目的核心,不是“先把页面做出来”,而是先把以下 4 个底座做稳:
|
| 444 |
+
|
| 445 |
+
- 国家适配器标准
|
| 446 |
+
- 原始数据留存
|
| 447 |
+
- 标准模型
|
| 448 |
+
- 调度与质量控制
|
| 449 |
+
|
| 450 |
+
如果这 4 个底座先做对,后面扩国家、提时效、做商业化会比较顺。
|
| 451 |
+
|
| 452 |
+
如果这 4 个底座先做错,后面国家越多,系统越难维护。
|
| 453 |
+
|
| 454 |
+
## 13. 架构设计补充建议(进阶增强)
|
| 455 |
+
|
| 456 |
+
作为架构视角的补充,为了保证系统在长期海量数据采集和存储下的稳定性与成本可控,建议在 MVP 阶段就将以下底层机制纳入设计(不一定立刻完整实现,但接口要留好):
|
| 457 |
+
|
| 458 |
+
### 13.1 动态代理池与反爬隔离 (Anti-Crawler & Proxy Pool)
|
| 459 |
+
- **痛点**:全球各国的海关官网或第三方源都有严格的访问频控和反爬机制(如 Cloudflare、滑块验证码)。
|
| 460 |
+
- **建议**:在 `connectors` 层下方封装统一的 `Request Client`。
|
| 461 |
+
- 必须剥离业务代码与网络请求代码。
|
| 462 |
+
- 接口原生支持动态切换代理池(住宅 IP、数据中心 IP)。
|
| 463 |
+
- 预留 JS 渲染(Playwright/Puppeteer)和打码平台对接能力,应对突发的反爬升级。
|
| 464 |
+
|
| 465 |
+
### 13.2 解析器版本控制与数据重放 (Schema Evolution)
|
| 466 |
+
- **痛点**:海关数据源的网页结构或 API 字段经常会毫无预警地变更,导致旧的解析器报错或解析出脏数据。
|
| 467 |
+
- **建议**:
|
| 468 |
+
- 每个国家的数据 `parser` 必须维护版本号(如 `v1.0.1`),并在落库的记录中带上 `parser_version`。
|
| 469 |
+
- 一旦发现解析错误或逻辑更新,由于原始数据(HTML/JSON)仍存放在对象存储中,只需升级 `parser` 版本,利用 `worker` 对指定的历史批次触发“重放解析”即可,无需重新请求对方接口。
|
| 470 |
+
|
| 471 |
+
### 13.3 数据血缘追踪 (Data Lineage)
|
| 472 |
+
- **痛点**:客户或内部运营发现某条标准数据的金额或企业名异常时,需要快速排查是源头本身错了,还是我们解析、汇率换算时出错了。
|
| 473 |
+
- **建议**:
|
| 474 |
+
- 标准表中的 `record_id` 必须能直接关联到 `source_record_id`。
|
| 475 |
+
- 通过 `batch_no` 直接在对象存储中定位到那一次抓取的原始响应切片,做到“所见即所得”的防甩锅和可追溯。
|
| 476 |
+
|
| 477 |
+
### 13.4 存储降本与冷热分离架构
|
| 478 |
+
- **痛点**:海关数据是典型的时间序列数据,越老的数据查询频率越低,但全量累积放在关系型数据库中成本极高且拖慢性能。
|
| 479 |
+
- **建议**:
|
| 480 |
+
- **热数据**(近 1-2 年):存 PG + OpenSearch,支持高性能多维检索。
|
| 481 |
+
- **温数据**(2-5 年):存 PG(按时间分区表),OpenSearch 剔除部分非核心索引或降低副本数。
|
| 482 |
+
- **冷数据**(5 年前):归档导出为 Parquet 格式存入 S3(对象存储),并通过类似 Athena/DuckDB 等机制按需查询,释放高昂的数据库内存。
|
| 483 |
+
|
| 484 |
+
## 14. MVP 阶段开发计划预估
|
| 485 |
+
|
| 486 |
+
为了保证项目可落地,建议将 MVP 切分为 4 个冲刺(Sprint),每个 Sprint 为期 1-2 周(具体根据投入人力调整)。核心原则是**“底层先行,串联验证,最后扩容”**。
|
| 487 |
+
|
| 488 |
+
### Sprint 1: 核心底座与架构初始化
|
| 489 |
+
**目标**:搭建工程骨架,跑通从“模拟抓取 -> 原始存储 -> 解析标准化 -> 关系型入库”的单链路。
|
| 490 |
+
- **任务 1**:环境搭建(Python + FastAPI + PostgreSQL + Redis 基础容器化)。
|
| 491 |
+
- **任务 2**:核心包(`packages/core`)开发,包括基础数据模型(ORM 定义)、统一日志、配置管理。
|
| 492 |
+
- **任务 3**:实现基础的 `Request Client`(支持代理接入机制)。
|
| 493 |
+
- **任务 4**:设计并实现第一个国家(如美国或巴西)的 Mock `Connector`,跑通下载和存储逻辑(打通 S3 或本地 Mock 存储)。
|
| 494 |
+
- **交付物**:可运行的工程脚手架、数据库表结构初始化脚本、单条链路的日志与存储验证。
|
| 495 |
+
|
| 496 |
+
### Sprint 2: 真实数据源接入与解析引擎
|
| 497 |
+
**目标**:接入 3 个真实高价值国家,完成核心字段标准化与清洗逻辑。
|
| 498 |
+
- **任务 1**:开发并调试 3 个首批国家(如美国、巴西、越南)的真实 `Connector`。
|
| 499 |
+
- **任务 2**:开发标准化组件(`packages/normalizers`),实现企业名初步清洗、日期格式化。
|
| 500 |
+
- **任务 3**:建立基础映射字典(`packages/dictionaries`),如国家代码(ISO 3166)、货币(ISO 4217)、单位转换。
|
| 501 |
+
- **任务 4**:实现血缘追踪机制(`source_record_id` 与 `batch_no` 绑定落库)。
|
| 502 |
+
- **交付物**:3 个国家的增量数据能稳定入库,并在 PG 中形成标准化的贸易记录。
|
| 503 |
+
|
| 504 |
+
### Sprint 3: 任务调度与质量监控
|
| 505 |
+
**目标**:脱离手动脚本,实现自动化、定时调度与异常告警。
|
| 506 |
+
- **任务 1**:引入调度器(Prefect / Celery),将 3 个国家的抓取与解析封装为定时任务。
|
| 507 |
+
- **任务 2**:实现历史回补与断点续跑逻辑(`checkpoint` 机制)。
|
| 508 |
+
- **任务 3**:开发数据质量监控模块(如缺失率统计、单次批次数量波动告警)。
|
| 509 |
+
- **任务 4**:对接飞书/钉钉/企业微信或邮件的异常通知。
|
| 510 |
+
- **交付物**:无人值守的自动化采集链路,具备失败重试与告警能力。
|
| 511 |
+
|
| 512 |
+
### Sprint 4: 服务层 API 与扩容准备
|
| 513 |
+
**目标**:提供对外的查询能力,并将国家扩容至首批 10 个。
|
| 514 |
+
- **任务 1**:开发 `apps/api` 查询接口,支持按企业、国家、HS 编码、时间段的多维查询。
|
| 515 |
+
- **任务 2**:接入剩余 7 个 P0 国家(印度、墨西哥等),验证并优化 `Connector` ���复用度。
|
| 516 |
+
- **任务 3**:引入 OpenSearch(如果 PG 的查询性能达到瓶颈),实现商品描述和企业名的模糊检索。
|
| 517 |
+
- **任务 4**:提供简单的后台看板接口(`apps/admin`),展示各国家最近一次更新时间与成功率。
|
| 518 |
+
- **交付物**:首批 10 国数据全自动化运转,提供标准查询 API,MVP 闭环完成。
|
| 519 |
+
|
| 520 |
+
## 历史数据回溯策略 (Backfill)
|
| 521 |
+
|
| 522 |
+
为了应对海关数据的时间跨度问题,系统内置了专门的历史数据回溯机制:
|
| 523 |
+
- **执行方式**:通过独立的回溯脚本(`apps/worker/run_backfill.py`)执行,不与日常增量定时任务混合,避免影响最新数据的获取时效。
|
| 524 |
+
- **拆分策略**:脚本按自然月切分时间窗口。如果用户要求回溯过去 5 年的数据,系统会自动将其切分为 60 个月的批次,分批请求海关源或公开数据集,防止单次请求超时或触发反爬。
|
| 525 |
+
- **数据对齐**:回溯抓取的数据与日常增量一样,必须经过标准化的 Pipeline(`save_raw` -> `parse` -> `normalize` -> `save_standard`)。这意味着 10 年前的旧公司名称也会被最新的企业名清洗字典(`normalizers/company.py`)清洗为现代标准名。
|
infrastructure/monitoring/__init__.py
ADDED
|
File without changes
|
infrastructure/monitoring/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (151 Bytes). View file
|
|
|
infrastructure/monitoring/__pycache__/alert.cpython-311.pyc
ADDED
|
Binary file (961 Bytes). View file
|
|
|
infrastructure/monitoring/__pycache__/quality.cpython-311.pyc
ADDED
|
Binary file (3.21 kB). View file
|
|
|
infrastructure/monitoring/alert.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from packages.core.logger import app_logger
|
| 2 |
+
|
| 3 |
+
def send_alert(title: str, message: str, level: str = "warning"):
|
| 4 |
+
"""
|
| 5 |
+
通用告警模块:
|
| 6 |
+
未来可在此处接入钉钉/飞书/企业微信机器人的 Webhook。
|
| 7 |
+
当前 MVP 阶段直接输出到高优先级日志。
|
| 8 |
+
"""
|
| 9 |
+
alert_msg = f"🚨 [ALERT - {level.upper()}] {title}: {message}"
|
| 10 |
+
|
| 11 |
+
if level == "critical" or level == "error":
|
| 12 |
+
app_logger.error(alert_msg)
|
| 13 |
+
else:
|
| 14 |
+
app_logger.warning(alert_msg)
|
| 15 |
+
|
| 16 |
+
# TODO: 实现 HTTP POST 调用飞书/钉钉 Webhook
|
infrastructure/monitoring/quality.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import select, func
|
| 2 |
+
from packages.core.database import AsyncSessionLocal
|
| 3 |
+
from packages.core.models import StandardTradeRecord
|
| 4 |
+
from packages.core.logger import app_logger
|
| 5 |
+
from infrastructure.monitoring.alert import send_alert
|
| 6 |
+
|
| 7 |
+
async def check_batch_quality(batch_no: str):
|
| 8 |
+
"""
|
| 9 |
+
数据质量巡检:
|
| 10 |
+
在每批次数据采集并标准化入库后执行,检查是否有大规模字段缺失或总量异常。
|
| 11 |
+
"""
|
| 12 |
+
app_logger.info(f"Starting quality check for batch: {batch_no}")
|
| 13 |
+
|
| 14 |
+
async with AsyncSessionLocal() as session:
|
| 15 |
+
# 1. 统计批次总记录数
|
| 16 |
+
total_stmt = select(func.count()).select_from(StandardTradeRecord).where(
|
| 17 |
+
StandardTradeRecord.batch_no == batch_no
|
| 18 |
+
)
|
| 19 |
+
total_result = await session.execute(total_stmt)
|
| 20 |
+
total = total_result.scalar() or 0
|
| 21 |
+
|
| 22 |
+
if total == 0:
|
| 23 |
+
send_alert("数据空跑告警", f"批次 {batch_no} 抓取入库的标准化记录数为 0!", level="error")
|
| 24 |
+
return
|
| 25 |
+
|
| 26 |
+
# 2. 统计 HS 编码缺失数
|
| 27 |
+
missing_hs_stmt = select(func.count()).select_from(StandardTradeRecord).where(
|
| 28 |
+
StandardTradeRecord.batch_no == batch_no,
|
| 29 |
+
(StandardTradeRecord.hs_code == None) | (StandardTradeRecord.hs_code == "")
|
| 30 |
+
)
|
| 31 |
+
missing_result = await session.execute(missing_hs_stmt)
|
| 32 |
+
missing_hs = missing_result.scalar() or 0
|
| 33 |
+
|
| 34 |
+
missing_rate = missing_hs / total
|
| 35 |
+
app_logger.info(f"Batch {batch_no} quality metrics - Total: {total}, Missing HS: {missing_hs} ({missing_rate:.2%})")
|
| 36 |
+
|
| 37 |
+
# 3. 触发阈值告警 (例如缺失率超过 30%)
|
| 38 |
+
if missing_rate > 0.3:
|
| 39 |
+
send_alert(
|
| 40 |
+
"字段大面积缺失告警",
|
| 41 |
+
f"批次 {batch_no} 的 HS 编码缺失率高达 {missing_rate:.2%} (阈值 30%),可能解析器失效或源端改版!",
|
| 42 |
+
level="warning"
|
| 43 |
+
)
|
infrastructure/scheduler/__init__.py
ADDED
|
File without changes
|
infrastructure/scheduler/main.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import sys
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
# 将项目根目录加入 sys.path
|
| 6 |
+
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
| 7 |
+
|
| 8 |
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
| 9 |
+
from packages.core.logger import app_logger
|
| 10 |
+
|
| 11 |
+
# 引入已有的采集任务
|
| 12 |
+
from apps.worker.run_mock import run_mock_job
|
| 13 |
+
from apps.worker.run_brazil import run_brazil_job
|
| 14 |
+
from apps.worker.run_extended import run_extended_mock_jobs
|
| 15 |
+
|
| 16 |
+
async def scheduled_us_job():
|
| 17 |
+
try:
|
| 18 |
+
app_logger.info("Triggering US Mock Sync Job via Scheduler...")
|
| 19 |
+
await run_mock_job()
|
| 20 |
+
except Exception as e:
|
| 21 |
+
app_logger.error(f"Scheduled US job failed: {e}")
|
| 22 |
+
|
| 23 |
+
async def scheduled_br_job():
|
| 24 |
+
try:
|
| 25 |
+
app_logger.info("Triggering Brazil Sync Job via Scheduler...")
|
| 26 |
+
await run_brazil_job()
|
| 27 |
+
except Exception as e:
|
| 28 |
+
app_logger.error(f"Scheduled BR job failed: {e}")
|
| 29 |
+
|
| 30 |
+
async def scheduled_extended_job():
|
| 31 |
+
try:
|
| 32 |
+
app_logger.info("Triggering Extended Countries Sync Job via Scheduler...")
|
| 33 |
+
await run_extended_mock_jobs()
|
| 34 |
+
except Exception as e:
|
| 35 |
+
app_logger.error(f"Scheduled Extended job failed: {e}")
|
| 36 |
+
|
| 37 |
+
def start_scheduler():
|
| 38 |
+
"""
|
| 39 |
+
启动任务调度中心
|
| 40 |
+
"""
|
| 41 |
+
scheduler = AsyncIOScheduler()
|
| 42 |
+
|
| 43 |
+
# MVP 阶段用于演示,设置较短的定时:
|
| 44 |
+
scheduler.add_job(scheduled_us_job, 'interval', minutes=2, id="sync_us_data")
|
| 45 |
+
scheduler.add_job(scheduled_br_job, 'interval', minutes=3, id="sync_br_data")
|
| 46 |
+
# 新增的 7 个国家,每 4 分钟跑一次批处理
|
| 47 |
+
scheduler.add_job(scheduled_extended_job, 'interval', minutes=4, id="sync_extended_data")
|
| 48 |
+
|
| 49 |
+
scheduler.start()
|
| 50 |
+
app_logger.info("Scheduler started successfully. Waiting for jobs to execute...")
|
| 51 |
+
|
| 52 |
+
# 保持主线程事件循环不退出
|
| 53 |
+
try:
|
| 54 |
+
asyncio.get_event_loop().run_forever()
|
| 55 |
+
except (KeyboardInterrupt, SystemExit):
|
| 56 |
+
app_logger.info("Scheduler shutting down...")
|
| 57 |
+
|
| 58 |
+
if __name__ == "__main__":
|
| 59 |
+
start_scheduler()
|
init_db.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from sqlalchemy.ext.asyncio import create_async_engine
|
| 3 |
+
from packages.core.config import settings
|
| 4 |
+
from packages.core.models import Base
|
| 5 |
+
|
| 6 |
+
async def init_models():
|
| 7 |
+
# 使用 PostgreSQL DSN
|
| 8 |
+
engine = create_async_engine(settings.DATABASE_URL, echo=True)
|
| 9 |
+
|
| 10 |
+
# 因为有 PostGIS 扩展依赖,最好确保扩展已被创建
|
| 11 |
+
# 正常 postgis image 默认在 public 下已有 postgis
|
| 12 |
+
|
| 13 |
+
async with engine.begin() as conn:
|
| 14 |
+
# 删除所有旧表(如果是全新项目,这里清理掉之前海关项目的旧表)
|
| 15 |
+
await conn.run_sync(Base.metadata.drop_all)
|
| 16 |
+
# 创建所有新表
|
| 17 |
+
await conn.run_sync(Base.metadata.create_all)
|
| 18 |
+
|
| 19 |
+
print("数据库表结构初始化成功!")
|
| 20 |
+
|
| 21 |
+
if __name__ == "__main__":
|
| 22 |
+
asyncio.run(init_models())
|
logs/app_2026-05-29.log
ADDED
|
File without changes
|
logs/app_2026-05-30.log
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
logs/app_2026-05-31.log
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
logs/app_2026-06-01.log
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
logs/app_2026-06-02.log
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
packages/__init__.py
ADDED
|
File without changes
|
packages/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (134 Bytes). View file
|
|
|
packages/connectors/__pycache__/base.cpython-311.pyc
ADDED
|
Binary file (10 kB). View file
|
|
|
packages/connectors/__pycache__/brazil.cpython-311.pyc
ADDED
|
Binary file (6.98 kB). View file
|
|
|
packages/connectors/base.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import json
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from typing import List, Dict, Any
|
| 6 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
+
from packages.core.logger import app_logger
|
| 8 |
+
from packages.core.models import RawTradeRecord, StandardTradeRecord
|
| 9 |
+
|
| 10 |
+
class BaseConnector:
|
| 11 |
+
"""
|
| 12 |
+
国家数据源适配器基类,规定了标准执行流程:
|
| 13 |
+
discover -> fetch -> parse -> save_raw -> normalize -> save_standard
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
country_code: str = "UNKNOWN"
|
| 17 |
+
source_system: str = "UNKNOWN"
|
| 18 |
+
parser_version: str = "1.0.0"
|
| 19 |
+
|
| 20 |
+
def __init__(self, session: AsyncSession):
|
| 21 |
+
self.session = session
|
| 22 |
+
self.batch_no = f"{self.country_code}_{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}"
|
| 23 |
+
|
| 24 |
+
async def discover(self) -> List[Any]:
|
| 25 |
+
"""发现新数据批次或需要抓取的任务切片"""
|
| 26 |
+
raise NotImplementedError
|
| 27 |
+
|
| 28 |
+
async def fetch(self, task_slice: Any) -> Dict[str, Any]:
|
| 29 |
+
"""执行抓取并返回原始数据"""
|
| 30 |
+
raise NotImplementedError
|
| 31 |
+
|
| 32 |
+
async def parse(self, raw_data: Any) -> List[Dict[str, Any]]:
|
| 33 |
+
"""
|
| 34 |
+
[子类必须实现]
|
| 35 |
+
解析并清洗原始数据响应,提取出需要留存为 RawTradeRecord 的记录列表
|
| 36 |
+
注意:此时不关联数据库模型,仅返回字典列表
|
| 37 |
+
"""
|
| 38 |
+
raise NotImplementedError
|
| 39 |
+
|
| 40 |
+
async def save_raw(self, raw_items: List[Dict[str, Any]]) -> List[RawTradeRecord]:
|
| 41 |
+
"""将原始数据留存入库,返回 RawTradeRecord 实例列表用于血缘追踪"""
|
| 42 |
+
raw_records = []
|
| 43 |
+
for item in raw_items:
|
| 44 |
+
raw_json_str = json.dumps(item, ensure_ascii=False, sort_keys=True)
|
| 45 |
+
hash_fp = hashlib.sha256(raw_json_str.encode('utf-8')).hexdigest()
|
| 46 |
+
|
| 47 |
+
record = RawTradeRecord(
|
| 48 |
+
id=str(uuid.uuid4()),
|
| 49 |
+
batch_no=self.batch_no,
|
| 50 |
+
source_country=self.country_code,
|
| 51 |
+
source_system=self.source_system,
|
| 52 |
+
raw_json=item,
|
| 53 |
+
content_hash=hash_fp
|
| 54 |
+
)
|
| 55 |
+
raw_records.append(record)
|
| 56 |
+
|
| 57 |
+
if raw_records:
|
| 58 |
+
# 为防止重跑导致内容指纹重复报错,MVP 阶段忽略冲突
|
| 59 |
+
from sqlalchemy.dialects.postgresql import insert
|
| 60 |
+
|
| 61 |
+
# 首先根据 content_hash 查询已经存在的记录
|
| 62 |
+
hashes = [r.content_hash for r in raw_records]
|
| 63 |
+
from sqlalchemy import select
|
| 64 |
+
stmt = select(RawTradeRecord).where(RawTradeRecord.content_hash.in_(hashes))
|
| 65 |
+
result = await self.session.execute(stmt)
|
| 66 |
+
existing_records = {r.content_hash: r.id for r in result.scalars()}
|
| 67 |
+
|
| 68 |
+
# 更新已存在记录的 ID,对于不存在的插入
|
| 69 |
+
to_insert = []
|
| 70 |
+
for r in raw_records:
|
| 71 |
+
if r.content_hash in existing_records:
|
| 72 |
+
r.id = existing_records[r.content_hash]
|
| 73 |
+
else:
|
| 74 |
+
to_insert.append({
|
| 75 |
+
"id": r.id,
|
| 76 |
+
"batch_no": r.batch_no,
|
| 77 |
+
"source_country": r.source_country,
|
| 78 |
+
"source_system": r.source_system,
|
| 79 |
+
"raw_json": r.raw_json,
|
| 80 |
+
"content_hash": r.content_hash
|
| 81 |
+
})
|
| 82 |
+
|
| 83 |
+
if to_insert:
|
| 84 |
+
stmt = insert(RawTradeRecord).values(to_insert).on_conflict_do_nothing(index_elements=["content_hash"])
|
| 85 |
+
await self.session.execute(stmt)
|
| 86 |
+
await self.session.flush()
|
| 87 |
+
|
| 88 |
+
return raw_records
|
| 89 |
+
|
| 90 |
+
async def normalize(self, raw: RawTradeRecord) -> StandardTradeRecord:
|
| 91 |
+
"""
|
| 92 |
+
[子类必须实现]
|
| 93 |
+
将单条原始数据记录(含 DB ID)转换为标准数据记录模型
|
| 94 |
+
"""
|
| 95 |
+
raise NotImplementedError
|
| 96 |
+
|
| 97 |
+
async def save_standard(self, std_records: List[StandardTradeRecord]):
|
| 98 |
+
"""保存标准数据"""
|
| 99 |
+
if std_records:
|
| 100 |
+
from sqlalchemy.dialects.postgresql import insert
|
| 101 |
+
stmt = insert(StandardTradeRecord).values([{
|
| 102 |
+
"record_id": r.record_id,
|
| 103 |
+
"source_record_id": r.source_record_id,
|
| 104 |
+
"batch_no": r.batch_no,
|
| 105 |
+
"source_country": r.source_country,
|
| 106 |
+
"trade_direction": r.trade_direction,
|
| 107 |
+
"trade_date": r.trade_date,
|
| 108 |
+
"importer_name": r.importer_name,
|
| 109 |
+
"exporter_name": r.exporter_name,
|
| 110 |
+
"hs_code": r.hs_code,
|
| 111 |
+
"product_name": r.product_name,
|
| 112 |
+
"amount": r.amount,
|
| 113 |
+
"currency": r.currency,
|
| 114 |
+
"weight": r.weight,
|
| 115 |
+
"weight_unit": r.weight_unit,
|
| 116 |
+
"origin_country": r.origin_country,
|
| 117 |
+
"destination_country": r.destination_country,
|
| 118 |
+
"departure_port": r.departure_port,
|
| 119 |
+
"arrival_port": r.arrival_port,
|
| 120 |
+
"transport_mode": r.transport_mode
|
| 121 |
+
} for r in std_records]).on_conflict_do_nothing(index_elements=["record_id"])
|
| 122 |
+
|
| 123 |
+
await self.session.execute(stmt)
|
| 124 |
+
await self.session.commit()
|
| 125 |
+
|
| 126 |
+
async def run(self):
|
| 127 |
+
"""执行完整链路"""
|
| 128 |
+
app_logger.info(f"[{self.country_code}] Starting job, batch_no: {self.batch_no}")
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
task_slices = await self.discover()
|
| 132 |
+
for ts in task_slices:
|
| 133 |
+
raw_data = await self.fetch(ts)
|
| 134 |
+
parsed_items = await self.parse(raw_data)
|
| 135 |
+
|
| 136 |
+
if not isinstance(parsed_items, list):
|
| 137 |
+
parsed_items = [parsed_items]
|
| 138 |
+
|
| 139 |
+
# 1. 留存原始数据
|
| 140 |
+
raw_records = await self.save_raw(parsed_items)
|
| 141 |
+
app_logger.info(f"[{self.country_code}] Saved {len(raw_records)} raw records")
|
| 142 |
+
|
| 143 |
+
# 2. 数据标准化与血缘绑定
|
| 144 |
+
std_records = []
|
| 145 |
+
for raw in raw_records:
|
| 146 |
+
std_rec = await self.normalize(raw)
|
| 147 |
+
std_records.append(std_rec)
|
| 148 |
+
|
| 149 |
+
# 3. 保存标准数据
|
| 150 |
+
await self.save_standard(std_records)
|
| 151 |
+
app_logger.info(f"[{self.country_code}] Saved {len(std_records)} standard records")
|
| 152 |
+
|
| 153 |
+
# 4. 触发数据质量巡检
|
| 154 |
+
from infrastructure.monitoring.quality import check_batch_quality
|
| 155 |
+
await check_batch_quality(self.batch_no)
|
| 156 |
+
|
| 157 |
+
app_logger.info(f"[{self.country_code}] Job completed successfully")
|
| 158 |
+
|
| 159 |
+
except Exception as e:
|
| 160 |
+
await self.session.rollback()
|
| 161 |
+
app_logger.error(f"[{self.country_code}] Job failed: {str(e)}")
|
| 162 |
+
raise e
|
packages/connectors/brazil.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from packages.connectors.base import BaseConnector
|
| 5 |
+
from packages.core.models import RawTradeRecord, StandardTradeRecord
|
| 6 |
+
from packages.dictionaries.brazil import CURRENCY_MAP, UNIT_MAP, TRANSPORT_MODE_MAP
|
| 7 |
+
from packages.normalizers.company import clean_company_name
|
| 8 |
+
|
| 9 |
+
class BrazilComexStatConnector(BaseConnector):
|
| 10 |
+
"""
|
| 11 |
+
巴西 Comex Stat 官方统计数据接入适配器
|
| 12 |
+
这是一个基于 API / CSV 的真实数据源结构模拟
|
| 13 |
+
"""
|
| 14 |
+
country_code = "BR"
|
| 15 |
+
source_system = "COMEX_STAT_API"
|
| 16 |
+
parser_version = "v1.1.0"
|
| 17 |
+
|
| 18 |
+
async def discover(self) -> List[Any]:
|
| 19 |
+
# 模拟发现 2024 年 5 月的数据批次
|
| 20 |
+
return [{"year": 2024, "month": 5}]
|
| 21 |
+
|
| 22 |
+
async def fetch(self, task_slice: Any) -> Dict[str, Any]:
|
| 23 |
+
# 模拟调用巴西统计局接口返回的原始数据结构(葡萄牙语字段)
|
| 24 |
+
# 真实情况这里会使用 self.http_client 进行 requests
|
| 25 |
+
import random
|
| 26 |
+
from datetime import datetime, timedelta
|
| 27 |
+
|
| 28 |
+
random_days_ago_1 = random.randint(0, 365 * 10)
|
| 29 |
+
simulated_date_1 = (datetime.now() - timedelta(days=random_days_ago_1)).strftime("%Y-%m-%d")
|
| 30 |
+
|
| 31 |
+
random_days_ago_2 = random.randint(0, 365 * 10)
|
| 32 |
+
simulated_date_2 = (datetime.now() - timedelta(days=random_days_ago_2)).strftime("%Y-%m-%d")
|
| 33 |
+
|
| 34 |
+
return {
|
| 35 |
+
"metadata": {
|
| 36 |
+
"status": "success",
|
| 37 |
+
"period": f"{task_slice['year']}-{task_slice['month']:02d}"
|
| 38 |
+
},
|
| 39 |
+
"data": [
|
| 40 |
+
{
|
| 41 |
+
"id_registro": "BR-202405-001",
|
| 42 |
+
"data_registro": simulated_date_1,
|
| 43 |
+
"tipo_operacao": "IMPORTACAO", # 进口
|
| 44 |
+
"cnpj_importador": "00.000.000/0001-91",
|
| 45 |
+
"nome_importador": "BANCO DO BRASIL S.A.",
|
| 46 |
+
"nome_exportador": "IBM CORPORATION",
|
| 47 |
+
"ncm_codigo": "8471.30.12", # HS 编码在巴西叫 NCM
|
| 48 |
+
"descricao_mercadoria": "MAQUINAS AUTOMATICAS PARA PROCESSAMENTO DE DADOS",
|
| 49 |
+
"valor_fob": "1500000.00",
|
| 50 |
+
"moeda": "DÓLAR",
|
| 51 |
+
"peso_liquido": "2000",
|
| 52 |
+
"unidade_medida": "QUILOGRAMA",
|
| 53 |
+
"pais_origem": "US",
|
| 54 |
+
"porto_desembarque": "SANTOS",
|
| 55 |
+
"via_transporte": "MARÍTIMO"
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
"id_registro": "BR-202405-002",
|
| 59 |
+
"data_registro": simulated_date_2,
|
| 60 |
+
"tipo_operacao": "EXPORTACAO", # 出口
|
| 61 |
+
"cnpj_exportador": "11.111.111/0001-11",
|
| 62 |
+
"nome_exportador": "VALE S.A.",
|
| 63 |
+
"nome_importador": "BAOSTEEL GROUP",
|
| 64 |
+
"ncm_codigo": "2601.11.00",
|
| 65 |
+
"descricao_mercadoria": "MINERIOS DE FERRO E SEUS CONCENTRADOS",
|
| 66 |
+
"valor_fob": "8500000.00",
|
| 67 |
+
"moeda": "DÓLAR",
|
| 68 |
+
"peso_liquido": "150000",
|
| 69 |
+
"unidade_medida": "TONELADA",
|
| 70 |
+
"pais_destino": "CN",
|
| 71 |
+
"porto_embarque": "VITORIA",
|
| 72 |
+
"via_transporte": "MARÍTIMO"
|
| 73 |
+
}
|
| 74 |
+
]
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
async def parse(self, raw_data: Any) -> List[Dict[str, Any]]:
|
| 78 |
+
# 实际的巴西接口返回的是一个字典 {"data": [...]},或者列表
|
| 79 |
+
if isinstance(raw_data, dict):
|
| 80 |
+
return raw_data.get("data", [])
|
| 81 |
+
elif isinstance(raw_data, list):
|
| 82 |
+
return raw_data
|
| 83 |
+
else:
|
| 84 |
+
return []
|
| 85 |
+
|
| 86 |
+
async def normalize(self, raw: RawTradeRecord) -> StandardTradeRecord:
|
| 87 |
+
data = raw.raw_json
|
| 88 |
+
|
| 89 |
+
# 判断贸易方向
|
| 90 |
+
op_type = data.get("tipo_operacao", "").upper()
|
| 91 |
+
trade_direction = "import" if op_type == "IMPORTACAO" else "export"
|
| 92 |
+
|
| 93 |
+
# 解析企业名并清洗
|
| 94 |
+
if trade_direction == "import":
|
| 95 |
+
importer = data.get("nome_importador", "")
|
| 96 |
+
exporter = data.get("nome_exportador", "")
|
| 97 |
+
else:
|
| 98 |
+
importer = data.get("nome_importador", "")
|
| 99 |
+
exporter = data.get("nome_exportador", "")
|
| 100 |
+
|
| 101 |
+
importer_clean = clean_company_name(importer)
|
| 102 |
+
exporter_clean = clean_company_name(exporter)
|
| 103 |
+
|
| 104 |
+
# 字典映射
|
| 105 |
+
currency = CURRENCY_MAP.get(data.get("moeda", "").upper(), "UNKNOWN")
|
| 106 |
+
weight_unit = UNIT_MAP.get(data.get("unidade_medida", "").upper(), "UNKNOWN")
|
| 107 |
+
transport_mode = TRANSPORT_MODE_MAP.get(data.get("via_transporte", "").upper(), "UNKNOWN")
|
| 108 |
+
|
| 109 |
+
record_id = str(uuid.uuid4())
|
| 110 |
+
|
| 111 |
+
return StandardTradeRecord(
|
| 112 |
+
record_id=record_id,
|
| 113 |
+
source_record_id=raw.id,
|
| 114 |
+
batch_no=raw.batch_no,
|
| 115 |
+
source_country=self.country_code,
|
| 116 |
+
trade_direction=trade_direction,
|
| 117 |
+
trade_date=datetime.strptime(data.get("data_registro"), "%Y-%m-%d"),
|
| 118 |
+
importer_name=importer_clean,
|
| 119 |
+
exporter_name=exporter_clean,
|
| 120 |
+
hs_code=data.get("ncm_codigo", "").replace(".", ""), # 巴西 NCM 带点,标准化去掉
|
| 121 |
+
product_name=data.get("descricao_mercadoria"),
|
| 122 |
+
amount=float(data.get("valor_fob", 0)),
|
| 123 |
+
currency=currency,
|
| 124 |
+
weight=float(data.get("peso_liquido", 0)),
|
| 125 |
+
weight_unit=weight_unit,
|
| 126 |
+
origin_country=data.get("pais_origem") if trade_direction == "import" else self.country_code,
|
| 127 |
+
destination_country=data.get("pais_destino") if trade_direction == "export" else self.country_code,
|
| 128 |
+
departure_port=data.get("porto_embarque"),
|
| 129 |
+
arrival_port=data.get("porto_desembarque"),
|
| 130 |
+
transport_mode=transport_mode
|
| 131 |
+
)
|
packages/connectors/mock/__pycache__/extended_mock.cpython-311.pyc
ADDED
|
Binary file (5.96 kB). View file
|
|
|
packages/connectors/mock/__pycache__/us_mock.cpython-311.pyc
ADDED
|
Binary file (4.36 kB). View file
|
|
|
packages/connectors/mock/extended_mock.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
import random
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from packages.connectors.base import BaseConnector
|
| 5 |
+
|
| 6 |
+
# 新国家的模拟配置字典
|
| 7 |
+
COUNTRY_CONFIGS = {
|
| 8 |
+
"ID": {"currency": "IDR", "port": "JAKARTA", "importers": ["PT. ASTRA", "PT. INDOFOOD", "PT. TELEKOMUNIKASI"]},
|
| 9 |
+
"TH": {"currency": "THB", "port": "BANGKOK", "importers": ["CP GROUP", "PTT PCL", "SIAM CEMENT"]},
|
| 10 |
+
"VN": {"currency": "VND", "port": "HO CHI MINH", "importers": ["VINAMILK", "VIETTEL", "PETROVIETNAM"]},
|
| 11 |
+
"MY": {"currency": "MYR", "port": "PORT KLANG", "importers": ["PETRONAS", "MAYBANK", "TENAGA NASIONAL"]},
|
| 12 |
+
"NZ": {"currency": "NZD", "port": "AUCKLAND", "importers": ["FONTERRA", "AIR NEW ZEALAND", "FLETCHER BUILDING"]},
|
| 13 |
+
"AE": {"currency": "AED", "port": "DUBAI", "importers": ["EMIRATES", "DP WORLD", "EMAAR PROPERTIES"]},
|
| 14 |
+
"DE": {"currency": "EUR", "port": "HAMBURG", "importers": ["VOLKSWAGEN", "SIEMENS", "BOSCH"]}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
from packages.core.models import RawTradeRecord, StandardTradeRecord
|
| 18 |
+
|
| 19 |
+
class ExtendedMockConnector(BaseConnector):
|
| 20 |
+
"""
|
| 21 |
+
通用模拟数据适配器,支持根据传入的国家代码动态生成该国的模拟数据
|
| 22 |
+
"""
|
| 23 |
+
def __init__(self, country_code: str, session):
|
| 24 |
+
super().__init__(session=session)
|
| 25 |
+
self.country_code = country_code
|
| 26 |
+
self.source_system = f"Mock_{country_code}_Customs"
|
| 27 |
+
self.config = COUNTRY_CONFIGS[country_code]
|
| 28 |
+
|
| 29 |
+
async def discover(self) -> list:
|
| 30 |
+
# 每次模拟发现 2-5 条新数据
|
| 31 |
+
count = random.randint(2, 5)
|
| 32 |
+
return [{"id": str(uuid.uuid4())} for _ in range(count)]
|
| 33 |
+
|
| 34 |
+
async def parse(self, raw_data: dict) -> dict:
|
| 35 |
+
return raw_data
|
| 36 |
+
|
| 37 |
+
async def fetch(self, tasks: list) -> list:
|
| 38 |
+
results = []
|
| 39 |
+
for t in tasks:
|
| 40 |
+
# tasks 里如果是一维数组的字典
|
| 41 |
+
item_id = t.get("id") if isinstance(t, dict) else t
|
| 42 |
+
|
| 43 |
+
# 模拟生成过去 10 年内随机某一天的数据
|
| 44 |
+
random_days_ago = random.randint(0, 365 * 10)
|
| 45 |
+
simulated_date = (datetime.now() - timedelta(days=random_days_ago)).strftime("%Y-%m-%d")
|
| 46 |
+
|
| 47 |
+
results.append({
|
| 48 |
+
"id": item_id,
|
| 49 |
+
"trade_type": random.choice(["import", "export"]),
|
| 50 |
+
"date": simulated_date,
|
| 51 |
+
"importer": random.choice(self.config["importers"]),
|
| 52 |
+
"exporter": "GLOBAL TRADING LLC",
|
| 53 |
+
"hs": f"8471{random.randint(1000, 9999)}",
|
| 54 |
+
"desc": "COMMERCIAL GOODS / MACHINERY",
|
| 55 |
+
"value": random.randint(10000, 500000),
|
| 56 |
+
"curr": self.config["currency"],
|
| 57 |
+
"qty": random.randint(1000, 5000),
|
| 58 |
+
"unit": "KG",
|
| 59 |
+
"port": self.config["port"]
|
| 60 |
+
})
|
| 61 |
+
return results
|
| 62 |
+
|
| 63 |
+
async def normalize(self, raw: RawTradeRecord) -> StandardTradeRecord:
|
| 64 |
+
data = raw.raw_json
|
| 65 |
+
is_import = data["trade_type"] == "import"
|
| 66 |
+
|
| 67 |
+
return StandardTradeRecord(
|
| 68 |
+
record_id=str(uuid.uuid4()),
|
| 69 |
+
source_record_id=raw.id,
|
| 70 |
+
batch_no=raw.batch_no,
|
| 71 |
+
source_country=self.country_code,
|
| 72 |
+
trade_direction=data["trade_type"],
|
| 73 |
+
trade_date=datetime.strptime(data["date"], "%Y-%m-%d"),
|
| 74 |
+
importer_name=data["importer"],
|
| 75 |
+
exporter_name=data["exporter"],
|
| 76 |
+
hs_code=data["hs"],
|
| 77 |
+
product_name=data["desc"],
|
| 78 |
+
amount=float(data["value"]),
|
| 79 |
+
currency=data["curr"],
|
| 80 |
+
weight=float(data["qty"]),
|
| 81 |
+
weight_unit=data["unit"],
|
| 82 |
+
origin_country="CN" if is_import else self.country_code,
|
| 83 |
+
destination_country=self.country_code if is_import else "US",
|
| 84 |
+
departure_port="SHANGHAI" if is_import else data["port"],
|
| 85 |
+
arrival_port=data["port"] if is_import else "LOS ANGELES",
|
| 86 |
+
transport_mode="SEA"
|
| 87 |
+
)
|
packages/connectors/mock/us_mock.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Dict, Any
|
| 2 |
+
import uuid
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from packages.connectors.base import BaseConnector
|
| 5 |
+
from packages.core.models import RawTradeRecord, StandardTradeRecord
|
| 6 |
+
|
| 7 |
+
class MockUSConnector(BaseConnector):
|
| 8 |
+
"""
|
| 9 |
+
模拟美国海关提单数据接入
|
| 10 |
+
用于跑通 MVP 底层链路,不发起真实网络请求
|
| 11 |
+
"""
|
| 12 |
+
country_code = "US"
|
| 13 |
+
source_system = "US_CBP_MOCK"
|
| 14 |
+
parser_version = "v1.0.0"
|
| 15 |
+
|
| 16 |
+
async def discover(self) -> List[Any]:
|
| 17 |
+
# 模拟发现了一个日期的切片任务
|
| 18 |
+
return ["2024-05-28"]
|
| 19 |
+
|
| 20 |
+
async def fetch(self, task_slice: Any) -> Dict[str, Any]:
|
| 21 |
+
import random
|
| 22 |
+
from datetime import timedelta
|
| 23 |
+
# 模拟抓取过程,返回包含 10 年内随机时间的原始 JSON 数据
|
| 24 |
+
random_days_ago_1 = random.randint(0, 365 * 10)
|
| 25 |
+
simulated_date_1 = (datetime.now() - timedelta(days=random_days_ago_1)).strftime("%Y-%m-%d")
|
| 26 |
+
|
| 27 |
+
random_days_ago_2 = random.randint(0, 365 * 10)
|
| 28 |
+
simulated_date_2 = (datetime.now() - timedelta(days=random_days_ago_2)).strftime("%Y-%m-%d")
|
| 29 |
+
|
| 30 |
+
return {
|
| 31 |
+
"status": "ok",
|
| 32 |
+
"date": task_slice,
|
| 33 |
+
"results": [
|
| 34 |
+
{
|
| 35 |
+
"id": "B/L-1001",
|
| 36 |
+
"consignee": "APPLE INC.",
|
| 37 |
+
"shipper": "FOXCONN TECH",
|
| 38 |
+
"description": "SMARTPHONES",
|
| 39 |
+
"qty": "1000",
|
| 40 |
+
"qty_unit": "PCS",
|
| 41 |
+
"port_of_lading": "SHENZHEN",
|
| 42 |
+
"port_of_unlading": "LOS ANGELES",
|
| 43 |
+
"date": simulated_date_1
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"id": "B/L-1002",
|
| 47 |
+
"consignee": "TESLA MOTORS",
|
| 48 |
+
"shipper": "PANASONIC",
|
| 49 |
+
"description": "LITHIUM ION BATTERIES",
|
| 50 |
+
"qty": "5000",
|
| 51 |
+
"qty_unit": "KGS",
|
| 52 |
+
"port_of_lading": "OSAKA",
|
| 53 |
+
"port_of_unlading": "SAN FRANCISCO",
|
| 54 |
+
"date": simulated_date_2
|
| 55 |
+
}
|
| 56 |
+
]
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
async def parse(self, raw_data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
| 60 |
+
# 从原始响应中提取需要保存的列表
|
| 61 |
+
return raw_data.get("results", [])
|
| 62 |
+
|
| 63 |
+
async def normalize(self, raw: RawTradeRecord) -> StandardTradeRecord:
|
| 64 |
+
data = raw.raw_json
|
| 65 |
+
|
| 66 |
+
# 模拟企业名基础清洗
|
| 67 |
+
importer = data.get("consignee", "").strip().upper()
|
| 68 |
+
exporter = data.get("shipper", "").strip().upper()
|
| 69 |
+
|
| 70 |
+
# 生成全局业务主键
|
| 71 |
+
record_id = str(uuid.uuid4())
|
| 72 |
+
|
| 73 |
+
return StandardTradeRecord(
|
| 74 |
+
record_id=record_id,
|
| 75 |
+
source_record_id=raw.id, # 血缘追踪绑定
|
| 76 |
+
batch_no=raw.batch_no,
|
| 77 |
+
source_country=self.country_code,
|
| 78 |
+
trade_direction="import",
|
| 79 |
+
trade_date=datetime.strptime(data.get("date"), "%Y-%m-%d"),
|
| 80 |
+
importer_name=importer,
|
| 81 |
+
exporter_name=exporter,
|
| 82 |
+
product_name=data.get("description"),
|
| 83 |
+
weight=float(data.get("qty", 0)),
|
| 84 |
+
weight_unit=data.get("qty_unit"),
|
| 85 |
+
departure_port=data.get("port_of_lading"),
|
| 86 |
+
arrival_port=data.get("port_of_unlading")
|
| 87 |
+
)
|
packages/core/__init__.py
ADDED
|
File without changes
|
packages/core/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (139 Bytes). View file
|
|
|
packages/core/__pycache__/config.cpython-311.pyc
ADDED
|
Binary file (1.41 kB). View file
|
|
|
packages/core/__pycache__/database.cpython-311.pyc
ADDED
|
Binary file (1.48 kB). View file
|
|
|