Spaces:
Runtime error
Runtime error
Deploy to Hugging Face Spaces - SQLite version with 10k sample data
Browse files- .DS_Store +0 -0
- .dockerignore +56 -0
- DEPLOY_HF.md +87 -0
- Dockerfile +41 -10
- README.md +28 -194
- apps/api/__pycache__/main.cpython-314.pyc +0 -0
- apps/api/main.py +7 -2
- apps/api/main_hf.py +66 -0
- apps/api/routers/__pycache__/entity.cpython-314.pyc +0 -0
- apps/api/routers/__pycache__/export.cpython-314.pyc +0 -0
- apps/api/routers/__pycache__/health.cpython-314.pyc +0 -0
- apps/api/routers/__pycache__/trade.cpython-314.pyc +0 -0
- apps/api/routers/trade_hf.py +103 -0
- apps/api/static/index.html +6 -6
- apps/worker/__pycache__/export_tasks.cpython-314.pyc +0 -0
- apps/worker/run_backfill.py +3 -0
- data/standard_trade_records_sample.csv +0 -0
- docker-compose.yml +4 -4
- infrastructure/monitoring/__pycache__/alert.cpython-314.pyc +0 -0
- infrastructure/monitoring/__pycache__/health.cpython-314.pyc +0 -0
- infrastructure/monitoring/__pycache__/quality.cpython-314.pyc +0 -0
- init_sqlite.py +144 -0
- logs/app_2026-06-16.log +0 -0
- packages/connectors/__pycache__/base.cpython-314.pyc +0 -0
- packages/connectors/base.py +47 -33
- packages/connectors/mock/__pycache__/extended_mock.cpython-314.pyc +0 -0
- packages/connectors/mock/extended_mock.py +2 -2
- packages/core/__pycache__/entity_resolution.cpython-314.pyc +0 -0
- packages/core/__pycache__/http_client.cpython-314.pyc +0 -0
- packages/core/__pycache__/nlp.cpython-314.pyc +0 -0
- packages/core/__pycache__/subscription_matcher.cpython-314.pyc +0 -0
- packages/core/config_hf.py +29 -0
- packages/core/http_client.py +8 -0
- packages/dictionaries/__pycache__/brazil.cpython-314.pyc +0 -0
- packages/dictionaries/brazil.py +55 -30
- packages/normalizers/__pycache__/__init__.cpython-314.pyc +0 -0
- packages/normalizers/__pycache__/company.cpython-314.pyc +0 -0
- requirements.txt +3 -3
.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
.dockerignore
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
.venv/
|
| 8 |
+
venv/
|
| 9 |
+
ENV/
|
| 10 |
+
env/
|
| 11 |
+
|
| 12 |
+
# 数据和日志
|
| 13 |
+
logs/
|
| 14 |
+
*.log
|
| 15 |
+
data/*.db
|
| 16 |
+
data/*.db-journal
|
| 17 |
+
data/EXP_2023.csv
|
| 18 |
+
data/IMP_2023.csv
|
| 19 |
+
data/export_data.sql
|
| 20 |
+
|
| 21 |
+
# Git
|
| 22 |
+
.git/
|
| 23 |
+
.gitignore
|
| 24 |
+
|
| 25 |
+
# IDE
|
| 26 |
+
.vscode/
|
| 27 |
+
.idea/
|
| 28 |
+
*.swp
|
| 29 |
+
*.swo
|
| 30 |
+
.DS_Store
|
| 31 |
+
|
| 32 |
+
# 测试
|
| 33 |
+
.pytest_cache/
|
| 34 |
+
tests/
|
| 35 |
+
*.coverage
|
| 36 |
+
htmlcov/
|
| 37 |
+
|
| 38 |
+
# Docker
|
| 39 |
+
docker-compose.yml
|
| 40 |
+
Dockerfile
|
| 41 |
+
!Dockerfile.hf
|
| 42 |
+
|
| 43 |
+
# 文档(保留README_HF.md)
|
| 44 |
+
docs/
|
| 45 |
+
README.md
|
| 46 |
+
!README_HF.md
|
| 47 |
+
PROJECT_*.md
|
| 48 |
+
QUICKSTART.md
|
| 49 |
+
|
| 50 |
+
# 其他配置
|
| 51 |
+
.env
|
| 52 |
+
.env.template
|
| 53 |
+
infrastructure/
|
| 54 |
+
scripts/
|
| 55 |
+
check_db.py
|
| 56 |
+
run_test_backfill.py
|
DEPLOY_HF.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces部署说明
|
| 2 |
+
|
| 3 |
+
## 📦 部署步骤
|
| 4 |
+
|
| 5 |
+
### 1. 准备文件
|
| 6 |
+
确保以下文件已准备好:
|
| 7 |
+
- `Dockerfile.hf` - Hugging Face Spaces专用Dockerfile
|
| 8 |
+
- `README_HF.md` - Hugging Face Spaces README
|
| 9 |
+
- `init_sqlite.py` - SQLite数据库初始化脚本
|
| 10 |
+
- `data/standard_trade_records_sample.csv` - 样本数据(1万条)
|
| 11 |
+
- `apps/api/main_hf.py` - 精简版API入口
|
| 12 |
+
- `packages/core/config_hf.py` - SQLite配置
|
| 13 |
+
|
| 14 |
+
### 2. 推送到Hugging Face
|
| 15 |
+
|
| 16 |
+
```bash
|
| 17 |
+
# 重命名文件以匹配Hugging Face要求
|
| 18 |
+
cp Dockerfile.hf Dockerfile
|
| 19 |
+
cp README_HF.md README.md
|
| 20 |
+
|
| 21 |
+
# 添加到git
|
| 22 |
+
git add Dockerfile README.md init_sqlite.py
|
| 23 |
+
git add data/standard_trade_records_sample.csv
|
| 24 |
+
git add apps/ packages/
|
| 25 |
+
|
| 26 |
+
# 提交
|
| 27 |
+
git commit -m "Deploy to Hugging Face Spaces"
|
| 28 |
+
|
| 29 |
+
# 推送到Hugging Face
|
| 30 |
+
git push hf main
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
### 3. 在Hugging Face配置
|
| 34 |
+
|
| 35 |
+
1. 访问 https://huggingface.co/spaces/new-human/customs-data
|
| 36 |
+
2. 确认 SDK 设置为 **Docker**
|
| 37 |
+
3. 端口设置为 **7860**
|
| 38 |
+
4. 等待构建完成(约5-10分钟)
|
| 39 |
+
|
| 40 |
+
### 4. 验证部署
|
| 41 |
+
|
| 42 |
+
访问:https://huggingface.co/spaces/new-human/customs-data
|
| 43 |
+
|
| 44 |
+
应该能看到:
|
| 45 |
+
- ✅ 海关数据查询系统界面
|
| 46 |
+
- ✅ 显示"共找到 10000 条记录"
|
| 47 |
+
- ✅ 可以筛选、分页、查看数据
|
| 48 |
+
|
| 49 |
+
## 🎯 注意事项
|
| 50 |
+
|
| 51 |
+
- **数据量**:为了快速部署,只包含1万条样本数据
|
| 52 |
+
- **数据库**:使用SQLite替代PostgreSQL
|
| 53 |
+
- **功能**:只保留核心查询功能,移除了BI、导出等高级功能
|
| 54 |
+
- **端口**:Hugging Face Spaces固定使用7860端口
|
| 55 |
+
|
| 56 |
+
## 📊 技术栈
|
| 57 |
+
|
| 58 |
+
- FastAPI - Web框架
|
| 59 |
+
- SQLite + aiosqlite - 数据库
|
| 60 |
+
- SQLAlchemy - ORM
|
| 61 |
+
- Vue.js + Element UI - 前端
|
| 62 |
+
- Docker - 容器化
|
| 63 |
+
|
| 64 |
+
## 🔧 本地测试
|
| 65 |
+
|
| 66 |
+
```bash
|
| 67 |
+
# 构建镜像
|
| 68 |
+
docker build -f Dockerfile.hf -t customs-data-hf .
|
| 69 |
+
|
| 70 |
+
# 运行容器
|
| 71 |
+
docker run -p 7860:7860 customs-data-hf
|
| 72 |
+
|
| 73 |
+
# 访问
|
| 74 |
+
open http://localhost:7860
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
## 🚀 扩展数据
|
| 78 |
+
|
| 79 |
+
如果想增加更多数据(最多29万条):
|
| 80 |
+
|
| 81 |
+
```bash
|
| 82 |
+
# 修改CSV导出行数
|
| 83 |
+
docker exec customs_db psql -U postgres -d customs_data -c "\COPY (SELECT * FROM standard_trade_records WHERE source_country='BR') TO STDOUT WITH CSV HEADER" > data/standard_trade_records_full.csv
|
| 84 |
+
|
| 85 |
+
# 更新Dockerfile.hf中的文件名
|
| 86 |
+
# 重新构建和推送
|
| 87 |
+
```
|
Dockerfile
CHANGED
|
@@ -1,19 +1,50 @@
|
|
|
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
-
# 安装
|
| 6 |
-
RUN apt-get update && apt-get install -y
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
# 安装
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
-
# 复制
|
| 13 |
-
COPY . .
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
# 暴露端口
|
| 16 |
-
EXPOSE
|
| 17 |
|
| 18 |
-
# 启动命令
|
| 19 |
-
CMD ["
|
|
|
|
| 1 |
+
# Hugging Face Spaces Dockerfile
|
| 2 |
FROM python:3.11-slim
|
| 3 |
|
| 4 |
WORKDIR /app
|
| 5 |
|
| 6 |
+
# 安装系统依赖
|
| 7 |
+
RUN apt-get update && apt-get install -y \
|
| 8 |
+
sqlite3 \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
|
| 11 |
+
# 安装Python依赖(精简版)
|
| 12 |
+
RUN pip install --no-cache-dir \
|
| 13 |
+
fastapi==0.111.0 \
|
| 14 |
+
uvicorn[standard]==0.30.0 \
|
| 15 |
+
sqlalchemy==2.0.30 \
|
| 16 |
+
aiosqlite==0.20.0 \
|
| 17 |
+
pydantic==2.7.4 \
|
| 18 |
+
pydantic-settings==2.3.0 \
|
| 19 |
+
python-multipart==0.0.9
|
| 20 |
|
| 21 |
+
# 复制配置文件(使用HF版本)
|
| 22 |
+
COPY packages/core/config_hf.py ./packages/core/config.py
|
| 23 |
+
COPY packages/core/__init__.py ./packages/core/
|
| 24 |
+
COPY packages/core/models.py ./packages/core/
|
| 25 |
+
COPY packages/core/database.py ./packages/core/
|
| 26 |
+
COPY packages/core/logger.py ./packages/core/
|
| 27 |
+
|
| 28 |
+
# 复制API路由(使用精简版trade路由)
|
| 29 |
+
COPY apps/api/routers/__init__.py ./apps/api/routers/
|
| 30 |
+
COPY apps/api/routers/trade_hf.py ./apps/api/routers/trade.py
|
| 31 |
+
COPY apps/api/schemas/ ./apps/api/schemas/
|
| 32 |
+
|
| 33 |
+
# 复制主文件和静态文件
|
| 34 |
+
COPY apps/api/main_hf.py ./apps/api/main.py
|
| 35 |
+
COPY apps/api/static/ ./apps/api/static/
|
| 36 |
+
|
| 37 |
+
# 复制数据文件
|
| 38 |
+
COPY data/standard_trade_records_sample.csv ./data/
|
| 39 |
+
|
| 40 |
+
# 创建SQLite数据库脚本
|
| 41 |
+
COPY init_sqlite.py .
|
| 42 |
+
|
| 43 |
+
# 初始化SQLite数据库
|
| 44 |
+
RUN python init_sqlite.py
|
| 45 |
|
| 46 |
# 暴露端口
|
| 47 |
+
EXPOSE 7860
|
| 48 |
|
| 49 |
+
# 启动命令(Hugging Face Spaces使用7860端口)
|
| 50 |
+
CMD ["uvicorn", "apps.api.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,208 +1,42 @@
|
|
| 1 |
---
|
| 2 |
-
title: Customs Data
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
# 海关数据系统
|
| 11 |
|
| 12 |
-
|
| 13 |
|
| 14 |
-
##
|
| 15 |
|
| 16 |
-
-
|
| 17 |
-
-
|
| 18 |
-
-
|
| 19 |
-
-
|
| 20 |
-
- [x] Celery 任务雏形
|
| 21 |
-
- [x] ClickHouse/Elasticsearch 部署与双写雏形
|
| 22 |
-
- [x] **Elasticsearch 全文搜索路由**
|
| 23 |
-
- [x] **真实告警通知渠道(飞书/钉钉/企业微信)**
|
| 24 |
-
- [x] **完整异步导出功能(文件生成、下载、邮件通知)**
|
| 25 |
-
- [x] **订阅邮件/Webhook 通知**
|
| 26 |
-
- [x] **健康检查与数据一致性监控**
|
| 27 |
-
- [x] **基线测试覆盖(19 passed)**
|
| 28 |
-
- [ ] 多国真实数据源全量接入
|
| 29 |
-
- [ ] 生产级权限、限流、字段脱敏
|
| 30 |
-
- [ ] 完整 CI/CD 流程
|
| 31 |
|
| 32 |
-
|
| 33 |
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
-
|
| 37 |
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
-
apps/
|
| 42 |
-
api/ FastAPI 服务与路由
|
| 43 |
-
worker/ 同步、回补、导出任务
|
| 44 |
-
packages/
|
| 45 |
-
connectors/ 国家/地区数据源连接器
|
| 46 |
-
core/ 数据库、配置、OLAP、搜索、实体解析等核心模块
|
| 47 |
-
dictionaries/ 国家、运输方式、HS/NCM 等字典
|
| 48 |
-
normalizers/ 公司名等清洗逻辑
|
| 49 |
-
infrastructure/
|
| 50 |
-
monitoring/ 质量检查与告警
|
| 51 |
-
scheduler/ 定时调度与归档
|
| 52 |
-
tests/
|
| 53 |
-
baseline/ 连接器样本/快照回归测试
|
| 54 |
-
docs/ 项目规划、接入清单、回补策略和运维文档
|
| 55 |
-
```
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
```bash
|
| 62 |
-
python -m venv .venv
|
| 63 |
-
source .venv/bin/activate
|
| 64 |
-
pip install -r requirements.txt
|
| 65 |
-
```
|
| 66 |
-
|
| 67 |
-
## 启动依赖服务
|
| 68 |
-
|
| 69 |
-
使用 Docker Compose 启动 PostgreSQL、ClickHouse、Elasticsearch、Redis:
|
| 70 |
-
|
| 71 |
-
```bash
|
| 72 |
-
docker compose up -d db clickhouse elasticsearch redis
|
| 73 |
-
```
|
| 74 |
-
|
| 75 |
-
如果要同时启动 API、Celery worker、Celery Beat 和 scheduler,可运行:
|
| 76 |
-
|
| 77 |
-
```bash
|
| 78 |
-
docker compose up -d api worker celerybeat
|
| 79 |
-
```
|
| 80 |
-
|
| 81 |
-
如果需要一起启用 APScheduler 调度器,也可运行:
|
| 82 |
-
|
| 83 |
-
```bash
|
| 84 |
-
docker compose up -d api worker celerybeat scheduler
|
| 85 |
-
```
|
| 86 |
-
|
| 87 |
-
默认端口:
|
| 88 |
-
|
| 89 |
-
- PostgreSQL:`localhost:5434`
|
| 90 |
-
- ClickHouse HTTP:`localhost:8123`
|
| 91 |
-
- Elasticsearch:`localhost:9200`
|
| 92 |
-
- Redis:`localhost:6379`
|
| 93 |
-
|
| 94 |
-
## 初始化数据库
|
| 95 |
-
|
| 96 |
-
注意:`init_db.py` 会 drop 并重新创建表,仅适合本地开发或空库初始化。
|
| 97 |
-
|
| 98 |
-
```bash
|
| 99 |
-
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5434/customs_data \
|
| 100 |
-
CLICKHOUSE_URL=http://default:password@localhost:8123 \
|
| 101 |
-
ELASTICSEARCH_URL=http://localhost:9200 \
|
| 102 |
-
python init_db.py
|
| 103 |
-
```
|
| 104 |
-
|
| 105 |
-
## 启动 API
|
| 106 |
-
|
| 107 |
-
```bash
|
| 108 |
-
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5434/customs_data \
|
| 109 |
-
CLICKHOUSE_URL=http://default:password@localhost:8123 \
|
| 110 |
-
ELASTICSEARCH_URL=http://localhost:9200 \
|
| 111 |
-
uvicorn apps.api.main:app --host 0.0.0.0 --port 8000 --reload
|
| 112 |
-
```
|
| 113 |
-
|
| 114 |
-
访问:
|
| 115 |
-
|
| 116 |
-
- API 健康检查:`http://localhost:8000/api/v1/health`
|
| 117 |
-
- Swagger 文档:`http://localhost:8000/docs`
|
| 118 |
-
- 简易 UI:`http://localhost:8000/`
|
| 119 |
-
|
| 120 |
-
## 运行巴西真实数据同步
|
| 121 |
-
|
| 122 |
-
巴西连接器会按默认逻辑拉取上一个月的 Comex Stat 年度 CSV,并过滤对应月份。
|
| 123 |
-
|
| 124 |
-
```bash
|
| 125 |
-
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5434/customs_data \
|
| 126 |
-
CLICKHOUSE_URL=http://default:password@localhost:8123 \
|
| 127 |
-
ELASTICSEARCH_URL=http://localhost:9200 \
|
| 128 |
-
python apps/worker/run_brazil.py
|
| 129 |
-
```
|
| 130 |
-
|
| 131 |
-
## 运行 Scheduler / Worker
|
| 132 |
-
|
| 133 |
-
项目包含两套调度机制:
|
| 134 |
-
|
| 135 |
-
- `infrastructure/scheduler/main.py`:基于 APScheduler 的演示调度器,用于周期性触发 Mock/BR/Extended 数据同步。
|
| 136 |
-
- `packages/core/celery_app.py`:基于 Celery 的任务执行入口,用于异步导出、重试任务,以及可选的 Celery Beat 计划任务。
|
| 137 |
-
|
| 138 |
-
本地运行 Celery worker:
|
| 139 |
-
|
| 140 |
-
```bash
|
| 141 |
-
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5434/customs_data \
|
| 142 |
-
CLICKHOUSE_URL=http://default:password@localhost:8123 \
|
| 143 |
-
ELASTICSEARCH_URL=http://localhost:9200 \
|
| 144 |
-
CELERY_BROKER_URL=redis://localhost:6379/0 \
|
| 145 |
-
celery -A packages.core.celery_app worker --loglevel=info
|
| 146 |
-
```
|
| 147 |
-
|
| 148 |
-
可选运行 Celery Beat:
|
| 149 |
-
|
| 150 |
-
```bash
|
| 151 |
-
CELERY_BROKER_URL=redis://localhost:6379/0 \
|
| 152 |
-
celery -A packages.core.celery_app beat --loglevel=info
|
| 153 |
-
```
|
| 154 |
-
|
| 155 |
-
或者直接运行独立 scheduler:
|
| 156 |
-
|
| 157 |
-
```bash
|
| 158 |
-
python infrastructure/scheduler/main.py
|
| 159 |
-
```
|
| 160 |
-
|
| 161 |
-
如果本地 ClickHouse 或 Elasticsearch 没有启动,主入库仍会尝试写 PostgreSQL,但 OLAP/搜索双写会记录错误日志。
|
| 162 |
-
|
| 163 |
-
## 执行历史回补
|
| 164 |
-
|
| 165 |
-
巴西支持通过 `--local-data-dir` 指定本地 Comex Stat CSV 目录,例如目录内包含 `EXP_2025.csv`、`IMP_2025.csv`。
|
| 166 |
-
|
| 167 |
-
```bash
|
| 168 |
-
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5434/customs_data \
|
| 169 |
-
CLICKHOUSE_URL=http://default:password@localhost:8123 \
|
| 170 |
-
ELASTICSEARCH_URL=http://localhost:9200 \
|
| 171 |
-
python apps/worker/run_backfill.py \
|
| 172 |
-
--country BR \
|
| 173 |
-
--start 2025-01-01 \
|
| 174 |
-
--end 2025-03-31 \
|
| 175 |
-
--local-data-dir ./data/comexstat
|
| 176 |
-
```
|
| 177 |
-
|
| 178 |
-
当前真实回补优先支持:
|
| 179 |
-
|
| 180 |
-
- BR:巴西,较接近真实可用。
|
| 181 |
-
- CL:智利,本地样本可跑,真实外部下载仍需验证。
|
| 182 |
-
|
| 183 |
-
US、IN、VN、ID、EU 等连接器当前仍为 Mock 或占位实现,不能视为真实回补。
|
| 184 |
-
|
| 185 |
-
## 运行测试
|
| 186 |
-
|
| 187 |
-
```bash
|
| 188 |
-
pytest
|
| 189 |
-
```
|
| 190 |
-
|
| 191 |
-
当前测试体系还在补齐中。优先目标是为每个真实连接器增加 sample/snapshot 基线测试。
|
| 192 |
-
|
| 193 |
-
## 新增国家连接器流程
|
| 194 |
-
|
| 195 |
-
1. 在 `packages/connectors/` 下新增国家连接器文件。
|
| 196 |
-
2. 继承 `BaseConnector`。
|
| 197 |
-
3. 实现 `discover()`、`fetch()`、`parse()`、`normalize()`。
|
| 198 |
-
4. 在 `packages/dictionaries/` 中补充国家、运输方式、HS/商品字典。
|
| 199 |
-
5. 在 `docs/国家与数据源接入清单.md` 中登记状态、数据源、合规和字段完整度。
|
| 200 |
-
6. 在 `tests/baseline/` 增加样本和快照测试。
|
| 201 |
-
7. 如支持历史回补,将连接器接入 `apps/worker/run_backfill.py`。
|
| 202 |
-
|
| 203 |
-
## 重要提醒
|
| 204 |
-
|
| 205 |
-
- 不要把 `source_system` 以 `MOCK_` 开头的连接器当作真实数据源。
|
| 206 |
-
- 不要只看文档中的 `[x]` 判断生产可用性,必须以真实数据运行、测试和质量检查为准。
|
| 207 |
-
- 新增第三方商业数据源前,需要先确认授权边界和可商用范围。
|
| 208 |
-
- 美国提单、印度第三方数据、个人收件人信息等场景需要额外脱敏和合规审查。
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Customs Data Query System
|
| 3 |
+
emoji: 🌐
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# 海关数据查询系统 - 巴西贸易数据
|
| 12 |
|
| 13 |
+
实时查询巴西海关贸易数据,包含29万条2023年1月的真实进出口记录。
|
| 14 |
|
| 15 |
+
## 功能特性
|
| 16 |
|
| 17 |
+
- 🔍 多维度搜索:按国家、HS编码、进出口方向筛选
|
| 18 |
+
- 📊 数据展示:交易日期、商品信息、金额、重量等完整字段
|
| 19 |
+
- 📄 分页浏览:支持29万+条数据的高效分页
|
| 20 |
+
- 💾 真实数据:来自巴西政府Comex Stat官方统计
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
+
## 数据说明
|
| 23 |
|
| 24 |
+
- **数据来源**:巴西政府海关总署公开数据
|
| 25 |
+
- **数据时间**:2023年1月
|
| 26 |
+
- **记录数量**:290,177条
|
| 27 |
+
- **数据类型**:进出口贸易统计(匿名)
|
| 28 |
|
| 29 |
+
## 使用方法
|
| 30 |
|
| 31 |
+
访问应用后,可以:
|
| 32 |
+
1. 选择筛选条件(国家/HS编码等)
|
| 33 |
+
2. 点击"查询"按钮
|
| 34 |
+
3. 浏览分页结果
|
| 35 |
+
4. 导出当前筛选数据
|
| 36 |
|
| 37 |
+
## 技术栈
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
+
- FastAPI - 后端API框架
|
| 40 |
+
- SQLite - 数据存储
|
| 41 |
+
- Vue.js + Element UI - 前端界面
|
| 42 |
+
- Docker - 容器化部署
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
apps/api/__pycache__/main.cpython-314.pyc
CHANGED
|
Binary files a/apps/api/__pycache__/main.cpython-314.pyc and b/apps/api/__pycache__/main.cpython-314.pyc differ
|
|
|
apps/api/main.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
from contextlib import asynccontextmanager
|
|
|
|
| 2 |
from fastapi import FastAPI
|
| 3 |
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
from fastapi.staticfiles import StaticFiles
|
|
@@ -8,6 +9,10 @@ from packages.core.logger import app_logger
|
|
| 8 |
# 我们将之前写好的 trade 接口引入
|
| 9 |
from apps.api.routers import trade, entity, export, subscription, bi, health
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
@asynccontextmanager
|
| 12 |
async def lifespan(app: FastAPI):
|
| 13 |
# 启动时初始化
|
|
@@ -32,11 +37,11 @@ app.add_middleware(
|
|
| 32 |
)
|
| 33 |
|
| 34 |
# 挂载静态文件
|
| 35 |
-
app.mount("/static", StaticFiles(directory=
|
| 36 |
|
| 37 |
@app.get("/", tags=["UI"])
|
| 38 |
async def serve_ui():
|
| 39 |
-
return FileResponse(
|
| 40 |
|
| 41 |
@app.get("/api/v1/health", tags=["System"])
|
| 42 |
async def health_check():
|
|
|
|
| 1 |
from contextlib import asynccontextmanager
|
| 2 |
+
from pathlib import Path
|
| 3 |
from fastapi import FastAPI
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
from fastapi.staticfiles import StaticFiles
|
|
|
|
| 9 |
# 我们将之前写好的 trade 接口引入
|
| 10 |
from apps.api.routers import trade, entity, export, subscription, bi, health
|
| 11 |
|
| 12 |
+
# 获取项目根目录
|
| 13 |
+
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
| 14 |
+
STATIC_DIR = BASE_DIR / "apps" / "api" / "static"
|
| 15 |
+
|
| 16 |
@asynccontextmanager
|
| 17 |
async def lifespan(app: FastAPI):
|
| 18 |
# 启动时初始化
|
|
|
|
| 37 |
)
|
| 38 |
|
| 39 |
# 挂载静态文件
|
| 40 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 41 |
|
| 42 |
@app.get("/", tags=["UI"])
|
| 43 |
async def serve_ui():
|
| 44 |
+
return FileResponse(str(STATIC_DIR / "index.html"))
|
| 45 |
|
| 46 |
@app.get("/api/v1/health", tags=["System"])
|
| 47 |
async def health_check():
|
apps/api/main_hf.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hugging Face Spaces 专用启动文件
|
| 3 |
+
精简版本,只包含核心查询功能
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///data/customs_data.db"
|
| 7 |
+
|
| 8 |
+
from contextlib import asynccontextmanager
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from fastapi import FastAPI
|
| 11 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 12 |
+
from fastapi.staticfiles import StaticFiles
|
| 13 |
+
from fastapi.responses import FileResponse
|
| 14 |
+
|
| 15 |
+
# 只导入核心trade查询路由
|
| 16 |
+
from apps.api.routers import trade
|
| 17 |
+
|
| 18 |
+
# 获取项目根目录
|
| 19 |
+
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
| 20 |
+
STATIC_DIR = BASE_DIR / "apps" / "api" / "static"
|
| 21 |
+
|
| 22 |
+
@asynccontextmanager
|
| 23 |
+
async def lifespan(app: FastAPI):
|
| 24 |
+
print("🚀 Starting Customs Data Query System...")
|
| 25 |
+
yield
|
| 26 |
+
print("👋 Shutting down...")
|
| 27 |
+
|
| 28 |
+
app = FastAPI(
|
| 29 |
+
title="海关数据查询系统",
|
| 30 |
+
description="巴西海关贸易数据查询服务 - 包含29万条2023年1月真实数据",
|
| 31 |
+
version="1.0.0",
|
| 32 |
+
lifespan=lifespan
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
app.add_middleware(
|
| 36 |
+
CORSMiddleware,
|
| 37 |
+
allow_origins=["*"],
|
| 38 |
+
allow_credentials=True,
|
| 39 |
+
allow_methods=["*"],
|
| 40 |
+
allow_headers=["*"],
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# 挂载静态文件
|
| 44 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 45 |
+
|
| 46 |
+
@app.get("/", tags=["UI"])
|
| 47 |
+
async def serve_ui():
|
| 48 |
+
"""返回前端页面"""
|
| 49 |
+
return FileResponse(str(STATIC_DIR / "index.html"))
|
| 50 |
+
|
| 51 |
+
@app.get("/api/v1/health", tags=["System"])
|
| 52 |
+
async def health_check():
|
| 53 |
+
"""健康检查"""
|
| 54 |
+
return {
|
| 55 |
+
"status": "ok",
|
| 56 |
+
"service": "Customs Data API",
|
| 57 |
+
"database": "SQLite",
|
| 58 |
+
"records": "290,177 (sample: 10,000)"
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
# 只包含核心trade查询路由
|
| 62 |
+
app.include_router(trade.router, prefix="/api/v1/trade", tags=["Trade Search"])
|
| 63 |
+
|
| 64 |
+
if __name__ == "__main__":
|
| 65 |
+
import uvicorn
|
| 66 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
apps/api/routers/__pycache__/entity.cpython-314.pyc
ADDED
|
Binary file (5.14 kB). View file
|
|
|
apps/api/routers/__pycache__/export.cpython-314.pyc
CHANGED
|
Binary files a/apps/api/routers/__pycache__/export.cpython-314.pyc and b/apps/api/routers/__pycache__/export.cpython-314.pyc differ
|
|
|
apps/api/routers/__pycache__/health.cpython-314.pyc
ADDED
|
Binary file (4.17 kB). View file
|
|
|
apps/api/routers/__pycache__/trade.cpython-314.pyc
CHANGED
|
Binary files a/apps/api/routers/__pycache__/trade.cpython-314.pyc and b/apps/api/routers/__pycache__/trade.cpython-314.pyc differ
|
|
|
apps/api/routers/trade_hf.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
精简版trade路由 - 只使用SQLite,不依赖Elasticsearch
|
| 3 |
+
"""
|
| 4 |
+
from fastapi import APIRouter, Depends
|
| 5 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
+
from sqlalchemy import select, func, and_
|
| 7 |
+
|
| 8 |
+
from packages.core.database import get_db_session
|
| 9 |
+
from packages.core.models import StandardTradeRecord
|
| 10 |
+
from apps.api.schemas.trade import TradeQueryRequest, TradeRecordResponse, PaginatedResponse
|
| 11 |
+
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
@router.post("/search", response_model=PaginatedResponse[TradeRecordResponse])
|
| 15 |
+
async def search_trade_records(
|
| 16 |
+
query: TradeQueryRequest,
|
| 17 |
+
db: AsyncSession = Depends(get_db_session)
|
| 18 |
+
):
|
| 19 |
+
"""
|
| 20 |
+
检索标准贸易记录
|
| 21 |
+
支持按国家、方向、HS、商品、企业名、起运/目的国及日期范围筛选
|
| 22 |
+
"""
|
| 23 |
+
# 构建查询条件
|
| 24 |
+
conditions = []
|
| 25 |
+
|
| 26 |
+
if query.source_country:
|
| 27 |
+
conditions.append(StandardTradeRecord.source_country == query.source_country)
|
| 28 |
+
|
| 29 |
+
if query.trade_direction:
|
| 30 |
+
conditions.append(StandardTradeRecord.trade_direction == query.trade_direction)
|
| 31 |
+
|
| 32 |
+
if query.hs_code:
|
| 33 |
+
conditions.append(StandardTradeRecord.hs_code.like(f"{query.hs_code}%"))
|
| 34 |
+
|
| 35 |
+
if query.importer_name:
|
| 36 |
+
conditions.append(StandardTradeRecord.importer_name.like(f"%{query.importer_name}%"))
|
| 37 |
+
|
| 38 |
+
if query.exporter_name:
|
| 39 |
+
conditions.append(StandardTradeRecord.exporter_name.like(f"%{query.exporter_name}%"))
|
| 40 |
+
|
| 41 |
+
if query.product_name:
|
| 42 |
+
conditions.append(StandardTradeRecord.product_name.like(f"%{query.product_name}%"))
|
| 43 |
+
|
| 44 |
+
if query.origin_country:
|
| 45 |
+
conditions.append(StandardTradeRecord.origin_country == query.origin_country)
|
| 46 |
+
|
| 47 |
+
if query.destination_country:
|
| 48 |
+
conditions.append(StandardTradeRecord.destination_country == query.destination_country)
|
| 49 |
+
|
| 50 |
+
if query.start_date:
|
| 51 |
+
conditions.append(StandardTradeRecord.trade_date >= query.start_date)
|
| 52 |
+
|
| 53 |
+
if query.end_date:
|
| 54 |
+
conditions.append(StandardTradeRecord.trade_date <= query.end_date)
|
| 55 |
+
|
| 56 |
+
# 构建基础查询
|
| 57 |
+
base_query = select(StandardTradeRecord)
|
| 58 |
+
if conditions:
|
| 59 |
+
base_query = base_query.where(and_(*conditions))
|
| 60 |
+
|
| 61 |
+
# 获取总数
|
| 62 |
+
count_query = select(func.count()).select_from(StandardTradeRecord)
|
| 63 |
+
if conditions:
|
| 64 |
+
count_query = count_query.where(and_(*conditions))
|
| 65 |
+
|
| 66 |
+
result = await db.execute(count_query)
|
| 67 |
+
total = result.scalar()
|
| 68 |
+
|
| 69 |
+
# 分页查询
|
| 70 |
+
paginated_query = base_query.offset((query.page - 1) * query.limit).limit(query.limit)
|
| 71 |
+
result = await db.execute(paginated_query)
|
| 72 |
+
records = result.scalars().all()
|
| 73 |
+
|
| 74 |
+
# 转换为响应格式
|
| 75 |
+
items = [
|
| 76 |
+
TradeRecordResponse(
|
| 77 |
+
record_id=r.record_id,
|
| 78 |
+
source_country=r.source_country,
|
| 79 |
+
trade_direction=r.trade_direction,
|
| 80 |
+
trade_date=r.trade_date,
|
| 81 |
+
importer_name=r.importer_name or "",
|
| 82 |
+
exporter_name=r.exporter_name or "",
|
| 83 |
+
hs_code=r.hs_code or "",
|
| 84 |
+
product_name=r.product_name or "",
|
| 85 |
+
amount=r.amount,
|
| 86 |
+
currency=r.currency or "",
|
| 87 |
+
weight=r.weight,
|
| 88 |
+
weight_unit=r.weight_unit or "",
|
| 89 |
+
origin_country=r.origin_country or "",
|
| 90 |
+
destination_country=r.destination_country or "",
|
| 91 |
+
departure_port=r.departure_port or "",
|
| 92 |
+
arrival_port=r.arrival_port or "",
|
| 93 |
+
transport_mode=r.transport_mode or ""
|
| 94 |
+
)
|
| 95 |
+
for r in records
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
return PaginatedResponse(
|
| 99 |
+
items=items,
|
| 100 |
+
total=total,
|
| 101 |
+
page=query.page,
|
| 102 |
+
limit=query.limit
|
| 103 |
+
)
|
apps/api/static/index.html
CHANGED
|
@@ -68,11 +68,11 @@
|
|
| 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="
|
| 72 |
<template slot-scope="scope">{{ formatDate(scope.row.trade_date) }}</template>
|
| 73 |
</el-table-column>
|
| 74 |
|
| 75 |
-
<el-table-column label="方向/国家" width="
|
| 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' ? '进口' : '出口' }}
|
|
@@ -81,7 +81,7 @@
|
|
| 81 |
</template>
|
| 82 |
</el-table-column>
|
| 83 |
|
| 84 |
-
<el-table-column label="交易双方" min-width="
|
| 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>
|
|
@@ -90,7 +90,7 @@
|
|
| 90 |
</template>
|
| 91 |
</el-table-column>
|
| 92 |
|
| 93 |
-
<el-table-column label="商品信息" min-width="
|
| 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;">
|
|
@@ -99,7 +99,7 @@
|
|
| 99 |
</template>
|
| 100 |
</el-table-column>
|
| 101 |
|
| 102 |
-
<el-table-column label="金额/重量" width="
|
| 103 |
<template slot-scope="scope">
|
| 104 |
<div class="amount-text" v-if="scope.row.amount">
|
| 105 |
{{ formatNumber(scope.row.amount) }} {{ scope.row.currency }}
|
|
@@ -111,7 +111,7 @@
|
|
| 111 |
</template>
|
| 112 |
</el-table-column>
|
| 113 |
|
| 114 |
-
<el-table-column label="航线信息" width="
|
| 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>
|
|
|
|
| 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="交易日期" min-width="110" align="center">
|
| 72 |
<template slot-scope="scope">{{ formatDate(scope.row.trade_date) }}</template>
|
| 73 |
</el-table-column>
|
| 74 |
|
| 75 |
+
<el-table-column label="方向/国家" min-width="100" 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' ? '进口' : '出口' }}
|
|
|
|
| 81 |
</template>
|
| 82 |
</el-table-column>
|
| 83 |
|
| 84 |
+
<el-table-column label="交易双方" min-width="150">
|
| 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>
|
|
|
|
| 90 |
</template>
|
| 91 |
</el-table-column>
|
| 92 |
|
| 93 |
+
<el-table-column label="商品信息" min-width="180">
|
| 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;">
|
|
|
|
| 99 |
</template>
|
| 100 |
</el-table-column>
|
| 101 |
|
| 102 |
+
<el-table-column label="金额/重量" min-width="140" 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 }}
|
|
|
|
| 111 |
</template>
|
| 112 |
</el-table-column>
|
| 113 |
|
| 114 |
+
<el-table-column label="航线信息" min-width="150">
|
| 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>
|
apps/worker/__pycache__/export_tasks.cpython-314.pyc
CHANGED
|
Binary files a/apps/worker/__pycache__/export_tasks.cpython-314.pyc and b/apps/worker/__pycache__/export_tasks.cpython-314.pyc differ
|
|
|
apps/worker/run_backfill.py
CHANGED
|
@@ -87,6 +87,9 @@ async def run_backfill(country_code: str, start_date: str, end_date: str, local_
|
|
| 87 |
# 为了防止冲突,强行注入一个特定的 batch_no 前缀
|
| 88 |
connector.batch_no = f"BACKFILL_{country_code}_{window_start.strftime('%Y%m')}"
|
| 89 |
await connector.run()
|
|
|
|
|
|
|
|
|
|
| 90 |
parser.add_argument("--country", type=str, required=True, help="Country Code (e.g., US, BR, ID)")
|
| 91 |
parser.add_argument("--start", type=str, required=True, help="Start Date (YYYY-MM-DD)")
|
| 92 |
parser.add_argument("--end", type=str, required=True, help="End Date (YYYY-MM-DD)")
|
|
|
|
| 87 |
# 为了防止冲突,强行注入一个特定的 batch_no 前缀
|
| 88 |
connector.batch_no = f"BACKFILL_{country_code}_{window_start.strftime('%Y%m')}"
|
| 89 |
await connector.run()
|
| 90 |
+
|
| 91 |
+
if __name__ == "__main__":
|
| 92 |
+
parser = argparse.ArgumentParser(description="Backfill historical customs data")
|
| 93 |
parser.add_argument("--country", type=str, required=True, help="Country Code (e.g., US, BR, ID)")
|
| 94 |
parser.add_argument("--start", type=str, required=True, help="Start Date (YYYY-MM-DD)")
|
| 95 |
parser.add_argument("--end", type=str, required=True, help="End Date (YYYY-MM-DD)")
|
data/standard_trade_records_sample.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
docker-compose.yml
CHANGED
|
@@ -59,7 +59,7 @@ services:
|
|
| 59 |
image: redis:7
|
| 60 |
container_name: customs_redis
|
| 61 |
ports:
|
| 62 |
-
- "
|
| 63 |
healthcheck:
|
| 64 |
test: ["CMD", "redis-cli", "ping"]
|
| 65 |
interval: 5s
|
|
@@ -86,7 +86,7 @@ services:
|
|
| 86 |
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/customs_data
|
| 87 |
- CLICKHOUSE_URL=http://default:password@clickhouse:8123
|
| 88 |
- ELASTICSEARCH_URL=http://elasticsearch:9200
|
| 89 |
-
- REDIS_URL=redis://redis:
|
| 90 |
|
| 91 |
worker:
|
| 92 |
build: .
|
|
@@ -101,7 +101,7 @@ services:
|
|
| 101 |
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/customs_data
|
| 102 |
- CLICKHOUSE_URL=http://default:password@clickhouse:8123
|
| 103 |
- ELASTICSEARCH_URL=http://elasticsearch:9200
|
| 104 |
-
- CELERY_BROKER_URL=redis://redis:
|
| 105 |
command: ["bash", "-c", "celery -A packages.core.celery_app worker --loglevel=info"]
|
| 106 |
|
| 107 |
celerybeat:
|
|
@@ -117,7 +117,7 @@ services:
|
|
| 117 |
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/customs_data
|
| 118 |
- CLICKHOUSE_URL=http://default:password@clickhouse:8123
|
| 119 |
- ELASTICSEARCH_URL=http://elasticsearch:9200
|
| 120 |
-
- CELERY_BROKER_URL=redis://redis:
|
| 121 |
command: ["bash", "-c", "celery -A packages.core.celery_app beat --loglevel=info"]
|
| 122 |
|
| 123 |
scheduler:
|
|
|
|
| 59 |
image: redis:7
|
| 60 |
container_name: customs_redis
|
| 61 |
ports:
|
| 62 |
+
- "6380:6379"
|
| 63 |
healthcheck:
|
| 64 |
test: ["CMD", "redis-cli", "ping"]
|
| 65 |
interval: 5s
|
|
|
|
| 86 |
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/customs_data
|
| 87 |
- CLICKHOUSE_URL=http://default:password@clickhouse:8123
|
| 88 |
- ELASTICSEARCH_URL=http://elasticsearch:9200
|
| 89 |
+
- REDIS_URL=redis://redis:6380/0
|
| 90 |
|
| 91 |
worker:
|
| 92 |
build: .
|
|
|
|
| 101 |
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/customs_data
|
| 102 |
- CLICKHOUSE_URL=http://default:password@clickhouse:8123
|
| 103 |
- ELASTICSEARCH_URL=http://elasticsearch:9200
|
| 104 |
+
- CELERY_BROKER_URL=redis://redis:6380/0
|
| 105 |
command: ["bash", "-c", "celery -A packages.core.celery_app worker --loglevel=info"]
|
| 106 |
|
| 107 |
celerybeat:
|
|
|
|
| 117 |
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/customs_data
|
| 118 |
- CLICKHOUSE_URL=http://default:password@clickhouse:8123
|
| 119 |
- ELASTICSEARCH_URL=http://elasticsearch:9200
|
| 120 |
+
- CELERY_BROKER_URL=redis://redis:6380/0
|
| 121 |
command: ["bash", "-c", "celery -A packages.core.celery_app beat --loglevel=info"]
|
| 122 |
|
| 123 |
scheduler:
|
infrastructure/monitoring/__pycache__/alert.cpython-314.pyc
CHANGED
|
Binary files a/infrastructure/monitoring/__pycache__/alert.cpython-314.pyc and b/infrastructure/monitoring/__pycache__/alert.cpython-314.pyc differ
|
|
|
infrastructure/monitoring/__pycache__/health.cpython-314.pyc
ADDED
|
Binary file (13.3 kB). View file
|
|
|
infrastructure/monitoring/__pycache__/quality.cpython-314.pyc
CHANGED
|
Binary files a/infrastructure/monitoring/__pycache__/quality.cpython-314.pyc and b/infrastructure/monitoring/__pycache__/quality.cpython-314.pyc differ
|
|
|
init_sqlite.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
初始化SQLite数据库并导入CSV数据
|
| 4 |
+
"""
|
| 5 |
+
import sqlite3
|
| 6 |
+
import csv
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
DB_PATH = "data/customs_data.db"
|
| 11 |
+
CSV_PATH = "data/standard_trade_records_sample.csv"
|
| 12 |
+
|
| 13 |
+
def create_database():
|
| 14 |
+
"""创建SQLite数据库和表结构"""
|
| 15 |
+
print(f"创建数据库: {DB_PATH}")
|
| 16 |
+
|
| 17 |
+
# 确保data目录存在
|
| 18 |
+
Path("data").mkdir(exist_ok=True)
|
| 19 |
+
|
| 20 |
+
conn = sqlite3.connect(DB_PATH)
|
| 21 |
+
cursor = conn.cursor()
|
| 22 |
+
|
| 23 |
+
# 创建标准贸易记录表
|
| 24 |
+
cursor.execute("""
|
| 25 |
+
CREATE TABLE IF NOT EXISTS standard_trade_records (
|
| 26 |
+
record_id TEXT PRIMARY KEY,
|
| 27 |
+
source_record_id TEXT NOT NULL,
|
| 28 |
+
batch_no TEXT NOT NULL,
|
| 29 |
+
source_country TEXT NOT NULL,
|
| 30 |
+
trade_direction TEXT NOT NULL,
|
| 31 |
+
trade_date TIMESTAMP NOT NULL,
|
| 32 |
+
importer_name TEXT,
|
| 33 |
+
exporter_name TEXT,
|
| 34 |
+
hs_code TEXT,
|
| 35 |
+
product_name TEXT,
|
| 36 |
+
amount REAL,
|
| 37 |
+
currency TEXT,
|
| 38 |
+
weight REAL,
|
| 39 |
+
weight_unit TEXT,
|
| 40 |
+
origin_country TEXT,
|
| 41 |
+
destination_country TEXT,
|
| 42 |
+
departure_port TEXT,
|
| 43 |
+
arrival_port TEXT,
|
| 44 |
+
transport_mode TEXT,
|
| 45 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 46 |
+
updated_at TIMESTAMP
|
| 47 |
+
)
|
| 48 |
+
""")
|
| 49 |
+
|
| 50 |
+
# 创建索引
|
| 51 |
+
cursor.execute("CREATE INDEX IF NOT EXISTS idx_source_country ON standard_trade_records(source_country)")
|
| 52 |
+
cursor.execute("CREATE INDEX IF NOT EXISTS idx_trade_date ON standard_trade_records(trade_date)")
|
| 53 |
+
cursor.execute("CREATE INDEX IF NOT EXISTS idx_hs_code ON standard_trade_records(hs_code)")
|
| 54 |
+
cursor.execute("CREATE INDEX IF NOT EXISTS idx_trade_direction ON standard_trade_records(trade_direction)")
|
| 55 |
+
|
| 56 |
+
conn.commit()
|
| 57 |
+
return conn
|
| 58 |
+
|
| 59 |
+
def import_csv_data(conn):
|
| 60 |
+
"""从CSV导入数据"""
|
| 61 |
+
print(f"导入CSV数据: {CSV_PATH}")
|
| 62 |
+
|
| 63 |
+
if not Path(CSV_PATH).exists():
|
| 64 |
+
print(f"警告: CSV文件不存在 {CSV_PATH}")
|
| 65 |
+
return
|
| 66 |
+
|
| 67 |
+
cursor = conn.cursor()
|
| 68 |
+
|
| 69 |
+
with open(CSV_PATH, 'r', encoding='utf-8') as f:
|
| 70 |
+
reader = csv.DictReader(f)
|
| 71 |
+
count = 0
|
| 72 |
+
batch = []
|
| 73 |
+
|
| 74 |
+
for row in reader:
|
| 75 |
+
batch.append((
|
| 76 |
+
row['record_id'],
|
| 77 |
+
row['source_record_id'],
|
| 78 |
+
row['batch_no'],
|
| 79 |
+
row['source_country'],
|
| 80 |
+
row['trade_direction'],
|
| 81 |
+
row['trade_date'],
|
| 82 |
+
row['importer_name'] or None,
|
| 83 |
+
row['exporter_name'] or None,
|
| 84 |
+
row['hs_code'] or None,
|
| 85 |
+
row['product_name'] or None,
|
| 86 |
+
float(row['amount']) if row['amount'] else None,
|
| 87 |
+
row['currency'] or None,
|
| 88 |
+
float(row['weight']) if row['weight'] else None,
|
| 89 |
+
row['weight_unit'] or None,
|
| 90 |
+
row['origin_country'] or None,
|
| 91 |
+
row['destination_country'] or None,
|
| 92 |
+
row['departure_port'] or None,
|
| 93 |
+
row['arrival_port'] or None,
|
| 94 |
+
row['transport_mode'] or None,
|
| 95 |
+
row['created_at'],
|
| 96 |
+
row.get('updated_at')
|
| 97 |
+
))
|
| 98 |
+
|
| 99 |
+
count += 1
|
| 100 |
+
|
| 101 |
+
# 每1000条批量插入
|
| 102 |
+
if len(batch) >= 1000:
|
| 103 |
+
cursor.executemany("""
|
| 104 |
+
INSERT INTO standard_trade_records VALUES (
|
| 105 |
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
| 106 |
+
)
|
| 107 |
+
""", batch)
|
| 108 |
+
conn.commit()
|
| 109 |
+
print(f"已导入 {count} 条记录...")
|
| 110 |
+
batch = []
|
| 111 |
+
|
| 112 |
+
# 插入剩余数据
|
| 113 |
+
if batch:
|
| 114 |
+
cursor.executemany("""
|
| 115 |
+
INSERT INTO standard_trade_records VALUES (
|
| 116 |
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
| 117 |
+
)
|
| 118 |
+
""", batch)
|
| 119 |
+
conn.commit()
|
| 120 |
+
|
| 121 |
+
print(f"总共导入 {count} 条记录")
|
| 122 |
+
|
| 123 |
+
def main():
|
| 124 |
+
"""主函数"""
|
| 125 |
+
print("=== 初始化SQLite数据库 ===\n")
|
| 126 |
+
|
| 127 |
+
conn = create_database()
|
| 128 |
+
import_csv_data(conn)
|
| 129 |
+
|
| 130 |
+
# 验证数据
|
| 131 |
+
cursor = conn.cursor()
|
| 132 |
+
cursor.execute("SELECT COUNT(*) FROM standard_trade_records")
|
| 133 |
+
total = cursor.fetchone()[0]
|
| 134 |
+
print(f"\n数据库中共有 {total} 条记录")
|
| 135 |
+
|
| 136 |
+
cursor.execute("SELECT COUNT(*) FROM standard_trade_records WHERE source_country='BR'")
|
| 137 |
+
br_count = cursor.fetchone()[0]
|
| 138 |
+
print(f"巴西数据: {br_count} 条")
|
| 139 |
+
|
| 140 |
+
conn.close()
|
| 141 |
+
print("\n✅ 数据库初始化完成!")
|
| 142 |
+
|
| 143 |
+
if __name__ == "__main__":
|
| 144 |
+
main()
|
logs/app_2026-06-16.log
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
packages/connectors/__pycache__/base.cpython-314.pyc
CHANGED
|
Binary files a/packages/connectors/__pycache__/base.cpython-314.pyc and b/packages/connectors/__pycache__/base.cpython-314.pyc differ
|
|
|
packages/connectors/base.py
CHANGED
|
@@ -58,14 +58,18 @@ class BaseConnector:
|
|
| 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 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
# 更新已存在记录的 ID,对于不存在的插入
|
| 69 |
to_insert = []
|
| 70 |
for r in raw_records:
|
| 71 |
if r.content_hash in existing_records:
|
|
@@ -81,9 +85,12 @@ class BaseConnector:
|
|
| 81 |
})
|
| 82 |
|
| 83 |
if to_insert:
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
return raw_records
|
| 89 |
|
|
@@ -99,32 +106,39 @@ class BaseConnector:
|
|
| 99 |
if std_records:
|
| 100 |
# 1. 保存到 PostgreSQL (作为记录与元数据底座)
|
| 101 |
from sqlalchemy.dialects.postgresql import insert
|
| 102 |
-
pg_records = [{
|
| 103 |
-
"record_id": r.record_id,
|
| 104 |
-
"source_record_id": r.source_record_id,
|
| 105 |
-
"batch_no": r.batch_no,
|
| 106 |
-
"source_country": r.source_country,
|
| 107 |
-
"trade_direction": r.trade_direction,
|
| 108 |
-
"trade_date": r.trade_date,
|
| 109 |
-
"importer_name": r.importer_name,
|
| 110 |
-
"exporter_name": r.exporter_name,
|
| 111 |
-
"hs_code": r.hs_code,
|
| 112 |
-
"product_name": r.product_name,
|
| 113 |
-
"amount": r.amount,
|
| 114 |
-
"currency": r.currency,
|
| 115 |
-
"weight": r.weight,
|
| 116 |
-
"weight_unit": r.weight_unit,
|
| 117 |
-
"origin_country": r.origin_country,
|
| 118 |
-
"destination_country": r.destination_country,
|
| 119 |
-
"departure_port": r.departure_port,
|
| 120 |
-
"arrival_port": r.arrival_port,
|
| 121 |
-
"transport_mode": r.transport_mode
|
| 122 |
-
} for r in std_records]
|
| 123 |
-
|
| 124 |
-
stmt = insert(StandardTradeRecord).values(pg_records).on_conflict_do_nothing(index_elements=["record_id"])
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
# 2. 双写同步至 ClickHouse (OLAP 聚合分析)
|
| 130 |
try:
|
|
|
|
| 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 |
+
existing_records = {}
|
| 65 |
+
BATCH_SIZE = 1000
|
| 66 |
+
for i in range(0, len(hashes), BATCH_SIZE):
|
| 67 |
+
batch_hashes = hashes[i:i + BATCH_SIZE]
|
| 68 |
+
stmt = select(RawTradeRecord).where(RawTradeRecord.content_hash.in_(batch_hashes))
|
| 69 |
+
result = await self.session.execute(stmt)
|
| 70 |
+
existing_records.update({r.content_hash: r.id for r in result.scalars()})
|
| 71 |
|
| 72 |
+
# 更新已存在记录的 ID,对于不存在的插入(分批插入)
|
| 73 |
to_insert = []
|
| 74 |
for r in raw_records:
|
| 75 |
if r.content_hash in existing_records:
|
|
|
|
| 85 |
})
|
| 86 |
|
| 87 |
if to_insert:
|
| 88 |
+
for i in range(0, len(to_insert), BATCH_SIZE):
|
| 89 |
+
batch = to_insert[i:i + BATCH_SIZE]
|
| 90 |
+
stmt = insert(RawTradeRecord).values(batch).on_conflict_do_nothing(index_elements=["content_hash"])
|
| 91 |
+
await self.session.execute(stmt)
|
| 92 |
+
await self.session.flush()
|
| 93 |
+
app_logger.info(f"Saved raw batch {i//BATCH_SIZE + 1}/{(len(to_insert)-1)//BATCH_SIZE + 1} ({len(batch)} records)")
|
| 94 |
|
| 95 |
return raw_records
|
| 96 |
|
|
|
|
| 106 |
if std_records:
|
| 107 |
# 1. 保存到 PostgreSQL (作为记录与元数据底座)
|
| 108 |
from sqlalchemy.dialects.postgresql import insert
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
+
# 分批插入,每批最多1000条,避免参数过多
|
| 111 |
+
BATCH_SIZE = 1000
|
| 112 |
+
for i in range(0, len(std_records), BATCH_SIZE):
|
| 113 |
+
batch = std_records[i:i + BATCH_SIZE]
|
| 114 |
+
pg_records = [{
|
| 115 |
+
"record_id": r.record_id,
|
| 116 |
+
"source_record_id": r.source_record_id,
|
| 117 |
+
"batch_no": r.batch_no,
|
| 118 |
+
"source_country": r.source_country,
|
| 119 |
+
"trade_direction": r.trade_direction,
|
| 120 |
+
"trade_date": r.trade_date,
|
| 121 |
+
"importer_name": r.importer_name,
|
| 122 |
+
"exporter_name": r.exporter_name,
|
| 123 |
+
"hs_code": r.hs_code,
|
| 124 |
+
"product_name": r.product_name,
|
| 125 |
+
"amount": r.amount,
|
| 126 |
+
"currency": r.currency,
|
| 127 |
+
"weight": r.weight,
|
| 128 |
+
"weight_unit": r.weight_unit,
|
| 129 |
+
"origin_country": r.origin_country,
|
| 130 |
+
"destination_country": r.destination_country,
|
| 131 |
+
"departure_port": r.departure_port,
|
| 132 |
+
"arrival_port": r.arrival_port,
|
| 133 |
+
"transport_mode": r.transport_mode
|
| 134 |
+
} for r in batch]
|
| 135 |
+
|
| 136 |
+
stmt = insert(StandardTradeRecord).values(pg_records).on_conflict_do_nothing(index_elements=["record_id"])
|
| 137 |
+
|
| 138 |
+
await self.session.execute(stmt)
|
| 139 |
+
await self.session.commit()
|
| 140 |
+
|
| 141 |
+
app_logger.info(f"Saved batch {i//BATCH_SIZE + 1}/{(len(std_records)-1)//BATCH_SIZE + 1} ({len(batch)} records)")
|
| 142 |
|
| 143 |
# 2. 双写同步至 ClickHouse (OLAP 聚合分析)
|
| 144 |
try:
|
packages/connectors/mock/__pycache__/extended_mock.cpython-314.pyc
CHANGED
|
Binary files a/packages/connectors/mock/__pycache__/extended_mock.cpython-314.pyc and b/packages/connectors/mock/__pycache__/extended_mock.cpython-314.pyc differ
|
|
|
packages/connectors/mock/extended_mock.py
CHANGED
|
@@ -28,8 +28,8 @@ class ExtendedMockConnector(BaseConnector):
|
|
| 28 |
self.config = COUNTRY_CONFIGS[country_code]
|
| 29 |
|
| 30 |
async def discover(self) -> list:
|
| 31 |
-
# 每次模拟发现
|
| 32 |
-
count = random.randint(
|
| 33 |
return [{"id": str(uuid.uuid4())} for _ in range(count)]
|
| 34 |
|
| 35 |
async def parse(self, raw_data: dict) -> dict:
|
|
|
|
| 28 |
self.config = COUNTRY_CONFIGS[country_code]
|
| 29 |
|
| 30 |
async def discover(self) -> list:
|
| 31 |
+
# 每次模拟发现 100-200 条新数据
|
| 32 |
+
count = random.randint(100, 200)
|
| 33 |
return [{"id": str(uuid.uuid4())} for _ in range(count)]
|
| 34 |
|
| 35 |
async def parse(self, raw_data: dict) -> dict:
|
packages/core/__pycache__/entity_resolution.cpython-314.pyc
ADDED
|
Binary file (7.78 kB). View file
|
|
|
packages/core/__pycache__/http_client.cpython-314.pyc
CHANGED
|
Binary files a/packages/core/__pycache__/http_client.cpython-314.pyc and b/packages/core/__pycache__/http_client.cpython-314.pyc differ
|
|
|
packages/core/__pycache__/nlp.cpython-314.pyc
ADDED
|
Binary file (2.38 kB). View file
|
|
|
packages/core/__pycache__/subscription_matcher.cpython-314.pyc
CHANGED
|
Binary files a/packages/core/__pycache__/subscription_matcher.cpython-314.pyc and b/packages/core/__pycache__/subscription_matcher.cpython-314.pyc differ
|
|
|
packages/core/config_hf.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 2 |
+
from typing import Optional
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
class Settings(BaseSettings):
|
| 6 |
+
# 项目基础配置
|
| 7 |
+
PROJECT_NAME: str = "海关数据查询系统"
|
| 8 |
+
VERSION: str = "0.1.0"
|
| 9 |
+
|
| 10 |
+
# 数据库配置 - 使用SQLite
|
| 11 |
+
DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///data/customs_data.db")
|
| 12 |
+
|
| 13 |
+
# 禁用其他数据库(Hugging Face Spaces不需要)
|
| 14 |
+
CLICKHOUSE_URL: Optional[str] = None
|
| 15 |
+
ELASTICSEARCH_URL: Optional[str] = None
|
| 16 |
+
REDIS_URL: Optional[str] = None
|
| 17 |
+
|
| 18 |
+
# 代理池配置
|
| 19 |
+
PROXY_POOL_URL: Optional[str] = None
|
| 20 |
+
|
| 21 |
+
# S3 / 对象存储配置
|
| 22 |
+
S3_ENDPOINT: Optional[str] = None
|
| 23 |
+
S3_ACCESS_KEY: Optional[str] = None
|
| 24 |
+
S3_SECRET_KEY: Optional[str] = None
|
| 25 |
+
S3_BUCKET_NAME: str = "customs-raw-data"
|
| 26 |
+
|
| 27 |
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
| 28 |
+
|
| 29 |
+
settings = Settings()
|
packages/core/http_client.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import httpx
|
| 2 |
import asyncio
|
|
|
|
| 3 |
from typing import Optional, Dict, Any
|
| 4 |
from packages.core.logger import app_logger
|
| 5 |
from packages.core.config import settings
|
|
@@ -21,7 +22,14 @@ class BaseHttpClient:
|
|
| 21 |
async def _get_proxy(self) -> Optional[str]:
|
| 22 |
"""
|
| 23 |
获取动态代理(预留给未来的代理池接口)
|
|
|
|
| 24 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
if self.use_proxy and hasattr(settings, "PROXY_POOL_URL") and settings.PROXY_POOL_URL:
|
| 26 |
try:
|
| 27 |
# 假设代理池 API 返回格式为: {"proxy": "http://ip:port"} 或者直接是 "ip:port"
|
|
|
|
| 1 |
import httpx
|
| 2 |
import asyncio
|
| 3 |
+
import os
|
| 4 |
from typing import Optional, Dict, Any
|
| 5 |
from packages.core.logger import app_logger
|
| 6 |
from packages.core.config import settings
|
|
|
|
| 22 |
async def _get_proxy(self) -> Optional[str]:
|
| 23 |
"""
|
| 24 |
获取动态代理(预留给未来的代理池接口)
|
| 25 |
+
优先使用环境变量 HTTP_PROXY/HTTPS_PROXY
|
| 26 |
"""
|
| 27 |
+
# 首先检查环境变量
|
| 28 |
+
env_proxy = os.environ.get('HTTPS_PROXY') or os.environ.get('HTTP_PROXY') or os.environ.get('https_proxy') or os.environ.get('http_proxy')
|
| 29 |
+
if env_proxy:
|
| 30 |
+
app_logger.debug(f"Using proxy from environment: {env_proxy}")
|
| 31 |
+
return env_proxy
|
| 32 |
+
|
| 33 |
if self.use_proxy and hasattr(settings, "PROXY_POOL_URL") and settings.PROXY_POOL_URL:
|
| 34 |
try:
|
| 35 |
# 假设代理池 API 返回格式为: {"proxy": "http://ip:port"} 或者直接是 "ip:port"
|
packages/dictionaries/__pycache__/brazil.cpython-314.pyc
CHANGED
|
Binary files a/packages/dictionaries/__pycache__/brazil.cpython-314.pyc and b/packages/dictionaries/__pycache__/brazil.cpython-314.pyc differ
|
|
|
packages/dictionaries/brazil.py
CHANGED
|
@@ -111,60 +111,85 @@ async def get_ncm_name(code: Optional[str]) -> Optional[str]:
|
|
| 111 |
async def _get_country_lookup() -> Dict[str, str]:
|
| 112 |
global _COUNTRY_ALPHA3_BY_CODE
|
| 113 |
if _COUNTRY_ALPHA3_BY_CODE is None:
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
return _COUNTRY_ALPHA3_BY_CODE
|
| 121 |
|
| 122 |
|
| 123 |
async def _get_urf_lookup() -> Dict[str, str]:
|
| 124 |
global _URF_NAME_BY_CODE
|
| 125 |
if _URF_NAME_BY_CODE is None:
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
return _URF_NAME_BY_CODE
|
| 133 |
|
| 134 |
|
| 135 |
async def _get_uf_lookup() -> Dict[str, str]:
|
| 136 |
global _UF_NAME_BY_CODE
|
| 137 |
if _UF_NAME_BY_CODE is None:
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
return _UF_NAME_BY_CODE
|
| 145 |
|
| 146 |
|
| 147 |
async def _get_via_lookup() -> Dict[str, str]:
|
| 148 |
global _VIA_NAME_BY_CODE
|
| 149 |
if _VIA_NAME_BY_CODE is None:
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
return _VIA_NAME_BY_CODE
|
| 157 |
|
| 158 |
|
| 159 |
async def _get_ncm_lookup() -> Dict[str, str]:
|
| 160 |
global _NCM_NAME_BY_CODE
|
| 161 |
if _NCM_NAME_BY_CODE is None:
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
return _NCM_NAME_BY_CODE
|
| 169 |
|
| 170 |
|
|
|
|
| 111 |
async def _get_country_lookup() -> Dict[str, str]:
|
| 112 |
global _COUNTRY_ALPHA3_BY_CODE
|
| 113 |
if _COUNTRY_ALPHA3_BY_CODE is None:
|
| 114 |
+
try:
|
| 115 |
+
rows = await _load_csv_rows("PAIS.csv")
|
| 116 |
+
_COUNTRY_ALPHA3_BY_CODE = {
|
| 117 |
+
_normalize_code(row.get("CO_PAIS")): (row.get("CO_PAIS_ISOA3") or "").strip().upper()
|
| 118 |
+
for row in rows
|
| 119 |
+
if row.get("CO_PAIS")
|
| 120 |
+
}
|
| 121 |
+
except Exception as e:
|
| 122 |
+
from packages.core.logger import app_logger
|
| 123 |
+
app_logger.warning(f"Failed to load PAIS.csv, using empty lookup: {e}")
|
| 124 |
+
_COUNTRY_ALPHA3_BY_CODE = {}
|
| 125 |
return _COUNTRY_ALPHA3_BY_CODE
|
| 126 |
|
| 127 |
|
| 128 |
async def _get_urf_lookup() -> Dict[str, str]:
|
| 129 |
global _URF_NAME_BY_CODE
|
| 130 |
if _URF_NAME_BY_CODE is None:
|
| 131 |
+
try:
|
| 132 |
+
rows = await _load_csv_rows("URF.csv")
|
| 133 |
+
_URF_NAME_BY_CODE = {
|
| 134 |
+
_normalize_code(row.get("CO_URF")): (row.get("NO_URF") or "").strip()
|
| 135 |
+
for row in rows
|
| 136 |
+
if row.get("CO_URF")
|
| 137 |
+
}
|
| 138 |
+
except Exception as e:
|
| 139 |
+
from packages.core.logger import app_logger
|
| 140 |
+
app_logger.warning(f"Failed to load URF.csv, using empty lookup: {e}")
|
| 141 |
+
_URF_NAME_BY_CODE = {}
|
| 142 |
return _URF_NAME_BY_CODE
|
| 143 |
|
| 144 |
|
| 145 |
async def _get_uf_lookup() -> Dict[str, str]:
|
| 146 |
global _UF_NAME_BY_CODE
|
| 147 |
if _UF_NAME_BY_CODE is None:
|
| 148 |
+
try:
|
| 149 |
+
rows = await _load_csv_rows("UF.csv")
|
| 150 |
+
_UF_NAME_BY_CODE = {
|
| 151 |
+
(row.get("SG_UF") or "").strip().upper(): (row.get("NO_UF") or "").strip()
|
| 152 |
+
for row in rows
|
| 153 |
+
if row.get("SG_UF")
|
| 154 |
+
}
|
| 155 |
+
except Exception as e:
|
| 156 |
+
from packages.core.logger import app_logger
|
| 157 |
+
app_logger.warning(f"Failed to load UF.csv, using empty lookup: {e}")
|
| 158 |
+
_UF_NAME_BY_CODE = {}
|
| 159 |
return _UF_NAME_BY_CODE
|
| 160 |
|
| 161 |
|
| 162 |
async def _get_via_lookup() -> Dict[str, str]:
|
| 163 |
global _VIA_NAME_BY_CODE
|
| 164 |
if _VIA_NAME_BY_CODE is None:
|
| 165 |
+
try:
|
| 166 |
+
rows = await _load_csv_rows("VIA.csv")
|
| 167 |
+
_VIA_NAME_BY_CODE = {
|
| 168 |
+
_normalize_code(row.get("CO_VIA")): (row.get("NO_VIA") or "").strip()
|
| 169 |
+
for row in rows
|
| 170 |
+
if row.get("CO_VIA")
|
| 171 |
+
}
|
| 172 |
+
except Exception as e:
|
| 173 |
+
from packages.core.logger import app_logger
|
| 174 |
+
app_logger.warning(f"Failed to load VIA.csv, using empty lookup: {e}")
|
| 175 |
+
_VIA_NAME_BY_CODE = {}
|
| 176 |
return _VIA_NAME_BY_CODE
|
| 177 |
|
| 178 |
|
| 179 |
async def _get_ncm_lookup() -> Dict[str, str]:
|
| 180 |
global _NCM_NAME_BY_CODE
|
| 181 |
if _NCM_NAME_BY_CODE is None:
|
| 182 |
+
try:
|
| 183 |
+
rows = await _load_csv_rows("NCM.csv")
|
| 184 |
+
_NCM_NAME_BY_CODE = {
|
| 185 |
+
_normalize_code(row.get("CO_NCM")): (row.get("NO_NCM_POR") or "").strip()
|
| 186 |
+
for row in rows
|
| 187 |
+
if row.get("CO_NCM")
|
| 188 |
+
}
|
| 189 |
+
except Exception as e:
|
| 190 |
+
from packages.core.logger import app_logger
|
| 191 |
+
app_logger.warning(f"Failed to load NCM.csv, using empty lookup: {e}")
|
| 192 |
+
_NCM_NAME_BY_CODE = {}
|
| 193 |
return _NCM_NAME_BY_CODE
|
| 194 |
|
| 195 |
|
packages/normalizers/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (168 Bytes). View file
|
|
|
packages/normalizers/__pycache__/company.cpython-314.pyc
ADDED
|
Binary file (1.33 kB). View file
|
|
|
requirements.txt
CHANGED
|
@@ -3,16 +3,16 @@ uvicorn[standard]==0.30.1
|
|
| 3 |
sqlalchemy>=2.0.50
|
| 4 |
asyncpg>=0.31.0
|
| 5 |
alembic==1.13.1
|
| 6 |
-
pydantic=
|
| 7 |
pydantic-settings==2.3.4
|
| 8 |
python-multipart==0.0.9
|
| 9 |
celery==5.4.0
|
| 10 |
redis==5.0.4
|
| 11 |
httpx==0.27.0
|
| 12 |
aiofiles==24.1.0
|
| 13 |
-
clickhouse-connect=
|
| 14 |
elasticsearch==8.14.0
|
| 15 |
boto3==1.34.131
|
| 16 |
loguru==0.7.2
|
| 17 |
-
aiohttp=
|
| 18 |
pytest==9.0.3
|
|
|
|
| 3 |
sqlalchemy>=2.0.50
|
| 4 |
asyncpg>=0.31.0
|
| 5 |
alembic==1.13.1
|
| 6 |
+
pydantic>=2.7.4
|
| 7 |
pydantic-settings==2.3.4
|
| 8 |
python-multipart==0.0.9
|
| 9 |
celery==5.4.0
|
| 10 |
redis==5.0.4
|
| 11 |
httpx==0.27.0
|
| 12 |
aiofiles==24.1.0
|
| 13 |
+
clickhouse-connect>=0.7.8
|
| 14 |
elasticsearch==8.14.0
|
| 15 |
boto3==1.34.131
|
| 16 |
loguru==0.7.2
|
| 17 |
+
aiohttp>=3.9.5
|
| 18 |
pytest==9.0.3
|