3v324v23 commited on
Commit
3834ddb
·
0 Parent(s):

Initial commit for Hugging Face Space

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +22 -0
  2. apps/__init__.py +0 -0
  3. apps/api/__pycache__/main.cpython-311.pyc +0 -0
  4. apps/api/__pycache__/main.cpython-314.pyc +0 -0
  5. apps/api/main.py +45 -0
  6. apps/api/routers/__pycache__/trade.cpython-311.pyc +0 -0
  7. apps/api/routers/trade.py +55 -0
  8. apps/api/schemas/__pycache__/trade.cpython-311.pyc +0 -0
  9. apps/api/schemas/trade.py +46 -0
  10. apps/api/static/index.html +219 -0
  11. apps/worker/__init__.py +0 -0
  12. apps/worker/__pycache__/__init__.cpython-311.pyc +0 -0
  13. apps/worker/__pycache__/run_brazil.cpython-311.pyc +0 -0
  14. apps/worker/__pycache__/run_extended.cpython-311.pyc +0 -0
  15. apps/worker/__pycache__/run_mock.cpython-311.pyc +0 -0
  16. apps/worker/run_backfill.py +70 -0
  17. apps/worker/run_brazil.py +21 -0
  18. apps/worker/run_extended.py +24 -0
  19. apps/worker/run_mock.py +35 -0
  20. docker-compose.yml +46 -0
  21. docs/国家与数据源接入清单.md +159 -0
  22. docs/海关数据项目-MVP方案.md +525 -0
  23. infrastructure/monitoring/__init__.py +0 -0
  24. infrastructure/monitoring/__pycache__/__init__.cpython-311.pyc +0 -0
  25. infrastructure/monitoring/__pycache__/alert.cpython-311.pyc +0 -0
  26. infrastructure/monitoring/__pycache__/quality.cpython-311.pyc +0 -0
  27. infrastructure/monitoring/alert.py +16 -0
  28. infrastructure/monitoring/quality.py +43 -0
  29. infrastructure/scheduler/__init__.py +0 -0
  30. infrastructure/scheduler/main.py +59 -0
  31. init_db.py +22 -0
  32. logs/app_2026-05-29.log +0 -0
  33. logs/app_2026-05-30.log +0 -0
  34. logs/app_2026-05-31.log +0 -0
  35. logs/app_2026-06-01.log +0 -0
  36. logs/app_2026-06-02.log +0 -0
  37. packages/__init__.py +0 -0
  38. packages/__pycache__/__init__.cpython-311.pyc +0 -0
  39. packages/connectors/__pycache__/base.cpython-311.pyc +0 -0
  40. packages/connectors/__pycache__/brazil.cpython-311.pyc +0 -0
  41. packages/connectors/base.py +162 -0
  42. packages/connectors/brazil.py +131 -0
  43. packages/connectors/mock/__pycache__/extended_mock.cpython-311.pyc +0 -0
  44. packages/connectors/mock/__pycache__/us_mock.cpython-311.pyc +0 -0
  45. packages/connectors/mock/extended_mock.py +87 -0
  46. packages/connectors/mock/us_mock.py +87 -0
  47. packages/core/__init__.py +0 -0
  48. packages/core/__pycache__/__init__.cpython-311.pyc +0 -0
  49. packages/core/__pycache__/config.cpython-311.pyc +0 -0
  50. 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