Spaces:
Sleeping
Sleeping
Commit
·
954be92
1
Parent(s):
b65cf93
Add application file
Browse files- .dockerignore +75 -0
- .env +17 -0
- .env.example +17 -0
- Dockerfile +53 -0
- README.md +231 -10
- app/__init__.py +1 -0
- app/__pycache__/__init__.cpython-311.pyc +0 -0
- app/__pycache__/config.cpython-311.pyc +0 -0
- app/__pycache__/main.cpython-311.pyc +0 -0
- app/__pycache__/models.cpython-311.pyc +0 -0
- app/api/__init__.py +1 -0
- app/api/__pycache__/__init__.cpython-311.pyc +0 -0
- app/api/__pycache__/routes.cpython-311.pyc +0 -0
- app/api/routes.py +295 -0
- app/config.py +58 -0
- app/main.py +138 -0
- app/models.py +116 -0
- app/services/__init__.py +1 -0
- app/services/__pycache__/__init__.cpython-311.pyc +0 -0
- app/services/__pycache__/exchange_service.cpython-311.pyc +0 -0
- app/services/__pycache__/scheduler.cpython-311.pyc +0 -0
- app/services/exchange_service.py +307 -0
- app/services/scheduler.py +93 -0
- app/utils/__init__.py +1 -0
- app/utils/__pycache__/__init__.cpython-311.pyc +0 -0
- app/utils/__pycache__/currency_names.cpython-311.pyc +0 -0
- app/utils/__pycache__/logger.cpython-311.pyc +0 -0
- app/utils/currency_names.py +185 -0
- app/utils/logger.py +31 -0
- docker-compose.yaml +79 -0
- logs/app_2025-12-05.log +41 -0
- requirements.txt +22 -0
- run.py +34 -0
- static/css/style.css +475 -0
- static/js/app.js +445 -0
- templates/index.html +84 -0
.dockerignore
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ========================================
|
| 2 |
+
# Docker 构建忽略文件
|
| 3 |
+
# ========================================
|
| 4 |
+
|
| 5 |
+
# Python 缓存
|
| 6 |
+
__pycache__/
|
| 7 |
+
*.py[cod]
|
| 8 |
+
*$py.class
|
| 9 |
+
*.so
|
| 10 |
+
.Python
|
| 11 |
+
build/
|
| 12 |
+
develop-eggs/
|
| 13 |
+
dist/
|
| 14 |
+
downloads/
|
| 15 |
+
eggs/
|
| 16 |
+
.eggs/
|
| 17 |
+
lib/
|
| 18 |
+
lib64/
|
| 19 |
+
parts/
|
| 20 |
+
sdist/
|
| 21 |
+
var/
|
| 22 |
+
wheels/
|
| 23 |
+
*.egg-info/
|
| 24 |
+
.installed.cfg
|
| 25 |
+
*.egg
|
| 26 |
+
|
| 27 |
+
# 虚拟环境
|
| 28 |
+
.venv/
|
| 29 |
+
venv/
|
| 30 |
+
ENV/
|
| 31 |
+
env/
|
| 32 |
+
|
| 33 |
+
# IDE 配置
|
| 34 |
+
.idea/
|
| 35 |
+
.vscode/
|
| 36 |
+
*.swp
|
| 37 |
+
*.swo
|
| 38 |
+
*~
|
| 39 |
+
|
| 40 |
+
# 环境变量文件(保护敏感信息)
|
| 41 |
+
.env
|
| 42 |
+
.env.local
|
| 43 |
+
.env.*.local
|
| 44 |
+
|
| 45 |
+
# Git
|
| 46 |
+
.git/
|
| 47 |
+
.gitignore
|
| 48 |
+
|
| 49 |
+
# 日志文件
|
| 50 |
+
logs/
|
| 51 |
+
*.log
|
| 52 |
+
|
| 53 |
+
# 测试相关
|
| 54 |
+
.pytest_cache/
|
| 55 |
+
.coverage
|
| 56 |
+
htmlcov/
|
| 57 |
+
.tox/
|
| 58 |
+
.nox/
|
| 59 |
+
|
| 60 |
+
# 文档
|
| 61 |
+
docs/
|
| 62 |
+
*.md
|
| 63 |
+
!README.md
|
| 64 |
+
|
| 65 |
+
# Docker 相关
|
| 66 |
+
Dockerfile
|
| 67 |
+
docker-compose*.yaml
|
| 68 |
+
docker-compose*.yml
|
| 69 |
+
.dockerignore
|
| 70 |
+
|
| 71 |
+
# 其他
|
| 72 |
+
.DS_Store
|
| 73 |
+
Thumbs.db
|
| 74 |
+
*.bak
|
| 75 |
+
*.tmp
|
.env
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API Keys - 多个 key 用逗号分隔
|
| 2 |
+
API_KEYS=94d36633f4fe0768058986b5
|
| 3 |
+
|
| 4 |
+
# 基准货币
|
| 5 |
+
BASE_CURRENCY=CNY
|
| 6 |
+
|
| 7 |
+
# 缓存更新间隔(秒)- 默认 3600 秒(1小时)
|
| 8 |
+
CACHE_UPDATE_INTERVAL=3600
|
| 9 |
+
|
| 10 |
+
# API 请求超时时间(秒)
|
| 11 |
+
REQUEST_TIMEOUT=10
|
| 12 |
+
|
| 13 |
+
# 最大重试次数
|
| 14 |
+
MAX_RETRIES=3
|
| 15 |
+
|
| 16 |
+
# 服务端口
|
| 17 |
+
SERVER_PORT=8000
|
.env.example
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API Keys - 多个 key 用逗号分隔
|
| 2 |
+
API_KEYS=your_api_key_1,your_api_key_2,your_api_key_3
|
| 3 |
+
|
| 4 |
+
# 基准货币
|
| 5 |
+
BASE_CURRENCY=CNY
|
| 6 |
+
|
| 7 |
+
# 缓存更新间隔(秒)- 默认 3600 秒(1小时)
|
| 8 |
+
CACHE_UPDATE_INTERVAL=3600
|
| 9 |
+
|
| 10 |
+
# API 请求超时时间(秒)
|
| 11 |
+
REQUEST_TIMEOUT=10
|
| 12 |
+
|
| 13 |
+
# 最大重试次数
|
| 14 |
+
MAX_RETRIES=3
|
| 15 |
+
|
| 16 |
+
# 服务端口
|
| 17 |
+
SERVER_PORT=8000
|
Dockerfile
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ========================================
|
| 2 |
+
# 汇率换算服务 Dockerfile
|
| 3 |
+
# 基于 Python 3.11 构建
|
| 4 |
+
# ========================================
|
| 5 |
+
|
| 6 |
+
# 使用官方 Python 镜像作为基础镜像
|
| 7 |
+
FROM python:3.11-slim
|
| 8 |
+
|
| 9 |
+
# 设置工作目录
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
# 设置环境变量
|
| 13 |
+
# 防止 Python 生成 .pyc 文件
|
| 14 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 15 |
+
# 防止 Python 缓冲 stdout 和 stderr
|
| 16 |
+
ENV PYTHONUNBUFFERED=1
|
| 17 |
+
# 设置时区为中国时区
|
| 18 |
+
ENV TZ=Asia/Shanghai
|
| 19 |
+
|
| 20 |
+
# 安装系统依赖
|
| 21 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 22 |
+
tzdata \
|
| 23 |
+
&& rm -rf /var/lib/apt/lists/* \
|
| 24 |
+
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
|
| 25 |
+
&& echo $TZ > /etc/timezone
|
| 26 |
+
|
| 27 |
+
# 复制依赖文件
|
| 28 |
+
COPY requirements.txt .
|
| 29 |
+
|
| 30 |
+
# 安装 Python 依赖
|
| 31 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 32 |
+
|
| 33 |
+
# 复制项目代码
|
| 34 |
+
COPY app/ ./app/
|
| 35 |
+
COPY static/ ./static/
|
| 36 |
+
COPY templates/ ./templates/
|
| 37 |
+
COPY run.py .
|
| 38 |
+
|
| 39 |
+
# 创建非 root 用户运行应用(安全最佳实践)
|
| 40 |
+
RUN adduser --disabled-password --gecos "" appuser \
|
| 41 |
+
&& chown -R appuser:appuser /app
|
| 42 |
+
|
| 43 |
+
USER appuser
|
| 44 |
+
|
| 45 |
+
# 暴露端口
|
| 46 |
+
EXPOSE 8000
|
| 47 |
+
|
| 48 |
+
# 健康检查
|
| 49 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
| 50 |
+
CMD python -c "import httpx; httpx.get('http://localhost:8000/health', timeout=5)" || exit 1
|
| 51 |
+
|
| 52 |
+
# 启动命令
|
| 53 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
CHANGED
|
@@ -1,10 +1,231 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 💱 汇率换算服务
|
| 2 |
+
|
| 3 |
+
基于 Python + FastAPI 开发的实时汇率换算服务,支持多 API Key 轮询、数据缓存、实时换算等功能。
|
| 4 |
+
|
| 5 |
+
## ✨ 功能特性
|
| 6 |
+
|
| 7 |
+
- 🔄 **多 API Key 轮询** - 支持配置多个 API Key,自动轮询切换,防止单个 Key 超限
|
| 8 |
+
- 💾 **智能缓存** - 定时任务自动更新汇率数据,避免频繁请求 API
|
| 9 |
+
- 🌍 **161 种货币** - 支持全球 161 种货币的汇率查询和换算
|
| 10 |
+
- 💱 **实时换算** - 前端页面支持实时输入实时计算,用户体验流畅
|
| 11 |
+
- 🎨 **美观界面** - 响应式设计,支持搜索过滤,常用货币优先展示
|
| 12 |
+
- 📊 **RESTful API** - 提供完整的 API 接口,支持第三方集成
|
| 13 |
+
|
| 14 |
+
## 🛠️ 技术栈
|
| 15 |
+
|
| 16 |
+
- **后端**: Python 3.9+, FastAPI, Uvicorn
|
| 17 |
+
- **定时任务**: APScheduler
|
| 18 |
+
- **HTTP 客户端**: httpx
|
| 19 |
+
- **数据验证**: Pydantic
|
| 20 |
+
- **日志**: Loguru
|
| 21 |
+
- **前端**: 原生 HTML/CSS/JavaScript
|
| 22 |
+
|
| 23 |
+
## 📁 项目结构
|
| 24 |
+
|
| 25 |
+
```
|
| 26 |
+
exchangeRates/
|
| 27 |
+
├── app/
|
| 28 |
+
│ ├── __init__.py
|
| 29 |
+
│ ├── main.py # FastAPI 应用入口
|
| 30 |
+
│ ├── config.py # 配置管理
|
| 31 |
+
│ ├── models.py # 数据模型
|
| 32 |
+
│ ├── api/
|
| 33 |
+
│ │ ├── __init__.py
|
| 34 |
+
│ │ └── routes.py # API 路由
|
| 35 |
+
│ ├── services/
|
| 36 |
+
│ │ ├── __init__.py
|
| 37 |
+
│ │ ├── exchange_service.py # 汇率服务核心
|
| 38 |
+
│ │ └── scheduler.py # 定时任务
|
| 39 |
+
│ └── utils/
|
| 40 |
+
│ ├── __init__.py
|
| 41 |
+
│ ├── logger.py # 日志工具
|
| 42 |
+
│ └── currency_names.py # 货币名称映射
|
| 43 |
+
├── static/
|
| 44 |
+
│ ├── css/
|
| 45 |
+
│ │ └── style.css # 样式文件
|
| 46 |
+
│ └── js/
|
| 47 |
+
│ └── app.js # 前端交互
|
| 48 |
+
├── templates/
|
| 49 |
+
│ └── index.html # 前端页面
|
| 50 |
+
├── .env # 环境变量配置
|
| 51 |
+
├── .env.example # 环境变量示例
|
| 52 |
+
├── requirements.txt # Python 依赖
|
| 53 |
+
├── run.py # 启动脚本
|
| 54 |
+
└── README.md # 项目说明
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
## 🚀 快速开始
|
| 58 |
+
|
| 59 |
+
### 1. 克隆项目
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
cd C:\Users\Eleven\Desktop\exchangeRates
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
### 2. 创建虚拟环境
|
| 66 |
+
|
| 67 |
+
```bash
|
| 68 |
+
python -m venv .venv
|
| 69 |
+
.venv\Scripts\activate
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
### 3. 安装依赖
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
pip install -r requirements.txt
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
### 4. 配置环境变量
|
| 79 |
+
|
| 80 |
+
编辑 `.env` 文件,配置你的 API Key:
|
| 81 |
+
|
| 82 |
+
```env
|
| 83 |
+
# 多个 API Key 用逗号分隔
|
| 84 |
+
API_KEYS=your_api_key_1,your_api_key_2
|
| 85 |
+
|
| 86 |
+
# 基准货币
|
| 87 |
+
BASE_CURRENCY=CNY
|
| 88 |
+
|
| 89 |
+
# 缓存更新间隔(秒)
|
| 90 |
+
CACHE_UPDATE_INTERVAL=3600
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
### 5. 启动服务
|
| 94 |
+
|
| 95 |
+
```bash
|
| 96 |
+
python run.py
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
或者使用 uvicorn 直接启动:
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
### 6. 访问服务
|
| 106 |
+
|
| 107 |
+
- 🌐 **首页**: http://localhost:8000
|
| 108 |
+
- 📚 **API 文档**: http://localhost:8000/docs
|
| 109 |
+
- 📖 **ReDoc**: http://localhost:8000/redoc
|
| 110 |
+
|
| 111 |
+
## 📡 API 接口
|
| 112 |
+
|
| 113 |
+
### 获取所有汇率
|
| 114 |
+
|
| 115 |
+
```http
|
| 116 |
+
GET /api/rates
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
响应示例:
|
| 120 |
+
```json
|
| 121 |
+
{
|
| 122 |
+
"success": true,
|
| 123 |
+
"base_currency": "CNY",
|
| 124 |
+
"rates": {
|
| 125 |
+
"USD": 0.1413,
|
| 126 |
+
"EUR": 0.1212,
|
| 127 |
+
...
|
| 128 |
+
},
|
| 129 |
+
"last_update": "Fri, 05 Dec 2025 00:00:01 +0000",
|
| 130 |
+
"cached_at": "2025-12-05T10:30:00",
|
| 131 |
+
"currencies_count": 161
|
| 132 |
+
}
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
### 获取特定货币汇率
|
| 136 |
+
|
| 137 |
+
```http
|
| 138 |
+
GET /api/rates/{currency}
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
示例:`GET /api/rates/USD`
|
| 142 |
+
|
| 143 |
+
### 货币换算
|
| 144 |
+
|
| 145 |
+
```http
|
| 146 |
+
POST /api/convert
|
| 147 |
+
Content-Type: application/json
|
| 148 |
+
|
| 149 |
+
{
|
| 150 |
+
"from_currency": "USD",
|
| 151 |
+
"to_currency": "CNY",
|
| 152 |
+
"amount": 100
|
| 153 |
+
}
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
或使用 GET 方式:
|
| 157 |
+
|
| 158 |
+
```http
|
| 159 |
+
GET /api/convert/USD/CNY?amount=100
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
### 批量换算
|
| 163 |
+
|
| 164 |
+
```http
|
| 165 |
+
POST /api/batch-convert
|
| 166 |
+
Content-Type: application/json
|
| 167 |
+
|
| 168 |
+
{
|
| 169 |
+
"base_currency": "USD",
|
| 170 |
+
"amount": 100
|
| 171 |
+
}
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
或使用 GET 方式:
|
| 175 |
+
|
| 176 |
+
```http
|
| 177 |
+
GET /api/batch-convert/USD?amount=100
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
### 获取货币列表
|
| 181 |
+
|
| 182 |
+
```http
|
| 183 |
+
GET /api/currencies
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
### 服务状态
|
| 187 |
+
|
| 188 |
+
```http
|
| 189 |
+
GET /api/status
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
### 强制刷新缓存
|
| 193 |
+
|
| 194 |
+
```http
|
| 195 |
+
POST /api/refresh
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
## ⚙️ 配置说明
|
| 199 |
+
|
| 200 |
+
| 配置项 | 说明 | 默认值 |
|
| 201 |
+
|--------|------|--------|
|
| 202 |
+
| API_KEYS | API Key 列表(逗号分隔) | - |
|
| 203 |
+
| BASE_CURRENCY | 基准货币 | CNY |
|
| 204 |
+
| CACHE_UPDATE_INTERVAL | 缓存更新间隔(秒) | 3600 |
|
| 205 |
+
| REQUEST_TIMEOUT | 请求超时时间(秒) | 10 |
|
| 206 |
+
| MAX_RETRIES | 最大重试次数 | 3 |
|
| 207 |
+
| SERVER_PORT | 服务端口 | 8000 |
|
| 208 |
+
|
| 209 |
+
## 🔑 API Key 获取
|
| 210 |
+
|
| 211 |
+
1. 访问 [ExchangeRate-API](https://www.exchangerate-api.com/)
|
| 212 |
+
2. 注册账号(免费版每月 1500 次请求)
|
| 213 |
+
3. 获取 API Key
|
| 214 |
+
4. 配置到 `.env` 文件中
|
| 215 |
+
|
| 216 |
+
建议配置 3-5 个 API Key 以实现轮询,避免单个 Key 超限。
|
| 217 |
+
|
| 218 |
+
## 📝 注意事项
|
| 219 |
+
|
| 220 |
+
1. **API 限制**: 免费版 API 每月 1500 次请求,请合理设置缓存更新间隔
|
| 221 |
+
2. **缓存策略**: 汇率数据每日更新一次(UTC 00:00),建议缓存间隔设为 1 小时
|
| 222 |
+
3. **Key 管理**: 系统自动管理 Key 状态,失败的 Key 会进入冷却期
|
| 223 |
+
4. **错误处理**: API 请求失败时会自动重试并切换 Key
|
| 224 |
+
|
| 225 |
+
## 🤝 贡献
|
| 226 |
+
|
| 227 |
+
欢迎提交 Issue 和 Pull Request!
|
| 228 |
+
|
| 229 |
+
## 📄 许可证
|
| 230 |
+
|
| 231 |
+
MIT License
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Exchange Rates Application
|
app/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (162 Bytes). View file
|
|
|
app/__pycache__/config.cpython-311.pyc
ADDED
|
Binary file (2.89 kB). View file
|
|
|
app/__pycache__/main.cpython-311.pyc
ADDED
|
Binary file (5.48 kB). View file
|
|
|
app/__pycache__/models.cpython-311.pyc
ADDED
|
Binary file (6.35 kB). View file
|
|
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# API package
|
app/api/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (166 Bytes). View file
|
|
|
app/api/__pycache__/routes.cpython-311.pyc
ADDED
|
Binary file (10.4 kB). View file
|
|
|
app/api/routes.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API 路由定义
|
| 3 |
+
提供汇率查询和换算接口
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from app.models import (
|
| 9 |
+
RatesResponse,
|
| 10 |
+
CurrencyRateResponse,
|
| 11 |
+
ConvertRequest,
|
| 12 |
+
ConvertResponse,
|
| 13 |
+
BatchConvertRequest,
|
| 14 |
+
BatchConvertResponse,
|
| 15 |
+
CurrenciesResponse,
|
| 16 |
+
CurrencyInfo,
|
| 17 |
+
ServiceStatusResponse
|
| 18 |
+
)
|
| 19 |
+
from app.utils.currency_names import CURRENCY_INFO, get_currency_info
|
| 20 |
+
from app.utils.logger import logger
|
| 21 |
+
|
| 22 |
+
router = APIRouter(prefix="/api", tags=["汇率接口"])
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_exchange_service(request: Request):
|
| 26 |
+
"""从应用状态获取 ExchangeRateService 实例"""
|
| 27 |
+
return request.app.state.exchange_service
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ==================== 获取所有汇率 ====================
|
| 31 |
+
|
| 32 |
+
@router.get("/rates", response_model=RatesResponse)
|
| 33 |
+
async def get_all_rates(request: Request):
|
| 34 |
+
"""
|
| 35 |
+
获取所有货币汇率
|
| 36 |
+
|
| 37 |
+
返回缓存中的所有汇率数据,包括基准货币、汇率列表、更新时间等。
|
| 38 |
+
"""
|
| 39 |
+
service = get_exchange_service(request)
|
| 40 |
+
cached = service.get_cached_rates()
|
| 41 |
+
|
| 42 |
+
if not cached:
|
| 43 |
+
logger.warning("API /rates: 没有可用的缓存数据")
|
| 44 |
+
raise HTTPException(
|
| 45 |
+
status_code=503,
|
| 46 |
+
detail="汇率数据不可用,请稍后重试。"
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
return RatesResponse(
|
| 50 |
+
success=True,
|
| 51 |
+
base_currency=cached.base_code,
|
| 52 |
+
rates=cached.rates,
|
| 53 |
+
last_update=cached.last_update_utc,
|
| 54 |
+
cached_at=cached.cached_at.isoformat(),
|
| 55 |
+
currencies_count=len(cached.rates)
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ==================== 获取特定货币汇率 ====================
|
| 60 |
+
|
| 61 |
+
@router.get("/rates/{currency}", response_model=CurrencyRateResponse)
|
| 62 |
+
async def get_currency_rate(currency: str, request: Request):
|
| 63 |
+
"""
|
| 64 |
+
获取特定货币对基准货币的汇率
|
| 65 |
+
|
| 66 |
+
- **currency**: 货币代码(如 USD, EUR, JPY)
|
| 67 |
+
"""
|
| 68 |
+
service = get_exchange_service(request)
|
| 69 |
+
cached = service.get_cached_rates()
|
| 70 |
+
|
| 71 |
+
if not cached:
|
| 72 |
+
raise HTTPException(
|
| 73 |
+
status_code=503,
|
| 74 |
+
detail="汇率数据不可用"
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
currency = currency.upper()
|
| 78 |
+
|
| 79 |
+
if currency not in cached.rates:
|
| 80 |
+
raise HTTPException(
|
| 81 |
+
status_code=404,
|
| 82 |
+
detail=f"未找到货币 '{currency}'"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
return CurrencyRateResponse(
|
| 86 |
+
success=True,
|
| 87 |
+
base_currency=cached.base_code,
|
| 88 |
+
currency=currency,
|
| 89 |
+
rate=cached.rates[currency]
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ==================== 两种货币换算 ====================
|
| 94 |
+
|
| 95 |
+
@router.post("/convert", response_model=ConvertResponse)
|
| 96 |
+
async def convert_currency(request: Request, body: ConvertRequest):
|
| 97 |
+
"""
|
| 98 |
+
两种货币之间的汇率换算
|
| 99 |
+
|
| 100 |
+
- **from_currency**: 源货币代码
|
| 101 |
+
- **to_currency**: 目标货币代码
|
| 102 |
+
- **amount**: 换算金额
|
| 103 |
+
|
| 104 |
+
返回换算结果、汇率和反向汇率。
|
| 105 |
+
"""
|
| 106 |
+
service = get_exchange_service(request)
|
| 107 |
+
|
| 108 |
+
result = service.convert(
|
| 109 |
+
body.from_currency,
|
| 110 |
+
body.to_currency,
|
| 111 |
+
body.amount
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
if not result:
|
| 115 |
+
raise HTTPException(
|
| 116 |
+
status_code=400,
|
| 117 |
+
detail="换算失败,请检查货币代码是否有效。"
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
return result
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ==================== GET 方式的货币换算 ====================
|
| 124 |
+
|
| 125 |
+
@router.get("/convert/{from_currency}/{to_currency}")
|
| 126 |
+
async def convert_currency_get(
|
| 127 |
+
from_currency: str,
|
| 128 |
+
to_currency: str,
|
| 129 |
+
amount: float,
|
| 130 |
+
request: Request
|
| 131 |
+
):
|
| 132 |
+
"""
|
| 133 |
+
两种货币之间的汇率换算(GET 方式)
|
| 134 |
+
|
| 135 |
+
- **from_currency**: 源货币代码
|
| 136 |
+
- **to_currency**: 目标货币代码
|
| 137 |
+
- **amount**: 换算金额(查询参数)
|
| 138 |
+
|
| 139 |
+
示例: GET /api/convert/USD/CNY?amount=100
|
| 140 |
+
"""
|
| 141 |
+
service = get_exchange_service(request)
|
| 142 |
+
|
| 143 |
+
result = service.convert(from_currency, to_currency, amount)
|
| 144 |
+
|
| 145 |
+
if not result:
|
| 146 |
+
raise HTTPException(
|
| 147 |
+
status_code=400,
|
| 148 |
+
detail="换算失败,请检查货币代码是否有效。"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
return result
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ==================== 批量换算 ====================
|
| 155 |
+
|
| 156 |
+
@router.post("/batch-convert", response_model=BatchConvertResponse)
|
| 157 |
+
async def batch_convert(request: Request, body: BatchConvertRequest):
|
| 158 |
+
"""
|
| 159 |
+
批量换算:计算某金额对应所有货币的值
|
| 160 |
+
|
| 161 |
+
- **base_currency**: 基准货币代码
|
| 162 |
+
- **amount**: 换算金额
|
| 163 |
+
|
| 164 |
+
返回所有货币的换算结果。
|
| 165 |
+
"""
|
| 166 |
+
service = get_exchange_service(request)
|
| 167 |
+
|
| 168 |
+
result = service.batch_convert(body.base_currency, body.amount)
|
| 169 |
+
|
| 170 |
+
if not result:
|
| 171 |
+
raise HTTPException(
|
| 172 |
+
status_code=400,
|
| 173 |
+
detail="批量换算失败,请检查基准货币是否有效。"
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
return result
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ==================== GET 方式的批量换算 ====================
|
| 180 |
+
|
| 181 |
+
@router.get("/batch-convert/{base_currency}")
|
| 182 |
+
async def batch_convert_get(base_currency: str, amount: float, request: Request):
|
| 183 |
+
"""
|
| 184 |
+
批量换算(GET 方式)
|
| 185 |
+
|
| 186 |
+
- **base_currency**: 基准货币代码
|
| 187 |
+
- **amount**: 换算金额(查询参数)
|
| 188 |
+
|
| 189 |
+
示例: GET /api/batch-convert/USD?amount=100
|
| 190 |
+
"""
|
| 191 |
+
service = get_exchange_service(request)
|
| 192 |
+
|
| 193 |
+
result = service.batch_convert(base_currency, amount)
|
| 194 |
+
|
| 195 |
+
if not result:
|
| 196 |
+
raise HTTPException(
|
| 197 |
+
status_code=400,
|
| 198 |
+
detail="批量换算失败,请检查基准货币是否有效。"
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
return result
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ==================== 获取货币列表 ====================
|
| 205 |
+
|
| 206 |
+
@router.get("/currencies", response_model=CurrenciesResponse)
|
| 207 |
+
async def get_currencies(request: Request):
|
| 208 |
+
"""
|
| 209 |
+
获取支持的货币列表(含中英文名称)
|
| 210 |
+
|
| 211 |
+
返回所有可用货币的代码、名称和符号,按优先级排序。
|
| 212 |
+
"""
|
| 213 |
+
service = get_exchange_service(request)
|
| 214 |
+
settings = service.settings
|
| 215 |
+
cached = service.get_cached_rates()
|
| 216 |
+
|
| 217 |
+
if not cached:
|
| 218 |
+
raise HTTPException(
|
| 219 |
+
status_code=503,
|
| 220 |
+
detail="汇率数据不可用"
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
currencies = []
|
| 224 |
+
|
| 225 |
+
for code in cached.rates.keys():
|
| 226 |
+
info = get_currency_info(code)
|
| 227 |
+
currencies.append(CurrencyInfo(
|
| 228 |
+
code=code,
|
| 229 |
+
name=info.get("name", code),
|
| 230 |
+
name_cn=info.get("name_cn", code),
|
| 231 |
+
symbol=info.get("symbol", "")
|
| 232 |
+
))
|
| 233 |
+
|
| 234 |
+
# 按优先级排序
|
| 235 |
+
priority = settings.PRIORITY_CURRENCIES
|
| 236 |
+
currencies.sort(key=lambda x: (
|
| 237 |
+
priority.index(x.code) if x.code in priority else 999,
|
| 238 |
+
x.code
|
| 239 |
+
))
|
| 240 |
+
|
| 241 |
+
return CurrenciesResponse(
|
| 242 |
+
success=True,
|
| 243 |
+
currencies=currencies
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
# ==================== 服务状态 ====================
|
| 248 |
+
|
| 249 |
+
@router.get("/status", response_model=ServiceStatusResponse)
|
| 250 |
+
async def get_status(request: Request):
|
| 251 |
+
"""
|
| 252 |
+
获取服务状态
|
| 253 |
+
|
| 254 |
+
返回缓存状态、上次更新时间、货币数量等信息。
|
| 255 |
+
"""
|
| 256 |
+
service = get_exchange_service(request)
|
| 257 |
+
cached = service.get_cached_rates()
|
| 258 |
+
|
| 259 |
+
return ServiceStatusResponse(
|
| 260 |
+
status="healthy" if cached else "degraded",
|
| 261 |
+
cache_valid=service.is_cache_valid(),
|
| 262 |
+
last_update=cached.cached_at.isoformat() if cached else None,
|
| 263 |
+
currencies_count=len(cached.rates) if cached else 0,
|
| 264 |
+
api_keys_count=len(service.api_keys)
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
# ==================== 强制刷新缓存 ====================
|
| 269 |
+
|
| 270 |
+
@router.post("/refresh")
|
| 271 |
+
async def refresh_cache(request: Request):
|
| 272 |
+
"""
|
| 273 |
+
强制刷新汇率缓存
|
| 274 |
+
|
| 275 |
+
立即从 API 获取最新汇率数据,不等待定时任务。
|
| 276 |
+
注意:频繁调用可能导致 API 限流。
|
| 277 |
+
"""
|
| 278 |
+
service = get_exchange_service(request)
|
| 279 |
+
|
| 280 |
+
logger.info("收到手动刷新缓存请求")
|
| 281 |
+
success = await service.update_cache()
|
| 282 |
+
|
| 283 |
+
if success:
|
| 284 |
+
cached = service.get_cached_rates()
|
| 285 |
+
return {
|
| 286 |
+
"success": True,
|
| 287 |
+
"message": "缓存刷新成功",
|
| 288 |
+
"currencies_count": len(cached.rates) if cached else 0,
|
| 289 |
+
"cached_at": cached.cached_at.isoformat() if cached else None
|
| 290 |
+
}
|
| 291 |
+
else:
|
| 292 |
+
raise HTTPException(
|
| 293 |
+
status_code=503,
|
| 294 |
+
detail="刷新缓存失败,请稍后重试。"
|
| 295 |
+
)
|
app/config.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
配置管理模块
|
| 3 |
+
从环境变量加载配置,支持多 API Key
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
from typing import List
|
| 7 |
+
from functools import lru_cache
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
|
| 10 |
+
# 加载 .env 文件
|
| 11 |
+
load_dotenv()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Settings:
|
| 15 |
+
"""应用配置类"""
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
# API Keys 列表(支持多个,逗号分隔)
|
| 19 |
+
api_keys_str = os.getenv("API_KEYS", "")
|
| 20 |
+
self.API_KEYS: List[str] = [k.strip() for k in api_keys_str.split(",") if k.strip()]
|
| 21 |
+
|
| 22 |
+
# 基准货币
|
| 23 |
+
self.BASE_CURRENCY: str = os.getenv("BASE_CURRENCY", "CNY")
|
| 24 |
+
|
| 25 |
+
# 缓存更新间隔(秒)
|
| 26 |
+
self.CACHE_UPDATE_INTERVAL: int = int(os.getenv("CACHE_UPDATE_INTERVAL", "3600"))
|
| 27 |
+
|
| 28 |
+
# API 请求超时时间(秒)
|
| 29 |
+
self.REQUEST_TIMEOUT: int = int(os.getenv("REQUEST_TIMEOUT", "10"))
|
| 30 |
+
|
| 31 |
+
# 最大重试次数
|
| 32 |
+
self.MAX_RETRIES: int = int(os.getenv("MAX_RETRIES", "3"))
|
| 33 |
+
|
| 34 |
+
# 服务端口
|
| 35 |
+
self.SERVER_PORT: int = int(os.getenv("SERVER_PORT", "8000"))
|
| 36 |
+
|
| 37 |
+
# 常用货币列表(前端优先展示)
|
| 38 |
+
self.PRIORITY_CURRENCIES: List[str] = [
|
| 39 |
+
"CNY", "USD", "EUR", "GBP", "JPY", "HKD", "AUD", "CAD",
|
| 40 |
+
"CHF", "SGD", "KRW", "TWD", "THB", "MYR", "INR", "RUB"
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
# API 基础 URL
|
| 44 |
+
self.API_BASE_URL: str = "https://v6.exchangerate-api.com/v6"
|
| 45 |
+
|
| 46 |
+
def validate(self) -> bool:
|
| 47 |
+
"""验证配置是否有效"""
|
| 48 |
+
if not self.API_KEYS:
|
| 49 |
+
raise ValueError("API_KEYS 不能为空,请在 .env 文件中配置")
|
| 50 |
+
return True
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@lru_cache()
|
| 54 |
+
def get_settings() -> Settings:
|
| 55 |
+
"""获取配置单例"""
|
| 56 |
+
settings = Settings()
|
| 57 |
+
settings.validate()
|
| 58 |
+
return settings
|
app/main.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI 应用入口
|
| 3 |
+
汇率换算服务主程序
|
| 4 |
+
"""
|
| 5 |
+
from contextlib import asynccontextmanager
|
| 6 |
+
|
| 7 |
+
from fastapi import FastAPI, Request
|
| 8 |
+
from fastapi.staticfiles import StaticFiles
|
| 9 |
+
from fastapi.templating import Jinja2Templates
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from fastapi.responses import HTMLResponse
|
| 12 |
+
|
| 13 |
+
from app.config import get_settings
|
| 14 |
+
from app.services.exchange_service import ExchangeRateService
|
| 15 |
+
from app.services.scheduler import RateScheduler
|
| 16 |
+
from app.api.routes import router
|
| 17 |
+
from app.utils.logger import logger
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@asynccontextmanager
|
| 21 |
+
async def lifespan(app: FastAPI):
|
| 22 |
+
"""应用生命周期管理"""
|
| 23 |
+
# ========== 启动时 ==========
|
| 24 |
+
logger.info("=" * 50)
|
| 25 |
+
logger.info("应用正在启动...")
|
| 26 |
+
logger.info("=" * 50)
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
# 加载配置
|
| 30 |
+
settings = get_settings()
|
| 31 |
+
logger.info(f"配置加载完成: 基准货币 = {settings.BASE_CURRENCY}")
|
| 32 |
+
logger.info(f"API Key 数量: {len(settings.API_KEYS)}")
|
| 33 |
+
logger.info(f"缓存更新间隔: {settings.CACHE_UPDATE_INTERVAL} 秒")
|
| 34 |
+
|
| 35 |
+
# 初始化汇率服务
|
| 36 |
+
exchange_service = ExchangeRateService(settings)
|
| 37 |
+
|
| 38 |
+
# 初始化定时调度器
|
| 39 |
+
scheduler = RateScheduler(exchange_service, settings)
|
| 40 |
+
|
| 41 |
+
# 保存到应用状态
|
| 42 |
+
app.state.exchange_service = exchange_service
|
| 43 |
+
app.state.scheduler = scheduler
|
| 44 |
+
app.state.settings = settings
|
| 45 |
+
|
| 46 |
+
# 启动调度器(会立即获取一次数据)
|
| 47 |
+
await scheduler.start()
|
| 48 |
+
|
| 49 |
+
logger.info("应用启动成功!")
|
| 50 |
+
logger.info("=" * 50)
|
| 51 |
+
|
| 52 |
+
yield
|
| 53 |
+
|
| 54 |
+
except Exception as e:
|
| 55 |
+
logger.error(f"应用启动失败: {e}")
|
| 56 |
+
raise
|
| 57 |
+
|
| 58 |
+
finally:
|
| 59 |
+
# ========== 关闭时 ==========
|
| 60 |
+
logger.info("=" * 50)
|
| 61 |
+
logger.info("应用正在关闭...")
|
| 62 |
+
|
| 63 |
+
if hasattr(app.state, 'scheduler'):
|
| 64 |
+
app.state.scheduler.stop()
|
| 65 |
+
|
| 66 |
+
logger.info("应用已停止")
|
| 67 |
+
logger.info("=" * 50)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ==================== 创建 FastAPI 应用 ====================
|
| 71 |
+
|
| 72 |
+
app = FastAPI(
|
| 73 |
+
title="汇率换算服务",
|
| 74 |
+
description="""
|
| 75 |
+
## 汇率换算 API
|
| 76 |
+
|
| 77 |
+
提供实时汇率查询和换算功能。
|
| 78 |
+
|
| 79 |
+
### 主要功能
|
| 80 |
+
|
| 81 |
+
- 📊 **查询汇率**: 获取所有货币或特定货币的实时汇率
|
| 82 |
+
- 💱 **货币换算**: 支持任意两种货币之间的换算
|
| 83 |
+
- 🔄 **批量换算**: 一次计算某金额对应所有货币的值
|
| 84 |
+
- 📋 **货币列表**: 获取支持的货币列表(含中英文名称)
|
| 85 |
+
|
| 86 |
+
### 数据来源
|
| 87 |
+
|
| 88 |
+
数据来自 ExchangeRate-API,每日更新。
|
| 89 |
+
|
| 90 |
+
### 缓存策略
|
| 91 |
+
|
| 92 |
+
系统使用内存缓存,定时从 API 更新数据,避免频繁请求。
|
| 93 |
+
""",
|
| 94 |
+
version="1.0.0",
|
| 95 |
+
lifespan=lifespan,
|
| 96 |
+
docs_url="/docs",
|
| 97 |
+
redoc_url="/redoc"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ==================== 中间件配置 ====================
|
| 102 |
+
|
| 103 |
+
# CORS 中间件
|
| 104 |
+
app.add_middleware(
|
| 105 |
+
CORSMiddleware,
|
| 106 |
+
allow_origins=["*"],
|
| 107 |
+
allow_credentials=True,
|
| 108 |
+
allow_methods=["*"],
|
| 109 |
+
allow_headers=["*"],
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ==================== 静态文件和模板 ====================
|
| 114 |
+
|
| 115 |
+
# 挂载静态文件目录
|
| 116 |
+
app.mount("/static", StaticFiles(directory="static"), name="static")
|
| 117 |
+
|
| 118 |
+
# 配置模板引擎
|
| 119 |
+
templates = Jinja2Templates(directory="templates")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ==================== 注册路由 ====================
|
| 123 |
+
|
| 124 |
+
app.include_router(router)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ==================== 页面路由 ====================
|
| 128 |
+
|
| 129 |
+
@app.get("/", response_class=HTMLResponse)
|
| 130 |
+
async def home(request: Request):
|
| 131 |
+
"""首页 - 汇率换算器"""
|
| 132 |
+
return templates.TemplateResponse("index.html", {"request": request})
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@app.get("/health")
|
| 136 |
+
async def health_check():
|
| 137 |
+
"""健康检查接口"""
|
| 138 |
+
return {"status": "ok", "service": "exchange-rates"}
|
app/models.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
数据模型定义
|
| 3 |
+
使用 Pydantic 进行数据验证
|
| 4 |
+
"""
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
from typing import Dict, Optional
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# ============== API 响应模型 ==============
|
| 11 |
+
|
| 12 |
+
class ExchangeRateAPIResponse(BaseModel):
|
| 13 |
+
"""外部 API 原始响应模型"""
|
| 14 |
+
result: str
|
| 15 |
+
documentation: Optional[str] = None
|
| 16 |
+
terms_of_use: Optional[str] = None
|
| 17 |
+
time_last_update_unix: int
|
| 18 |
+
time_last_update_utc: str
|
| 19 |
+
time_next_update_unix: int
|
| 20 |
+
time_next_update_utc: str
|
| 21 |
+
base_code: str
|
| 22 |
+
conversion_rates: Dict[str, float]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ============== 缓存数据模型 ==============
|
| 26 |
+
|
| 27 |
+
class CachedRates(BaseModel):
|
| 28 |
+
"""缓存的汇率数据"""
|
| 29 |
+
base_code: str
|
| 30 |
+
rates: Dict[str, float]
|
| 31 |
+
last_update_unix: int
|
| 32 |
+
last_update_utc: str
|
| 33 |
+
next_update_utc: str
|
| 34 |
+
cached_at: datetime = Field(default_factory=datetime.now)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ============== API 请求/响应模型 ==============
|
| 38 |
+
|
| 39 |
+
class RatesResponse(BaseModel):
|
| 40 |
+
"""获取所有汇率的响应"""
|
| 41 |
+
success: bool
|
| 42 |
+
base_currency: str
|
| 43 |
+
rates: Dict[str, float]
|
| 44 |
+
last_update: str
|
| 45 |
+
cached_at: str
|
| 46 |
+
currencies_count: int
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class CurrencyRateResponse(BaseModel):
|
| 50 |
+
"""获取单个货币汇率的响应"""
|
| 51 |
+
success: bool
|
| 52 |
+
base_currency: str
|
| 53 |
+
currency: str
|
| 54 |
+
rate: float
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class ConvertRequest(BaseModel):
|
| 58 |
+
"""汇率换算请求"""
|
| 59 |
+
from_currency: str = Field(..., min_length=3, max_length=3, description="源货币代码")
|
| 60 |
+
to_currency: str = Field(..., min_length=3, max_length=3, description="目标货币代码")
|
| 61 |
+
amount: float = Field(..., gt=0, description="换算金额")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class ConvertResponse(BaseModel):
|
| 65 |
+
"""汇率换算响应"""
|
| 66 |
+
success: bool
|
| 67 |
+
from_currency: str
|
| 68 |
+
to_currency: str
|
| 69 |
+
amount: float
|
| 70 |
+
result: float
|
| 71 |
+
rate: float
|
| 72 |
+
reverse_rate: float
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class BatchConvertRequest(BaseModel):
|
| 76 |
+
"""批量换算请求"""
|
| 77 |
+
base_currency: str = Field(..., min_length=3, max_length=3, description="基准货币代码")
|
| 78 |
+
amount: float = Field(..., gt=0, description="换算金额")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class BatchConvertResponse(BaseModel):
|
| 82 |
+
"""批量换算响应"""
|
| 83 |
+
success: bool
|
| 84 |
+
base_currency: str
|
| 85 |
+
amount: float
|
| 86 |
+
conversions: Dict[str, float]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class CurrencyInfo(BaseModel):
|
| 90 |
+
"""货币信息"""
|
| 91 |
+
code: str
|
| 92 |
+
name: str
|
| 93 |
+
name_cn: str
|
| 94 |
+
symbol: Optional[str] = None
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class CurrenciesResponse(BaseModel):
|
| 98 |
+
"""货币列表响应"""
|
| 99 |
+
success: bool
|
| 100 |
+
currencies: list[CurrencyInfo]
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class ServiceStatusResponse(BaseModel):
|
| 104 |
+
"""服务状态响应"""
|
| 105 |
+
status: str
|
| 106 |
+
cache_valid: bool
|
| 107 |
+
last_update: Optional[str] = None
|
| 108 |
+
currencies_count: int
|
| 109 |
+
api_keys_count: int
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class ErrorResponse(BaseModel):
|
| 113 |
+
"""错误响应"""
|
| 114 |
+
success: bool = False
|
| 115 |
+
error: str
|
| 116 |
+
detail: Optional[str] = None
|
app/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Services package
|
app/services/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (171 Bytes). View file
|
|
|
app/services/__pycache__/exchange_service.cpython-311.pyc
ADDED
|
Binary file (15.3 kB). View file
|
|
|
app/services/__pycache__/scheduler.cpython-311.pyc
ADDED
|
Binary file (4.83 kB). View file
|
|
|
app/services/exchange_service.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
汇率服务核心模块
|
| 3 |
+
实现 API Key 轮询、缓存管理、汇率换算
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
from datetime import datetime, timedelta
|
| 7 |
+
from typing import Optional, Dict
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
|
| 10 |
+
import httpx
|
| 11 |
+
|
| 12 |
+
from app.config import Settings
|
| 13 |
+
from app.models import (
|
| 14 |
+
ExchangeRateAPIResponse,
|
| 15 |
+
CachedRates,
|
| 16 |
+
ConvertResponse,
|
| 17 |
+
BatchConvertResponse
|
| 18 |
+
)
|
| 19 |
+
from app.utils.logger import logger
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class KeyStatus:
|
| 24 |
+
"""API Key 状态"""
|
| 25 |
+
failed_at: datetime
|
| 26 |
+
cooldown_until: datetime
|
| 27 |
+
error_message: str = ""
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ExchangeRateService:
|
| 31 |
+
"""汇率服务核心类"""
|
| 32 |
+
|
| 33 |
+
def __init__(self, settings: Settings):
|
| 34 |
+
self.settings = settings
|
| 35 |
+
self.api_keys = settings.API_KEYS.copy()
|
| 36 |
+
self.current_key_index = 0
|
| 37 |
+
self.cached_rates: Optional[CachedRates] = None
|
| 38 |
+
self._lock = asyncio.Lock()
|
| 39 |
+
|
| 40 |
+
# Key 状态追踪
|
| 41 |
+
self.key_status: Dict[str, KeyStatus] = {}
|
| 42 |
+
|
| 43 |
+
logger.info(f"汇率服务初始化完成,共配置 {len(self.api_keys)} 个 API Key")
|
| 44 |
+
|
| 45 |
+
# ==================== API Key 轮询机制 ====================
|
| 46 |
+
|
| 47 |
+
def _get_current_key(self) -> str:
|
| 48 |
+
"""获取当前 API Key"""
|
| 49 |
+
return self.api_keys[self.current_key_index]
|
| 50 |
+
|
| 51 |
+
def _rotate_key(self) -> str:
|
| 52 |
+
"""轮换到下一个可用的 API Key"""
|
| 53 |
+
original_index = self.current_key_index
|
| 54 |
+
attempts = 0
|
| 55 |
+
|
| 56 |
+
while attempts < len(self.api_keys):
|
| 57 |
+
self.current_key_index = (self.current_key_index + 1) % len(self.api_keys)
|
| 58 |
+
key = self.api_keys[self.current_key_index]
|
| 59 |
+
|
| 60 |
+
if self._is_key_available(key):
|
| 61 |
+
if self.current_key_index != original_index:
|
| 62 |
+
logger.info(f"已切换到 API Key 索引 {self.current_key_index}")
|
| 63 |
+
return key
|
| 64 |
+
|
| 65 |
+
attempts += 1
|
| 66 |
+
|
| 67 |
+
# 所有 key 都不可用,返回第一个并记录警告
|
| 68 |
+
logger.warning("所有 API Key 均在冷却中,使用第一个 Key")
|
| 69 |
+
self.current_key_index = 0
|
| 70 |
+
return self.api_keys[0]
|
| 71 |
+
|
| 72 |
+
def _is_key_available(self, key: str) -> bool:
|
| 73 |
+
"""检查 Key 是否可用(未被限流)"""
|
| 74 |
+
status = self.key_status.get(key)
|
| 75 |
+
if not status:
|
| 76 |
+
return True
|
| 77 |
+
|
| 78 |
+
# 检查冷却时间是否已过
|
| 79 |
+
if datetime.now() >= status.cooldown_until:
|
| 80 |
+
# 冷却时间已过,移除状态记录
|
| 81 |
+
del self.key_status[key]
|
| 82 |
+
logger.info(f"API Key 冷却时间已过,现在可用")
|
| 83 |
+
return True
|
| 84 |
+
|
| 85 |
+
return False
|
| 86 |
+
|
| 87 |
+
def _mark_key_failed(self, key: str, cooldown_seconds: int = 60, error_message: str = ""):
|
| 88 |
+
"""标记 Key 为失败状态"""
|
| 89 |
+
self.key_status[key] = KeyStatus(
|
| 90 |
+
failed_at=datetime.now(),
|
| 91 |
+
cooldown_until=datetime.now() + timedelta(seconds=cooldown_seconds),
|
| 92 |
+
error_message=error_message
|
| 93 |
+
)
|
| 94 |
+
logger.warning(f"API Key 标记为失败,冷却 {cooldown_seconds} 秒。错误: {error_message}")
|
| 95 |
+
|
| 96 |
+
# ==================== API 请求 ====================
|
| 97 |
+
|
| 98 |
+
async def fetch_rates(self) -> Optional[ExchangeRateAPIResponse]:
|
| 99 |
+
"""
|
| 100 |
+
请求汇率数据,支持自动重试和 Key 切换
|
| 101 |
+
"""
|
| 102 |
+
last_error = None
|
| 103 |
+
|
| 104 |
+
for attempt in range(self.settings.MAX_RETRIES):
|
| 105 |
+
api_key = self._get_current_key()
|
| 106 |
+
|
| 107 |
+
# 如果当前 key 不可用,轮换到下一个
|
| 108 |
+
if not self._is_key_available(api_key):
|
| 109 |
+
api_key = self._rotate_key()
|
| 110 |
+
|
| 111 |
+
url = f"{self.settings.API_BASE_URL}/{api_key}/latest/{self.settings.BASE_CURRENCY}"
|
| 112 |
+
|
| 113 |
+
try:
|
| 114 |
+
logger.info(f"正在获取汇率数据 (第 {attempt + 1}/{self.settings.MAX_RETRIES} 次尝试)")
|
| 115 |
+
|
| 116 |
+
async with httpx.AsyncClient(timeout=self.settings.REQUEST_TIMEOUT) as client:
|
| 117 |
+
response = await client.get(url)
|
| 118 |
+
|
| 119 |
+
if response.status_code == 200:
|
| 120 |
+
data = response.json()
|
| 121 |
+
|
| 122 |
+
if data.get("result") == "success":
|
| 123 |
+
logger.info(f"成功获取 {self.settings.BASE_CURRENCY} 的汇率数据")
|
| 124 |
+
return ExchangeRateAPIResponse(**data)
|
| 125 |
+
else:
|
| 126 |
+
error_type = data.get("error-type", "unknown")
|
| 127 |
+
logger.warning(f"API 返回错误: {error_type}")
|
| 128 |
+
last_error = f"API 错误: {error_type}"
|
| 129 |
+
|
| 130 |
+
# 处理错误状态码
|
| 131 |
+
elif response.status_code == 429:
|
| 132 |
+
# Rate limited
|
| 133 |
+
logger.warning(f"API Key 请求过于频繁被限流 (429)")
|
| 134 |
+
self._mark_key_failed(api_key, cooldown_seconds=3600, error_message="请求被限流")
|
| 135 |
+
api_key = self._rotate_key()
|
| 136 |
+
|
| 137 |
+
elif response.status_code == 403:
|
| 138 |
+
# Invalid or expired key
|
| 139 |
+
logger.error(f"API Key 无效或已过期 (403)")
|
| 140 |
+
self._mark_key_failed(api_key, cooldown_seconds=86400, error_message="无效的 Key")
|
| 141 |
+
api_key = self._rotate_key()
|
| 142 |
+
|
| 143 |
+
elif response.status_code == 404:
|
| 144 |
+
# Invalid base currency
|
| 145 |
+
logger.error(f"无效的基准货币: {self.settings.BASE_CURRENCY}")
|
| 146 |
+
last_error = f"无效的基准货币: {self.settings.BASE_CURRENCY}"
|
| 147 |
+
break # 不需要重试
|
| 148 |
+
|
| 149 |
+
else:
|
| 150 |
+
logger.warning(f"HTTP 错误 {response.status_code}: {response.text[:200]}")
|
| 151 |
+
last_error = f"HTTP {response.status_code}"
|
| 152 |
+
|
| 153 |
+
except httpx.TimeoutException:
|
| 154 |
+
logger.warning(f"请求超时 (第 {attempt + 1}/{self.settings.MAX_RETRIES} 次尝试)")
|
| 155 |
+
last_error = "请求超时"
|
| 156 |
+
|
| 157 |
+
except httpx.ConnectError as e:
|
| 158 |
+
logger.warning(f"连接错误: {e}")
|
| 159 |
+
last_error = f"连接错误: {e}"
|
| 160 |
+
|
| 161 |
+
except Exception as e:
|
| 162 |
+
logger.error(f"未知错误: {e}")
|
| 163 |
+
last_error = f"未知错误: {e}"
|
| 164 |
+
|
| 165 |
+
# 等待一小段时间再重试
|
| 166 |
+
if attempt < self.settings.MAX_RETRIES - 1:
|
| 167 |
+
await asyncio.sleep(1)
|
| 168 |
+
|
| 169 |
+
logger.error(f"所有重试均失败。最后错误: {last_error}")
|
| 170 |
+
return None
|
| 171 |
+
|
| 172 |
+
# ==================== 缓存管理 ====================
|
| 173 |
+
|
| 174 |
+
async def update_cache(self) -> bool:
|
| 175 |
+
"""更新缓存数据"""
|
| 176 |
+
async with self._lock:
|
| 177 |
+
response = await self.fetch_rates()
|
| 178 |
+
|
| 179 |
+
if response:
|
| 180 |
+
self.cached_rates = CachedRates(
|
| 181 |
+
base_code=response.base_code,
|
| 182 |
+
rates=response.conversion_rates,
|
| 183 |
+
last_update_unix=response.time_last_update_unix,
|
| 184 |
+
last_update_utc=response.time_last_update_utc,
|
| 185 |
+
next_update_utc=response.time_next_update_utc,
|
| 186 |
+
cached_at=datetime.now()
|
| 187 |
+
)
|
| 188 |
+
logger.info(f"缓存更新成功,共 {len(response.conversion_rates)} 种货币")
|
| 189 |
+
return True
|
| 190 |
+
|
| 191 |
+
logger.warning("缓存更新失败,将保留旧数据")
|
| 192 |
+
return False
|
| 193 |
+
|
| 194 |
+
def get_cached_rates(self) -> Optional[CachedRates]:
|
| 195 |
+
"""获取缓存的汇率数据"""
|
| 196 |
+
return self.cached_rates
|
| 197 |
+
|
| 198 |
+
def is_cache_valid(self) -> bool:
|
| 199 |
+
"""检查缓存是否有效"""
|
| 200 |
+
if not self.cached_rates:
|
| 201 |
+
return False
|
| 202 |
+
|
| 203 |
+
# 缓存时间超过更新间隔则视为无效
|
| 204 |
+
elapsed = (datetime.now() - self.cached_rates.cached_at).total_seconds()
|
| 205 |
+
return elapsed < self.settings.CACHE_UPDATE_INTERVAL
|
| 206 |
+
|
| 207 |
+
def get_cache_age_seconds(self) -> Optional[float]:
|
| 208 |
+
"""获取缓存年龄(秒)"""
|
| 209 |
+
if not self.cached_rates:
|
| 210 |
+
return None
|
| 211 |
+
return (datetime.now() - self.cached_rates.cached_at).total_seconds()
|
| 212 |
+
|
| 213 |
+
# ==================== 汇率换算 ====================
|
| 214 |
+
|
| 215 |
+
def convert(self, from_currency: str, to_currency: str, amount: float) -> Optional[ConvertResponse]:
|
| 216 |
+
"""
|
| 217 |
+
两种货币之间的汇率换算
|
| 218 |
+
|
| 219 |
+
使用交叉汇率计算:
|
| 220 |
+
如果基准货币是 CNY,rates 中存储的是 1 CNY = X 其他货币
|
| 221 |
+
from_rate = 1 CNY = X from_currency => 1 from_currency = 1/X CNY
|
| 222 |
+
to_rate = 1 CNY = Y to_currency => 1 from_currency = Y/X to_currency
|
| 223 |
+
"""
|
| 224 |
+
if not self.cached_rates:
|
| 225 |
+
logger.warning("换算失败: 无可用的缓存数据")
|
| 226 |
+
return None
|
| 227 |
+
|
| 228 |
+
from_currency = from_currency.upper()
|
| 229 |
+
to_currency = to_currency.upper()
|
| 230 |
+
|
| 231 |
+
rates = self.cached_rates.rates
|
| 232 |
+
|
| 233 |
+
if from_currency not in rates:
|
| 234 |
+
logger.warning(f"换算失败: 未找到货币 {from_currency}")
|
| 235 |
+
return None
|
| 236 |
+
|
| 237 |
+
if to_currency not in rates:
|
| 238 |
+
logger.warning(f"换算失败: 未找到货币 {to_currency}")
|
| 239 |
+
return None
|
| 240 |
+
|
| 241 |
+
# 计算交叉汇率
|
| 242 |
+
from_rate = rates[from_currency]
|
| 243 |
+
to_rate = rates[to_currency]
|
| 244 |
+
|
| 245 |
+
# 交叉汇率 = to_rate / from_rate
|
| 246 |
+
# 表示 1 from_currency = (to_rate / from_rate) to_currency
|
| 247 |
+
cross_rate = to_rate / from_rate
|
| 248 |
+
result = amount * cross_rate
|
| 249 |
+
|
| 250 |
+
return ConvertResponse(
|
| 251 |
+
success=True,
|
| 252 |
+
from_currency=from_currency,
|
| 253 |
+
to_currency=to_currency,
|
| 254 |
+
amount=amount,
|
| 255 |
+
result=round(result, 6),
|
| 256 |
+
rate=round(cross_rate, 6),
|
| 257 |
+
reverse_rate=round(1 / cross_rate, 6)
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
def batch_convert(self, base_currency: str, amount: float) -> Optional[BatchConvertResponse]:
|
| 261 |
+
"""
|
| 262 |
+
批量换算:计算某金额对应所有货币的值
|
| 263 |
+
"""
|
| 264 |
+
if not self.cached_rates:
|
| 265 |
+
logger.warning("批量换算失败: 无可用的缓存数据")
|
| 266 |
+
return None
|
| 267 |
+
|
| 268 |
+
base_currency = base_currency.upper()
|
| 269 |
+
rates = self.cached_rates.rates
|
| 270 |
+
|
| 271 |
+
if base_currency not in rates:
|
| 272 |
+
logger.warning(f"批量换算失败: 未找到货币 {base_currency}")
|
| 273 |
+
return None
|
| 274 |
+
|
| 275 |
+
base_rate = rates[base_currency]
|
| 276 |
+
conversions = {}
|
| 277 |
+
|
| 278 |
+
for currency, rate in rates.items():
|
| 279 |
+
cross_rate = rate / base_rate
|
| 280 |
+
conversions[currency] = round(amount * cross_rate, 6)
|
| 281 |
+
|
| 282 |
+
return BatchConvertResponse(
|
| 283 |
+
success=True,
|
| 284 |
+
base_currency=base_currency,
|
| 285 |
+
amount=amount,
|
| 286 |
+
conversions=conversions
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
def get_rate(self, from_currency: str, to_currency: str) -> Optional[float]:
|
| 290 |
+
"""
|
| 291 |
+
获取两种货币之间的汇率
|
| 292 |
+
"""
|
| 293 |
+
if not self.cached_rates:
|
| 294 |
+
return None
|
| 295 |
+
|
| 296 |
+
from_currency = from_currency.upper()
|
| 297 |
+
to_currency = to_currency.upper()
|
| 298 |
+
|
| 299 |
+
rates = self.cached_rates.rates
|
| 300 |
+
|
| 301 |
+
if from_currency not in rates or to_currency not in rates:
|
| 302 |
+
return None
|
| 303 |
+
|
| 304 |
+
from_rate = rates[from_currency]
|
| 305 |
+
to_rate = rates[to_currency]
|
| 306 |
+
|
| 307 |
+
return to_rate / from_rate
|
app/services/scheduler.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
定时任务调度器
|
| 3 |
+
使用 APScheduler 实现定时更新汇率数据
|
| 4 |
+
"""
|
| 5 |
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
| 6 |
+
from apscheduler.triggers.interval import IntervalTrigger
|
| 7 |
+
|
| 8 |
+
from app.config import Settings
|
| 9 |
+
from app.services.exchange_service import ExchangeRateService
|
| 10 |
+
from app.utils.logger import logger
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class RateScheduler:
|
| 14 |
+
"""汇率更新定时调度器"""
|
| 15 |
+
|
| 16 |
+
def __init__(self, exchange_service: ExchangeRateService, settings: Settings):
|
| 17 |
+
self.exchange_service = exchange_service
|
| 18 |
+
self.settings = settings
|
| 19 |
+
self.scheduler = AsyncIOScheduler()
|
| 20 |
+
self._is_running = False
|
| 21 |
+
|
| 22 |
+
async def _update_job(self):
|
| 23 |
+
"""定时更新任务"""
|
| 24 |
+
logger.info("定时汇率更新开始")
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
success = await self.exchange_service.update_cache()
|
| 28 |
+
|
| 29 |
+
if success:
|
| 30 |
+
cached = self.exchange_service.get_cached_rates()
|
| 31 |
+
if cached:
|
| 32 |
+
logger.info(
|
| 33 |
+
f"定时汇率更新完成。"
|
| 34 |
+
f"货币数量: {len(cached.rates)}, "
|
| 35 |
+
f"数据源更新时间: {cached.last_update_utc}"
|
| 36 |
+
)
|
| 37 |
+
else:
|
| 38 |
+
logger.warning("定时汇率更新失败,将在下一个时间间隔重试")
|
| 39 |
+
|
| 40 |
+
except Exception as e:
|
| 41 |
+
logger.error(f"定时更新任务出错: {e}")
|
| 42 |
+
|
| 43 |
+
async def start(self):
|
| 44 |
+
"""启动调度器"""
|
| 45 |
+
if self._is_running:
|
| 46 |
+
logger.warning("调度器已在运行中")
|
| 47 |
+
return
|
| 48 |
+
|
| 49 |
+
logger.info("正在启动汇率调度器...")
|
| 50 |
+
|
| 51 |
+
# 立即执行一次更新
|
| 52 |
+
logger.info("正在执行初始汇率获取...")
|
| 53 |
+
await self._update_job()
|
| 54 |
+
|
| 55 |
+
# 添加定时任务
|
| 56 |
+
self.scheduler.add_job(
|
| 57 |
+
self._update_job,
|
| 58 |
+
trigger=IntervalTrigger(seconds=self.settings.CACHE_UPDATE_INTERVAL),
|
| 59 |
+
id='rate_update',
|
| 60 |
+
name='定时更新汇率',
|
| 61 |
+
replace_existing=True,
|
| 62 |
+
max_instances=1 # 确保同一时间只有一个任务实例运行
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
self.scheduler.start()
|
| 66 |
+
self._is_running = True
|
| 67 |
+
|
| 68 |
+
logger.info(
|
| 69 |
+
f"调度器启动成功。"
|
| 70 |
+
f"更新间隔: {self.settings.CACHE_UPDATE_INTERVAL} 秒 "
|
| 71 |
+
f"({self.settings.CACHE_UPDATE_INTERVAL / 60:.1f} 分钟)"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
def stop(self):
|
| 75 |
+
"""停止调度器"""
|
| 76 |
+
if not self._is_running:
|
| 77 |
+
logger.warning("调度器未在运行")
|
| 78 |
+
return
|
| 79 |
+
|
| 80 |
+
self.scheduler.shutdown(wait=False)
|
| 81 |
+
self._is_running = False
|
| 82 |
+
logger.info("调度器已停止")
|
| 83 |
+
|
| 84 |
+
def is_running(self) -> bool:
|
| 85 |
+
"""检查调度器是否正在运行"""
|
| 86 |
+
return self._is_running
|
| 87 |
+
|
| 88 |
+
def get_next_run_time(self):
|
| 89 |
+
"""获取下次运行时间"""
|
| 90 |
+
job = self.scheduler.get_job('rate_update')
|
| 91 |
+
if job:
|
| 92 |
+
return job.next_run_time
|
| 93 |
+
return None
|
app/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Utils package
|
app/utils/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (168 Bytes). View file
|
|
|
app/utils/__pycache__/currency_names.cpython-311.pyc
ADDED
|
Binary file (16.1 kB). View file
|
|
|
app/utils/__pycache__/logger.cpython-311.pyc
ADDED
|
Binary file (1.02 kB). View file
|
|
|
app/utils/currency_names.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
货币名称映射
|
| 3 |
+
包含货币代码、英文名称、中文名称和符号
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
CURRENCY_INFO = {
|
| 7 |
+
"AED": {"name": "UAE Dirham", "name_cn": "阿联酋迪拉姆", "symbol": "د.إ"},
|
| 8 |
+
"AFN": {"name": "Afghan Afghani", "name_cn": "阿富汗阿富汗尼", "symbol": "؋"},
|
| 9 |
+
"ALL": {"name": "Albanian Lek", "name_cn": "阿尔巴尼亚列克", "symbol": "L"},
|
| 10 |
+
"AMD": {"name": "Armenian Dram", "name_cn": "亚美尼亚德拉姆", "symbol": "֏"},
|
| 11 |
+
"ANG": {"name": "Netherlands Antillian Guilder", "name_cn": "荷属安的列斯盾", "symbol": "ƒ"},
|
| 12 |
+
"AOA": {"name": "Angolan Kwanza", "name_cn": "安哥拉宽扎", "symbol": "Kz"},
|
| 13 |
+
"ARS": {"name": "Argentine Peso", "name_cn": "阿根廷比索", "symbol": "$"},
|
| 14 |
+
"AUD": {"name": "Australian Dollar", "name_cn": "澳大利亚元", "symbol": "A$"},
|
| 15 |
+
"AWG": {"name": "Aruban Florin", "name_cn": "阿鲁巴弗罗林", "symbol": "ƒ"},
|
| 16 |
+
"AZN": {"name": "Azerbaijani Manat", "name_cn": "阿塞拜疆马纳特", "symbol": "₼"},
|
| 17 |
+
"BAM": {"name": "Bosnia and Herzegovina Mark", "name_cn": "波黑可兑换马克", "symbol": "KM"},
|
| 18 |
+
"BBD": {"name": "Barbados Dollar", "name_cn": "巴巴多斯元", "symbol": "$"},
|
| 19 |
+
"BDT": {"name": "Bangladeshi Taka", "name_cn": "孟加拉塔卡", "symbol": "৳"},
|
| 20 |
+
"BGN": {"name": "Bulgarian Lev", "name_cn": "保加利亚列弗", "symbol": "лв"},
|
| 21 |
+
"BHD": {"name": "Bahraini Dinar", "name_cn": "巴林第纳尔", "symbol": ".د.ب"},
|
| 22 |
+
"BIF": {"name": "Burundian Franc", "name_cn": "布隆迪法郎", "symbol": "FBu"},
|
| 23 |
+
"BMD": {"name": "Bermudian Dollar", "name_cn": "百慕大元", "symbol": "$"},
|
| 24 |
+
"BND": {"name": "Brunei Dollar", "name_cn": "文莱元", "symbol": "$"},
|
| 25 |
+
"BOB": {"name": "Bolivian Boliviano", "name_cn": "玻利维亚诺", "symbol": "Bs."},
|
| 26 |
+
"BRL": {"name": "Brazilian Real", "name_cn": "巴西雷亚尔", "symbol": "R$"},
|
| 27 |
+
"BSD": {"name": "Bahamian Dollar", "name_cn": "巴哈马元", "symbol": "$"},
|
| 28 |
+
"BTN": {"name": "Bhutanese Ngultrum", "name_cn": "不丹努尔特鲁姆", "symbol": "Nu."},
|
| 29 |
+
"BWP": {"name": "Botswana Pula", "name_cn": "博茨瓦纳普拉", "symbol": "P"},
|
| 30 |
+
"BYN": {"name": "Belarusian Ruble", "name_cn": "白俄罗斯卢布", "symbol": "Br"},
|
| 31 |
+
"BZD": {"name": "Belize Dollar", "name_cn": "伯利兹元", "symbol": "$"},
|
| 32 |
+
"CAD": {"name": "Canadian Dollar", "name_cn": "加拿大元", "symbol": "C$"},
|
| 33 |
+
"CDF": {"name": "Congolese Franc", "name_cn": "刚果法郎", "symbol": "FC"},
|
| 34 |
+
"CHF": {"name": "Swiss Franc", "name_cn": "瑞士法郎", "symbol": "Fr"},
|
| 35 |
+
"CLF": {"name": "Chilean Unit of Account", "name_cn": "智利记账单位", "symbol": "UF"},
|
| 36 |
+
"CLP": {"name": "Chilean Peso", "name_cn": "智利比索", "symbol": "$"},
|
| 37 |
+
"CNH": {"name": "Chinese Yuan (Offshore)", "name_cn": "离岸人民币", "symbol": "¥"},
|
| 38 |
+
"CNY": {"name": "Chinese Yuan", "name_cn": "人民币", "symbol": "¥"},
|
| 39 |
+
"COP": {"name": "Colombian Peso", "name_cn": "哥伦比亚比索", "symbol": "$"},
|
| 40 |
+
"CRC": {"name": "Costa Rican Colon", "name_cn": "哥斯达黎加科朗", "symbol": "₡"},
|
| 41 |
+
"CUP": {"name": "Cuban Peso", "name_cn": "古巴比索", "symbol": "$"},
|
| 42 |
+
"CVE": {"name": "Cape Verdean Escudo", "name_cn": "佛得角埃斯库多", "symbol": "$"},
|
| 43 |
+
"CZK": {"name": "Czech Koruna", "name_cn": "捷克克朗", "symbol": "Kč"},
|
| 44 |
+
"DJF": {"name": "Djiboutian Franc", "name_cn": "吉布提法郎", "symbol": "Fdj"},
|
| 45 |
+
"DKK": {"name": "Danish Krone", "name_cn": "丹麦克朗", "symbol": "kr"},
|
| 46 |
+
"DOP": {"name": "Dominican Peso", "name_cn": "多米尼加比索", "symbol": "$"},
|
| 47 |
+
"DZD": {"name": "Algerian Dinar", "name_cn": "阿尔及利亚第纳尔", "symbol": "د.ج"},
|
| 48 |
+
"EGP": {"name": "Egyptian Pound", "name_cn": "埃及镑", "symbol": "£"},
|
| 49 |
+
"ERN": {"name": "Eritrean Nakfa", "name_cn": "厄立特里亚纳克法", "symbol": "Nfk"},
|
| 50 |
+
"ETB": {"name": "Ethiopian Birr", "name_cn": "埃塞俄比亚比尔", "symbol": "Br"},
|
| 51 |
+
"EUR": {"name": "Euro", "name_cn": "欧元", "symbol": "€"},
|
| 52 |
+
"FJD": {"name": "Fiji Dollar", "name_cn": "斐济元", "symbol": "$"},
|
| 53 |
+
"FKP": {"name": "Falkland Islands Pound", "name_cn": "福克兰镑", "symbol": "£"},
|
| 54 |
+
"FOK": {"name": "Faroese Króna", "name_cn": "法罗克朗", "symbol": "kr"},
|
| 55 |
+
"GBP": {"name": "British Pound", "name_cn": "英镑", "symbol": "£"},
|
| 56 |
+
"GEL": {"name": "Georgian Lari", "name_cn": "格鲁吉亚拉里", "symbol": "₾"},
|
| 57 |
+
"GGP": {"name": "Guernsey Pound", "name_cn": "根西镑", "symbol": "£"},
|
| 58 |
+
"GHS": {"name": "Ghanaian Cedi", "name_cn": "加纳塞地", "symbol": "₵"},
|
| 59 |
+
"GIP": {"name": "Gibraltar Pound", "name_cn": "直布罗陀镑", "symbol": "£"},
|
| 60 |
+
"GMD": {"name": "Gambian Dalasi", "name_cn": "冈比亚达拉西", "symbol": "D"},
|
| 61 |
+
"GNF": {"name": "Guinean Franc", "name_cn": "几内亚法郎", "symbol": "FG"},
|
| 62 |
+
"GTQ": {"name": "Guatemalan Quetzal", "name_cn": "危地马拉格查尔", "symbol": "Q"},
|
| 63 |
+
"GYD": {"name": "Guyanese Dollar", "name_cn": "圭亚那元", "symbol": "$"},
|
| 64 |
+
"HKD": {"name": "Hong Kong Dollar", "name_cn": "港元", "symbol": "HK$"},
|
| 65 |
+
"HNL": {"name": "Honduran Lempira", "name_cn": "洪都拉斯伦皮拉", "symbol": "L"},
|
| 66 |
+
"HRK": {"name": "Croatian Kuna", "name_cn": "克罗地亚库纳", "symbol": "kn"},
|
| 67 |
+
"HTG": {"name": "Haitian Gourde", "name_cn": "海地古德", "symbol": "G"},
|
| 68 |
+
"HUF": {"name": "Hungarian Forint", "name_cn": "匈牙利福林", "symbol": "Ft"},
|
| 69 |
+
"IDR": {"name": "Indonesian Rupiah", "name_cn": "印度尼西亚卢比", "symbol": "Rp"},
|
| 70 |
+
"ILS": {"name": "Israeli New Shekel", "name_cn": "以色列新谢克尔", "symbol": "₪"},
|
| 71 |
+
"IMP": {"name": "Manx Pound", "name_cn": "马恩岛镑", "symbol": "£"},
|
| 72 |
+
"INR": {"name": "Indian Rupee", "name_cn": "印度卢比", "symbol": "₹"},
|
| 73 |
+
"IQD": {"name": "Iraqi Dinar", "name_cn": "伊拉克第纳尔", "symbol": "ع.د"},
|
| 74 |
+
"IRR": {"name": "Iranian Rial", "name_cn": "伊朗里亚尔", "symbol": "﷼"},
|
| 75 |
+
"ISK": {"name": "Icelandic Króna", "name_cn": "冰岛克朗", "symbol": "kr"},
|
| 76 |
+
"JEP": {"name": "Jersey Pound", "name_cn": "泽西镑", "symbol": "£"},
|
| 77 |
+
"JMD": {"name": "Jamaican Dollar", "name_cn": "牙买加元", "symbol": "$"},
|
| 78 |
+
"JOD": {"name": "Jordanian Dinar", "name_cn": "约旦第纳尔", "symbol": "د.ا"},
|
| 79 |
+
"JPY": {"name": "Japanese Yen", "name_cn": "日元", "symbol": "¥"},
|
| 80 |
+
"KES": {"name": "Kenyan Shilling", "name_cn": "肯尼亚先令", "symbol": "KSh"},
|
| 81 |
+
"KGS": {"name": "Kyrgyzstani Som", "name_cn": "吉尔吉斯斯坦索姆", "symbol": "с"},
|
| 82 |
+
"KHR": {"name": "Cambodian Riel", "name_cn": "柬埔寨瑞尔", "symbol": "៛"},
|
| 83 |
+
"KID": {"name": "Kiribati Dollar", "name_cn": "基里巴斯元", "symbol": "$"},
|
| 84 |
+
"KMF": {"name": "Comorian Franc", "name_cn": "科摩罗法郎", "symbol": "CF"},
|
| 85 |
+
"KRW": {"name": "South Korean Won", "name_cn": "韩元", "symbol": "₩"},
|
| 86 |
+
"KWD": {"name": "Kuwaiti Dinar", "name_cn": "科威特第纳尔", "symbol": "د.ك"},
|
| 87 |
+
"KYD": {"name": "Cayman Islands Dollar", "name_cn": "开曼群岛元", "symbol": "$"},
|
| 88 |
+
"KZT": {"name": "Kazakhstani Tenge", "name_cn": "哈萨克斯坦坚戈", "symbol": "₸"},
|
| 89 |
+
"LAK": {"name": "Lao Kip", "name_cn": "老挝基普", "symbol": "₭"},
|
| 90 |
+
"LBP": {"name": "Lebanese Pound", "name_cn": "黎巴嫩镑", "symbol": "ل.ل"},
|
| 91 |
+
"LKR": {"name": "Sri Lankan Rupee", "name_cn": "斯里兰卡卢比", "symbol": "Rs"},
|
| 92 |
+
"LRD": {"name": "Liberian Dollar", "name_cn": "利比里亚元", "symbol": "$"},
|
| 93 |
+
"LSL": {"name": "Lesotho Loti", "name_cn": "莱索托洛蒂", "symbol": "L"},
|
| 94 |
+
"LYD": {"name": "Libyan Dinar", "name_cn": "利比亚第纳尔", "symbol": "ل.د"},
|
| 95 |
+
"MAD": {"name": "Moroccan Dirham", "name_cn": "摩洛哥迪拉姆", "symbol": "د.م."},
|
| 96 |
+
"MDL": {"name": "Moldovan Leu", "name_cn": "摩尔多瓦列伊", "symbol": "L"},
|
| 97 |
+
"MGA": {"name": "Malagasy Ariary", "name_cn": "马达加斯加阿里亚里", "symbol": "Ar"},
|
| 98 |
+
"MKD": {"name": "Macedonian Denar", "name_cn": "北马其顿代纳尔", "symbol": "ден"},
|
| 99 |
+
"MMK": {"name": "Myanmar Kyat", "name_cn": "缅甸元", "symbol": "K"},
|
| 100 |
+
"MNT": {"name": "Mongolian Tugrik", "name_cn": "蒙古图格里克", "symbol": "₮"},
|
| 101 |
+
"MOP": {"name": "Macanese Pataca", "name_cn": "澳门元", "symbol": "MOP$"},
|
| 102 |
+
"MRU": {"name": "Mauritanian Ouguiya", "name_cn": "毛里塔尼亚乌吉亚", "symbol": "UM"},
|
| 103 |
+
"MUR": {"name": "Mauritian Rupee", "name_cn": "毛里求斯卢比", "symbol": "₨"},
|
| 104 |
+
"MVR": {"name": "Maldivian Rufiyaa", "name_cn": "马尔代夫拉菲亚", "symbol": "Rf"},
|
| 105 |
+
"MWK": {"name": "Malawian Kwacha", "name_cn": "马拉维克瓦查", "symbol": "MK"},
|
| 106 |
+
"MXN": {"name": "Mexican Peso", "name_cn": "墨西哥比索", "symbol": "$"},
|
| 107 |
+
"MYR": {"name": "Malaysian Ringgit", "name_cn": "马来西亚林吉特", "symbol": "RM"},
|
| 108 |
+
"MZN": {"name": "Mozambican Metical", "name_cn": "莫桑比克梅蒂卡尔", "symbol": "MT"},
|
| 109 |
+
"NAD": {"name": "Namibian Dollar", "name_cn": "纳米比亚元", "symbol": "$"},
|
| 110 |
+
"NGN": {"name": "Nigerian Naira", "name_cn": "尼日利亚奈拉", "symbol": "₦"},
|
| 111 |
+
"NIO": {"name": "Nicaraguan Córdoba", "name_cn": "尼加拉瓜科多巴", "symbol": "C$"},
|
| 112 |
+
"NOK": {"name": "Norwegian Krone", "name_cn": "挪威克朗", "symbol": "kr"},
|
| 113 |
+
"NPR": {"name": "Nepalese Rupee", "name_cn": "尼泊尔卢比", "symbol": "₨"},
|
| 114 |
+
"NZD": {"name": "New Zealand Dollar", "name_cn": "新西兰元", "symbol": "NZ$"},
|
| 115 |
+
"OMR": {"name": "Omani Rial", "name_cn": "阿曼里亚尔", "symbol": "ر.ع."},
|
| 116 |
+
"PAB": {"name": "Panamanian Balboa", "name_cn": "巴拿马巴波亚", "symbol": "B/."},
|
| 117 |
+
"PEN": {"name": "Peruvian Sol", "name_cn": "秘鲁索尔", "symbol": "S/"},
|
| 118 |
+
"PGK": {"name": "Papua New Guinean Kina", "name_cn": "巴布亚新几内亚基那", "symbol": "K"},
|
| 119 |
+
"PHP": {"name": "Philippine Peso", "name_cn": "菲律宾比索", "symbol": "₱"},
|
| 120 |
+
"PKR": {"name": "Pakistani Rupee", "name_cn": "巴基斯坦卢比", "symbol": "₨"},
|
| 121 |
+
"PLN": {"name": "Polish Zloty", "name_cn": "波兰兹罗提", "symbol": "zł"},
|
| 122 |
+
"PYG": {"name": "Paraguayan Guarani", "name_cn": "巴拉圭瓜拉尼", "symbol": "₲"},
|
| 123 |
+
"QAR": {"name": "Qatari Riyal", "name_cn": "卡塔尔里亚尔", "symbol": "ر.ق"},
|
| 124 |
+
"RON": {"name": "Romanian Leu", "name_cn": "罗马尼亚列伊", "symbol": "lei"},
|
| 125 |
+
"RSD": {"name": "Serbian Dinar", "name_cn": "塞尔维亚第纳尔", "symbol": "дин."},
|
| 126 |
+
"RUB": {"name": "Russian Ruble", "name_cn": "俄罗斯卢布", "symbol": "₽"},
|
| 127 |
+
"RWF": {"name": "Rwandan Franc", "name_cn": "卢旺达法郎", "symbol": "FRw"},
|
| 128 |
+
"SAR": {"name": "Saudi Riyal", "name_cn": "沙特里亚尔", "symbol": "﷼"},
|
| 129 |
+
"SBD": {"name": "Solomon Islands Dollar", "name_cn": "所罗门群岛元", "symbol": "$"},
|
| 130 |
+
"SCR": {"name": "Seychellois Rupee", "name_cn": "塞舌尔卢比", "symbol": "₨"},
|
| 131 |
+
"SDG": {"name": "Sudanese Pound", "name_cn": "苏丹镑", "symbol": "£"},
|
| 132 |
+
"SEK": {"name": "Swedish Krona", "name_cn": "瑞典克朗", "symbol": "kr"},
|
| 133 |
+
"SGD": {"name": "Singapore Dollar", "name_cn": "新加坡元", "symbol": "S$"},
|
| 134 |
+
"SHP": {"name": "Saint Helena Pound", "name_cn": "圣赫勒拿镑", "symbol": "£"},
|
| 135 |
+
"SLE": {"name": "Sierra Leonean Leone", "name_cn": "塞拉利昂利昂", "symbol": "Le"},
|
| 136 |
+
"SLL": {"name": "Sierra Leonean Leone", "name_cn": "塞拉利昂利昂(旧)", "symbol": "Le"},
|
| 137 |
+
"SOS": {"name": "Somali Shilling", "name_cn": "索马里先令", "symbol": "Sh"},
|
| 138 |
+
"SRD": {"name": "Surinamese Dollar", "name_cn": "苏里南元", "symbol": "$"},
|
| 139 |
+
"SSP": {"name": "South Sudanese Pound", "name_cn": "南苏丹镑", "symbol": "£"},
|
| 140 |
+
"STN": {"name": "São Tomé and Príncipe Dobra", "name_cn": "圣多美多布拉", "symbol": "Db"},
|
| 141 |
+
"SYP": {"name": "Syrian Pound", "name_cn": "叙利亚镑", "symbol": "£"},
|
| 142 |
+
"SZL": {"name": "Swazi Lilangeni", "name_cn": "斯威士兰里兰吉尼", "symbol": "L"},
|
| 143 |
+
"THB": {"name": "Thai Baht", "name_cn": "泰铢", "symbol": "฿"},
|
| 144 |
+
"TJS": {"name": "Tajikistani Somoni", "name_cn": "塔吉克斯坦索莫尼", "symbol": "SM"},
|
| 145 |
+
"TMT": {"name": "Turkmenistani Manat", "name_cn": "土库曼斯坦马纳特", "symbol": "T"},
|
| 146 |
+
"TND": {"name": "Tunisian Dinar", "name_cn": "突尼斯第纳尔", "symbol": "د.ت"},
|
| 147 |
+
"TOP": {"name": "Tongan Paʻanga", "name_cn": "汤加潘加", "symbol": "T$"},
|
| 148 |
+
"TRY": {"name": "Turkish Lira", "name_cn": "土耳其里拉", "symbol": "₺"},
|
| 149 |
+
"TTD": {"name": "Trinidad and Tobago Dollar", "name_cn": "特立尼达和多巴哥元", "symbol": "$"},
|
| 150 |
+
"TVD": {"name": "Tuvaluan Dollar", "name_cn": "图瓦卢元", "symbol": "$"},
|
| 151 |
+
"TWD": {"name": "New Taiwan Dollar", "name_cn": "新台币", "symbol": "NT$"},
|
| 152 |
+
"TZS": {"name": "Tanzanian Shilling", "name_cn": "坦桑尼亚先令", "symbol": "TSh"},
|
| 153 |
+
"UAH": {"name": "Ukrainian Hryvnia", "name_cn": "乌克兰格里夫纳", "symbol": "₴"},
|
| 154 |
+
"UGX": {"name": "Ugandan Shilling", "name_cn": "乌干达先令", "symbol": "USh"},
|
| 155 |
+
"USD": {"name": "US Dollar", "name_cn": "美元", "symbol": "$"},
|
| 156 |
+
"UYU": {"name": "Uruguayan Peso", "name_cn": "乌拉圭比索", "symbol": "$"},
|
| 157 |
+
"UZS": {"name": "Uzbekistani Som", "name_cn": "乌兹别克斯坦苏姆", "symbol": "so'm"},
|
| 158 |
+
"VES": {"name": "Venezuelan Bolívar", "name_cn": "委内瑞拉玻利瓦尔", "symbol": "Bs."},
|
| 159 |
+
"VND": {"name": "Vietnamese Dong", "name_cn": "越南盾", "symbol": "₫"},
|
| 160 |
+
"VUV": {"name": "Vanuatu Vatu", "name_cn": "瓦努阿图瓦图", "symbol": "VT"},
|
| 161 |
+
"WST": {"name": "Samoan Tala", "name_cn": "萨摩亚塔拉", "symbol": "T"},
|
| 162 |
+
"XAF": {"name": "Central African CFA Franc", "name_cn": "中非金融合作法郎", "symbol": "FCFA"},
|
| 163 |
+
"XCD": {"name": "East Caribbean Dollar", "name_cn": "东加勒比元", "symbol": "$"},
|
| 164 |
+
"XCG": {"name": "Caribbean Guilder", "name_cn": "加勒比盾", "symbol": "ƒ"},
|
| 165 |
+
"XDR": {"name": "Special Drawing Rights", "name_cn": "特别提款权", "symbol": "SDR"},
|
| 166 |
+
"XOF": {"name": "West African CFA Franc", "name_cn": "西非金融合作法郎", "symbol": "CFA"},
|
| 167 |
+
"XPF": {"name": "CFP Franc", "name_cn": "太平洋法郎", "symbol": "₣"},
|
| 168 |
+
"YER": {"name": "Yemeni Rial", "name_cn": "也门里亚尔", "symbol": "﷼"},
|
| 169 |
+
"ZAR": {"name": "South African Rand", "name_cn": "南非兰特", "symbol": "R"},
|
| 170 |
+
"ZMW": {"name": "Zambian Kwacha", "name_cn": "赞比亚克瓦查", "symbol": "ZK"},
|
| 171 |
+
"ZWG": {"name": "Zimbabwe Gold", "name_cn": "津巴布韦金", "symbol": "ZiG"},
|
| 172 |
+
"ZWL": {"name": "Zimbabwean Dollar", "name_cn": "津巴布韦元", "symbol": "$"},
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def get_currency_info(code: str) -> dict:
|
| 177 |
+
"""获取货币信息"""
|
| 178 |
+
code = code.upper()
|
| 179 |
+
default_info = {"name": code, "name_cn": code, "symbol": ""}
|
| 180 |
+
return CURRENCY_INFO.get(code, default_info)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def get_all_currencies() -> dict:
|
| 184 |
+
"""获取所有货币信息"""
|
| 185 |
+
return CURRENCY_INFO
|
app/utils/logger.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
日志工具模块
|
| 3 |
+
使用 loguru 提供统一的日志记录
|
| 4 |
+
"""
|
| 5 |
+
import sys
|
| 6 |
+
from loguru import logger
|
| 7 |
+
|
| 8 |
+
# 移除默认处理器
|
| 9 |
+
logger.remove()
|
| 10 |
+
|
| 11 |
+
# 添加控制台处理器
|
| 12 |
+
logger.add(
|
| 13 |
+
sys.stdout,
|
| 14 |
+
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
|
| 15 |
+
level="INFO",
|
| 16 |
+
colorize=True
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
# 添加文件处理器(可选)
|
| 20 |
+
logger.add(
|
| 21 |
+
"logs/app_{time:YYYY-MM-DD}.log",
|
| 22 |
+
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
|
| 23 |
+
level="DEBUG",
|
| 24 |
+
rotation="00:00", # 每天轮换
|
| 25 |
+
retention="7 days", # 保留 7 天
|
| 26 |
+
compression="zip", # 压缩旧日志
|
| 27 |
+
encoding="utf-8"
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# 导出 logger
|
| 31 |
+
__all__ = ["logger"]
|
docker-compose.yaml
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ========================================
|
| 2 |
+
# 汇率换算服务 Docker Compose 配置
|
| 3 |
+
# ========================================
|
| 4 |
+
|
| 5 |
+
version: "3.8"
|
| 6 |
+
|
| 7 |
+
services:
|
| 8 |
+
# 汇率换算服务
|
| 9 |
+
exchange-rates:
|
| 10 |
+
build:
|
| 11 |
+
context: .
|
| 12 |
+
dockerfile: Dockerfile
|
| 13 |
+
container_name: exchange-rates
|
| 14 |
+
restart: unless-stopped
|
| 15 |
+
|
| 16 |
+
# 端口映射
|
| 17 |
+
ports:
|
| 18 |
+
- "${SERVER_PORT:-8000}:8000"
|
| 19 |
+
|
| 20 |
+
# 环境变量配置
|
| 21 |
+
environment:
|
| 22 |
+
# API Keys - 多个 key 用逗号分隔
|
| 23 |
+
- API_KEYS=${API_KEYS:-your_api_key_here}
|
| 24 |
+
# 基准货币
|
| 25 |
+
- BASE_CURRENCY=${BASE_CURRENCY:-CNY}
|
| 26 |
+
# 缓存更新间隔(秒)
|
| 27 |
+
- CACHE_UPDATE_INTERVAL=${CACHE_UPDATE_INTERVAL:-3600}
|
| 28 |
+
# API 请求超时时间(秒)
|
| 29 |
+
- REQUEST_TIMEOUT=${REQUEST_TIMEOUT:-10}
|
| 30 |
+
# 最大重试次数
|
| 31 |
+
- MAX_RETRIES=${MAX_RETRIES:-3}
|
| 32 |
+
# 服务端口
|
| 33 |
+
- SERVER_PORT=8000
|
| 34 |
+
# 时区设置
|
| 35 |
+
- TZ=Asia/Shanghai
|
| 36 |
+
|
| 37 |
+
# 使用 .env 文件加载环境变量
|
| 38 |
+
env_file:
|
| 39 |
+
- .env
|
| 40 |
+
|
| 41 |
+
# 健康检查
|
| 42 |
+
healthcheck:
|
| 43 |
+
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/health', timeout=5)"]
|
| 44 |
+
interval: 30s
|
| 45 |
+
timeout: 10s
|
| 46 |
+
retries: 3
|
| 47 |
+
start_period: 10s
|
| 48 |
+
|
| 49 |
+
# 日志配置
|
| 50 |
+
logging:
|
| 51 |
+
driver: "json-file"
|
| 52 |
+
options:
|
| 53 |
+
max-size: "10m"
|
| 54 |
+
max-file: "3"
|
| 55 |
+
|
| 56 |
+
# 资源限制(可选)
|
| 57 |
+
deploy:
|
| 58 |
+
resources:
|
| 59 |
+
limits:
|
| 60 |
+
cpus: "1.0"
|
| 61 |
+
memory: 512M
|
| 62 |
+
reservations:
|
| 63 |
+
cpus: "0.25"
|
| 64 |
+
memory: 128M
|
| 65 |
+
|
| 66 |
+
# ========================================
|
| 67 |
+
# 使用说明:
|
| 68 |
+
# ========================================
|
| 69 |
+
# 1. 确保已配置 .env 文件(参考 .env.example)
|
| 70 |
+
# 2. 构建并启动服务:
|
| 71 |
+
# docker-compose up -d --build
|
| 72 |
+
# 3. 查看日志:
|
| 73 |
+
# docker-compose logs -f
|
| 74 |
+
# 4. 停止服务:
|
| 75 |
+
# docker-compose down
|
| 76 |
+
# 5. 访问服务:
|
| 77 |
+
# - 首页: http://localhost:8000
|
| 78 |
+
# - API 文档: http://localhost:8000/docs
|
| 79 |
+
# ========================================
|
logs/app_2025-12-05.log
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
2025-12-05 23:16:12 | INFO | app.main:lifespan:24 - ==================================================
|
| 2 |
+
2025-12-05 23:16:12 | INFO | app.main:lifespan:25 - Application starting...
|
| 3 |
+
2025-12-05 23:16:12 | INFO | app.main:lifespan:26 - ==================================================
|
| 4 |
+
2025-12-05 23:16:12 | INFO | app.main:lifespan:31 - Configuration loaded: Base currency = CNY
|
| 5 |
+
2025-12-05 23:16:12 | INFO | app.main:lifespan:32 - API Keys count: 1
|
| 6 |
+
2025-12-05 23:16:12 | INFO | app.main:lifespan:33 - Cache update interval: 3600s
|
| 7 |
+
2025-12-05 23:16:12 | INFO | app.services.exchange_service:__init__:43 - ExchangeRateService initialized with 1 API key(s)
|
| 8 |
+
2025-12-05 23:16:12 | INFO | app.services.scheduler:start:49 - Starting rate scheduler...
|
| 9 |
+
2025-12-05 23:16:12 | INFO | app.services.scheduler:start:52 - Performing initial rate fetch...
|
| 10 |
+
2025-12-05 23:16:12 | INFO | app.services.scheduler:_update_job:24 - Scheduled rate update started
|
| 11 |
+
2025-12-05 23:16:12 | INFO | app.services.exchange_service:fetch_rates:114 - Fetching exchange rates (attempt 1/3)
|
| 12 |
+
2025-12-05 23:16:16 | WARNING | app.services.exchange_service:fetch_rates:158 - Connection error:
|
| 13 |
+
2025-12-05 23:16:17 | INFO | app.services.exchange_service:fetch_rates:114 - Fetching exchange rates (attempt 2/3)
|
| 14 |
+
2025-12-05 23:16:18 | INFO | app.services.exchange_service:fetch_rates:123 - Successfully fetched rates for CNY
|
| 15 |
+
2025-12-05 23:16:18 | INFO | app.services.exchange_service:update_cache:188 - Cache updated successfully with 166 currencies
|
| 16 |
+
2025-12-05 23:16:18 | INFO | app.services.scheduler:_update_job:32 - Scheduled rate update completed successfully. Currencies: 166, Source update: Fri, 05 Dec 2025 00:00:01 +0000
|
| 17 |
+
2025-12-05 23:16:18 | INFO | app.services.scheduler:start:68 - Scheduler started successfully. Update interval: 3600s (60.0 minutes)
|
| 18 |
+
2025-12-05 23:16:18 | INFO | app.main:lifespan:49 - Application started successfully!
|
| 19 |
+
2025-12-05 23:16:18 | INFO | app.main:lifespan:50 - ==================================================
|
| 20 |
+
2025-12-05 23:21:14 | INFO | app.main:lifespan:60 - ==================================================
|
| 21 |
+
2025-12-05 23:21:14 | INFO | app.main:lifespan:61 - Application shutting down...
|
| 22 |
+
2025-12-05 23:21:14 | INFO | app.services.scheduler:stop:82 - Scheduler stopped
|
| 23 |
+
2025-12-05 23:21:14 | INFO | app.main:lifespan:66 - Application stopped
|
| 24 |
+
2025-12-05 23:21:14 | INFO | app.main:lifespan:67 - ==================================================
|
| 25 |
+
2025-12-05 23:43:36 | INFO | app.main:lifespan:24 - ==================================================
|
| 26 |
+
2025-12-05 23:43:36 | INFO | app.main:lifespan:25 - 应用正在启动...
|
| 27 |
+
2025-12-05 23:43:36 | INFO | app.main:lifespan:26 - ==================================================
|
| 28 |
+
2025-12-05 23:43:36 | INFO | app.main:lifespan:31 - 配置加载完成: 基准货币 = CNY
|
| 29 |
+
2025-12-05 23:43:36 | INFO | app.main:lifespan:32 - API Key 数量: 1
|
| 30 |
+
2025-12-05 23:43:36 | INFO | app.main:lifespan:33 - 缓存更新间隔: 3600 秒
|
| 31 |
+
2025-12-05 23:43:36 | INFO | app.services.exchange_service:__init__:43 - 汇率服务初始化完成,共配置 1 个 API Key
|
| 32 |
+
2025-12-05 23:43:36 | INFO | app.services.scheduler:start:49 - 正在启动汇率调度器...
|
| 33 |
+
2025-12-05 23:43:36 | INFO | app.services.scheduler:start:52 - 正在执行初始汇率获取...
|
| 34 |
+
2025-12-05 23:43:36 | INFO | app.services.scheduler:_update_job:24 - 定时汇率更新开始
|
| 35 |
+
2025-12-05 23:43:36 | INFO | app.services.exchange_service:fetch_rates:114 - 正在获取汇率数据 (第 1/3 次尝试)
|
| 36 |
+
2025-12-05 23:43:37 | INFO | app.services.exchange_service:fetch_rates:123 - 成功获取 CNY 的汇率数据
|
| 37 |
+
2025-12-05 23:43:37 | INFO | app.services.exchange_service:update_cache:188 - 缓存更新成功,共 166 种货币
|
| 38 |
+
2025-12-05 23:43:37 | INFO | app.services.scheduler:_update_job:32 - 定时汇率更新完成。货币数量: 166, 数据源更新时间: Fri, 05 Dec 2025 00:00:01 +0000
|
| 39 |
+
2025-12-05 23:43:37 | INFO | app.services.scheduler:start:68 - 调度器启动成功。更新间隔: 3600 秒 (60.0 分钟)
|
| 40 |
+
2025-12-05 23:43:37 | INFO | app.main:lifespan:49 - 应用启动成功!
|
| 41 |
+
2025-12-05 23:43:37 | INFO | app.main:lifespan:50 - ==================================================
|
requirements.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Web 框架
|
| 2 |
+
fastapi==0.109.0
|
| 3 |
+
uvicorn[standard]==0.27.0
|
| 4 |
+
|
| 5 |
+
# HTTP 客户端
|
| 6 |
+
httpx==0.26.0
|
| 7 |
+
|
| 8 |
+
# 定时任务
|
| 9 |
+
apscheduler==3.10.4
|
| 10 |
+
|
| 11 |
+
# 数据验证
|
| 12 |
+
pydantic==2.5.3
|
| 13 |
+
pydantic-settings==2.1.0
|
| 14 |
+
|
| 15 |
+
# 配置管理
|
| 16 |
+
python-dotenv==1.0.0
|
| 17 |
+
|
| 18 |
+
# 模板引擎
|
| 19 |
+
jinja2==3.1.3
|
| 20 |
+
|
| 21 |
+
# 日志
|
| 22 |
+
loguru==0.7.2
|
run.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""
|
| 3 |
+
启动脚本
|
| 4 |
+
运行汇率换算服务
|
| 5 |
+
"""
|
| 6 |
+
import uvicorn
|
| 7 |
+
from app.config import get_settings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def main():
|
| 11 |
+
"""主函数"""
|
| 12 |
+
settings = get_settings()
|
| 13 |
+
|
| 14 |
+
print("=" * 50)
|
| 15 |
+
print("🚀 启动汇率换算服务")
|
| 16 |
+
print("=" * 50)
|
| 17 |
+
print(f"📍 服务地址: http://localhost:{settings.SERVER_PORT}")
|
| 18 |
+
print(f"📊 API 文档: http://localhost:{settings.SERVER_PORT}/docs")
|
| 19 |
+
print(f"💱 基准货币: {settings.BASE_CURRENCY}")
|
| 20 |
+
print(f"🔑 API Keys: {len(settings.API_KEYS)} 个")
|
| 21 |
+
print(f"⏰ 缓存更新间隔: {settings.CACHE_UPDATE_INTERVAL} 秒")
|
| 22 |
+
print("=" * 50)
|
| 23 |
+
|
| 24 |
+
uvicorn.run(
|
| 25 |
+
"app.main:app",
|
| 26 |
+
host="0.0.0.0",
|
| 27 |
+
port=settings.SERVER_PORT,
|
| 28 |
+
reload=True, # 开发模式下自动重载
|
| 29 |
+
log_level="info"
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
if __name__ == "__main__":
|
| 34 |
+
main()
|
static/css/style.css
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ==================== 基础样式 ==================== */
|
| 2 |
+
* {
|
| 3 |
+
margin: 0;
|
| 4 |
+
padding: 0;
|
| 5 |
+
box-sizing: border-box;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
:root {
|
| 9 |
+
--primary-color: #667eea;
|
| 10 |
+
--primary-dark: #5a67d8;
|
| 11 |
+
--secondary-color: #764ba2;
|
| 12 |
+
--success-color: #48bb78;
|
| 13 |
+
--error-color: #f56565;
|
| 14 |
+
--warning-color: #ed8936;
|
| 15 |
+
--text-color: #2d3748;
|
| 16 |
+
--text-light: #718096;
|
| 17 |
+
--bg-light: #f7fafc;
|
| 18 |
+
--border-color: #e2e8f0;
|
| 19 |
+
--card-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
| 20 |
+
--card-shadow-hover: 0 8px 25px rgba(0, 0, 0, 0.15);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
body {
|
| 24 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
| 25 |
+
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
| 26 |
+
min-height: 100vh;
|
| 27 |
+
padding: 20px;
|
| 28 |
+
color: var(--text-color);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/* ==================== 容器 ==================== */
|
| 32 |
+
.container {
|
| 33 |
+
max-width: 1400px;
|
| 34 |
+
margin: 0 auto;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/* ==================== 头部 ==================== */
|
| 38 |
+
header {
|
| 39 |
+
text-align: center;
|
| 40 |
+
color: white;
|
| 41 |
+
margin-bottom: 30px;
|
| 42 |
+
padding: 20px 0;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
header h1 {
|
| 46 |
+
font-size: 2.5rem;
|
| 47 |
+
margin-bottom: 15px;
|
| 48 |
+
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.header-info {
|
| 52 |
+
display: flex;
|
| 53 |
+
justify-content: center;
|
| 54 |
+
gap: 30px;
|
| 55 |
+
flex-wrap: wrap;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.header-info p {
|
| 59 |
+
opacity: 0.9;
|
| 60 |
+
font-size: 0.95rem;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.header-info .label {
|
| 64 |
+
opacity: 0.7;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/* ==================== 工具栏 ==================== */
|
| 68 |
+
.toolbar {
|
| 69 |
+
display: flex;
|
| 70 |
+
gap: 15px;
|
| 71 |
+
margin-bottom: 20px;
|
| 72 |
+
flex-wrap: wrap;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/* 搜索框 */
|
| 76 |
+
.search-box {
|
| 77 |
+
flex: 1;
|
| 78 |
+
min-width: 250px;
|
| 79 |
+
position: relative;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.search-box .search-icon {
|
| 83 |
+
position: absolute;
|
| 84 |
+
left: 15px;
|
| 85 |
+
top: 50%;
|
| 86 |
+
transform: translateY(-50%);
|
| 87 |
+
width: 20px;
|
| 88 |
+
height: 20px;
|
| 89 |
+
color: var(--text-light);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.search-box input {
|
| 93 |
+
width: 100%;
|
| 94 |
+
padding: 15px 20px 15px 45px;
|
| 95 |
+
border: none;
|
| 96 |
+
border-radius: 12px;
|
| 97 |
+
font-size: 1rem;
|
| 98 |
+
box-shadow: var(--card-shadow);
|
| 99 |
+
transition: box-shadow 0.3s, transform 0.2s;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.search-box input:focus {
|
| 103 |
+
outline: none;
|
| 104 |
+
box-shadow: var(--card-shadow-hover);
|
| 105 |
+
transform: translateY(-1px);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/* 筛选按钮 */
|
| 109 |
+
.filter-buttons {
|
| 110 |
+
display: flex;
|
| 111 |
+
gap: 10px;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.filter-btn {
|
| 115 |
+
padding: 12px 24px;
|
| 116 |
+
border: none;
|
| 117 |
+
border-radius: 12px;
|
| 118 |
+
background: white;
|
| 119 |
+
color: var(--text-color);
|
| 120 |
+
font-size: 0.95rem;
|
| 121 |
+
cursor: pointer;
|
| 122 |
+
box-shadow: var(--card-shadow);
|
| 123 |
+
transition: all 0.3s;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.filter-btn:hover {
|
| 127 |
+
transform: translateY(-2px);
|
| 128 |
+
box-shadow: var(--card-shadow-hover);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.filter-btn.active {
|
| 132 |
+
background: var(--primary-color);
|
| 133 |
+
color: white;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/* ==================== 提示信息 ==================== */
|
| 137 |
+
.tips {
|
| 138 |
+
display: flex;
|
| 139 |
+
align-items: center;
|
| 140 |
+
gap: 10px;
|
| 141 |
+
background: rgba(255, 255, 255, 0.9);
|
| 142 |
+
padding: 15px 20px;
|
| 143 |
+
border-radius: 12px;
|
| 144 |
+
margin-bottom: 20px;
|
| 145 |
+
box-shadow: var(--card-shadow);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.tips svg {
|
| 149 |
+
width: 20px;
|
| 150 |
+
height: 20px;
|
| 151 |
+
color: var(--primary-color);
|
| 152 |
+
flex-shrink: 0;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.tips span {
|
| 156 |
+
color: var(--text-light);
|
| 157 |
+
font-size: 0.9rem;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/* ==================== 加载状态 ==================== */
|
| 161 |
+
.loading {
|
| 162 |
+
display: flex;
|
| 163 |
+
flex-direction: column;
|
| 164 |
+
align-items: center;
|
| 165 |
+
justify-content: center;
|
| 166 |
+
padding: 60px 20px;
|
| 167 |
+
color: white;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.spinner {
|
| 171 |
+
width: 50px;
|
| 172 |
+
height: 50px;
|
| 173 |
+
border: 4px solid rgba(255, 255, 255, 0.3);
|
| 174 |
+
border-top-color: white;
|
| 175 |
+
border-radius: 50%;
|
| 176 |
+
animation: spin 1s linear infinite;
|
| 177 |
+
margin-bottom: 20px;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
@keyframes spin {
|
| 181 |
+
to {
|
| 182 |
+
transform: rotate(360deg);
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.loading p {
|
| 187 |
+
font-size: 1.1rem;
|
| 188 |
+
opacity: 0.9;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.loading.hidden {
|
| 192 |
+
display: none;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
/* ==================== 错误提示 ==================== */
|
| 196 |
+
.error-message {
|
| 197 |
+
display: flex;
|
| 198 |
+
align-items: center;
|
| 199 |
+
gap: 15px;
|
| 200 |
+
background: #fff5f5;
|
| 201 |
+
border: 1px solid #fed7d7;
|
| 202 |
+
padding: 20px;
|
| 203 |
+
border-radius: 12px;
|
| 204 |
+
margin-bottom: 20px;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.error-message svg {
|
| 208 |
+
width: 24px;
|
| 209 |
+
height: 24px;
|
| 210 |
+
color: var(--error-color);
|
| 211 |
+
flex-shrink: 0;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.error-message span {
|
| 215 |
+
flex: 1;
|
| 216 |
+
color: var(--error-color);
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.retry-btn {
|
| 220 |
+
padding: 8px 20px;
|
| 221 |
+
background: var(--error-color);
|
| 222 |
+
color: white;
|
| 223 |
+
border: none;
|
| 224 |
+
border-radius: 8px;
|
| 225 |
+
cursor: pointer;
|
| 226 |
+
font-size: 0.9rem;
|
| 227 |
+
transition: background 0.3s;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.retry-btn:hover {
|
| 231 |
+
background: #e53e3e;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
/* ==================== 货币网格 ==================== */
|
| 235 |
+
.currency-grid {
|
| 236 |
+
display: grid;
|
| 237 |
+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
| 238 |
+
gap: 15px;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
/* ==================== 货币卡片 ==================== */
|
| 242 |
+
.currency-card {
|
| 243 |
+
background: white;
|
| 244 |
+
border-radius: 16px;
|
| 245 |
+
padding: 18px 20px;
|
| 246 |
+
box-shadow: var(--card-shadow);
|
| 247 |
+
display: flex;
|
| 248 |
+
align-items: center;
|
| 249 |
+
gap: 15px;
|
| 250 |
+
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
|
| 251 |
+
border: 2px solid transparent;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.currency-card:hover {
|
| 255 |
+
transform: translateY(-3px);
|
| 256 |
+
box-shadow: var(--card-shadow-hover);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
.currency-card.priority {
|
| 260 |
+
border-left: 4px solid var(--primary-color);
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
.currency-card.active {
|
| 264 |
+
border-color: var(--primary-color);
|
| 265 |
+
background: linear-gradient(to right, #f8f9ff, white);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.currency-card.hidden {
|
| 269 |
+
display: none;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
/* 货币图标/符号 */
|
| 273 |
+
.currency-icon {
|
| 274 |
+
width: 48px;
|
| 275 |
+
height: 48px;
|
| 276 |
+
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
| 277 |
+
border-radius: 12px;
|
| 278 |
+
display: flex;
|
| 279 |
+
align-items: center;
|
| 280 |
+
justify-content: center;
|
| 281 |
+
color: white;
|
| 282 |
+
font-weight: bold;
|
| 283 |
+
font-size: 1.1rem;
|
| 284 |
+
flex-shrink: 0;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
/* 货币信息 */
|
| 288 |
+
.currency-info {
|
| 289 |
+
flex: 0 0 90px;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.currency-code {
|
| 293 |
+
font-size: 1.25rem;
|
| 294 |
+
font-weight: 700;
|
| 295 |
+
color: var(--text-color);
|
| 296 |
+
letter-spacing: 0.5px;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
.currency-name {
|
| 300 |
+
font-size: 0.85rem;
|
| 301 |
+
color: var(--text-light);
|
| 302 |
+
white-space: nowrap;
|
| 303 |
+
overflow: hidden;
|
| 304 |
+
text-overflow: ellipsis;
|
| 305 |
+
max-width: 100px;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
/* 输入区域 */
|
| 309 |
+
.currency-input {
|
| 310 |
+
flex: 1;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.currency-input input {
|
| 314 |
+
width: 100%;
|
| 315 |
+
padding: 12px 15px;
|
| 316 |
+
border: 2px solid var(--border-color);
|
| 317 |
+
border-radius: 10px;
|
| 318 |
+
font-size: 1.15rem;
|
| 319 |
+
text-align: right;
|
| 320 |
+
font-weight: 500;
|
| 321 |
+
transition: border-color 0.3s, box-shadow 0.3s;
|
| 322 |
+
color: var(--text-color);
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
.currency-input input:focus {
|
| 326 |
+
outline: none;
|
| 327 |
+
border-color: var(--primary-color);
|
| 328 |
+
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.currency-input input::placeholder {
|
| 332 |
+
color: #cbd5e0;
|
| 333 |
+
font-weight: normal;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
/* 禁用输入时去除 spinner */
|
| 337 |
+
.currency-input input::-webkit-outer-spin-button,
|
| 338 |
+
.currency-input input::-webkit-inner-spin-button {
|
| 339 |
+
-webkit-appearance: none;
|
| 340 |
+
margin: 0;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.currency-input input[type=number] {
|
| 344 |
+
-moz-appearance: textfield;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
/* 汇率显示 */
|
| 348 |
+
.currency-rate {
|
| 349 |
+
font-size: 0.75rem;
|
| 350 |
+
color: var(--text-light);
|
| 351 |
+
text-align: right;
|
| 352 |
+
margin-top: 6px;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
/* ==================== 底部 ==================== */
|
| 356 |
+
footer {
|
| 357 |
+
text-align: center;
|
| 358 |
+
padding: 30px 20px;
|
| 359 |
+
color: rgba(255, 255, 255, 0.8);
|
| 360 |
+
font-size: 0.9rem;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
footer a {
|
| 364 |
+
color: white;
|
| 365 |
+
text-decoration: none;
|
| 366 |
+
font-weight: 500;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
footer a:hover {
|
| 370 |
+
text-decoration: underline;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
.footer-note {
|
| 374 |
+
margin-top: 8px;
|
| 375 |
+
opacity: 0.7;
|
| 376 |
+
font-size: 0.85rem;
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
/* ==================== 响应式设计 ==================== */
|
| 380 |
+
@media (max-width: 768px) {
|
| 381 |
+
body {
|
| 382 |
+
padding: 15px;
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
header h1 {
|
| 386 |
+
font-size: 1.8rem;
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
.header-info {
|
| 390 |
+
flex-direction: column;
|
| 391 |
+
gap: 10px;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
.toolbar {
|
| 395 |
+
flex-direction: column;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.search-box {
|
| 399 |
+
min-width: auto;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
.filter-buttons {
|
| 403 |
+
justify-content: center;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
.currency-grid {
|
| 407 |
+
grid-template-columns: 1fr;
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
.currency-card {
|
| 411 |
+
padding: 15px;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
.currency-icon {
|
| 415 |
+
width: 42px;
|
| 416 |
+
height: 42px;
|
| 417 |
+
font-size: 1rem;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
.currency-info {
|
| 421 |
+
flex: 0 0 80px;
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
.currency-name {
|
| 425 |
+
max-width: 80px;
|
| 426 |
+
}
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
/* ==================== 动画 ==================== */
|
| 430 |
+
@keyframes fadeIn {
|
| 431 |
+
from {
|
| 432 |
+
opacity: 0;
|
| 433 |
+
transform: translateY(10px);
|
| 434 |
+
}
|
| 435 |
+
to {
|
| 436 |
+
opacity: 1;
|
| 437 |
+
transform: translateY(0);
|
| 438 |
+
}
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
.currency-card {
|
| 442 |
+
animation: fadeIn 0.3s ease-out;
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
/* 卡片依次出现的延迟效果 */
|
| 446 |
+
.currency-card:nth-child(1) { animation-delay: 0.02s; }
|
| 447 |
+
.currency-card:nth-child(2) { animation-delay: 0.04s; }
|
| 448 |
+
.currency-card:nth-child(3) { animation-delay: 0.06s; }
|
| 449 |
+
.currency-card:nth-child(4) { animation-delay: 0.08s; }
|
| 450 |
+
.currency-card:nth-child(5) { animation-delay: 0.1s; }
|
| 451 |
+
.currency-card:nth-child(6) { animation-delay: 0.12s; }
|
| 452 |
+
.currency-card:nth-child(7) { animation-delay: 0.14s; }
|
| 453 |
+
.currency-card:nth-child(8) { animation-delay: 0.16s; }
|
| 454 |
+
.currency-card:nth-child(9) { animation-delay: 0.18s; }
|
| 455 |
+
.currency-card:nth-child(10) { animation-delay: 0.2s; }
|
| 456 |
+
|
| 457 |
+
/* ==================== 滚动条美化 ==================== */
|
| 458 |
+
::-webkit-scrollbar {
|
| 459 |
+
width: 8px;
|
| 460 |
+
height: 8px;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
::-webkit-scrollbar-track {
|
| 464 |
+
background: rgba(255, 255, 255, 0.1);
|
| 465 |
+
border-radius: 4px;
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
::-webkit-scrollbar-thumb {
|
| 469 |
+
background: rgba(255, 255, 255, 0.3);
|
| 470 |
+
border-radius: 4px;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
::-webkit-scrollbar-thumb:hover {
|
| 474 |
+
background: rgba(255, 255, 255, 0.5);
|
| 475 |
+
}
|
static/js/app.js
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 汇率换算器前端交互逻辑
|
| 3 |
+
*
|
| 4 |
+
* 功能:
|
| 5 |
+
* - 加载货币列表和汇率数据
|
| 6 |
+
* - 实时输入实时换算
|
| 7 |
+
* - 搜索和筛选货币
|
| 8 |
+
* - 防抖处理优化性能
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
class CurrencyConverter {
|
| 12 |
+
constructor() {
|
| 13 |
+
// 数据
|
| 14 |
+
this.currencies = [];
|
| 15 |
+
this.rates = {};
|
| 16 |
+
this.baseCurrency = 'CNY';
|
| 17 |
+
|
| 18 |
+
// 状态
|
| 19 |
+
this.activeInput = null;
|
| 20 |
+
this.debounceTimer = null;
|
| 21 |
+
this.currentFilter = 'all';
|
| 22 |
+
|
| 23 |
+
// 常用货币列表
|
| 24 |
+
this.priorityCodes = ['CNY', 'USD', 'EUR', 'GBP', 'JPY', 'HKD', 'AUD', 'CAD', 'CHF', 'SGD', 'KRW', 'TWD', 'THB', 'MYR', 'INR', 'RUB'];
|
| 25 |
+
|
| 26 |
+
// 初始化
|
| 27 |
+
this.init();
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* 初始化应用
|
| 32 |
+
*/
|
| 33 |
+
async init() {
|
| 34 |
+
try {
|
| 35 |
+
// 并行加载数据
|
| 36 |
+
await Promise.all([
|
| 37 |
+
this.loadCurrencies(),
|
| 38 |
+
this.loadRates()
|
| 39 |
+
]);
|
| 40 |
+
|
| 41 |
+
// 隐藏加载状态
|
| 42 |
+
this.hideLoading();
|
| 43 |
+
|
| 44 |
+
// 渲染界面
|
| 45 |
+
this.renderCurrencyGrid();
|
| 46 |
+
this.setupEventListeners();
|
| 47 |
+
this.updateStatusInfo();
|
| 48 |
+
|
| 49 |
+
} catch (error) {
|
| 50 |
+
console.error('Initialization failed:', error);
|
| 51 |
+
this.showError('加载汇率数据失败,请检查网络连接');
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/**
|
| 56 |
+
* 加载货币列表
|
| 57 |
+
*/
|
| 58 |
+
async loadCurrencies() {
|
| 59 |
+
try {
|
| 60 |
+
const response = await fetch('/api/currencies');
|
| 61 |
+
|
| 62 |
+
if (!response.ok) {
|
| 63 |
+
throw new Error(`HTTP ${response.status}`);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
const data = await response.json();
|
| 67 |
+
|
| 68 |
+
if (data.success) {
|
| 69 |
+
this.currencies = data.currencies;
|
| 70 |
+
console.log(`Loaded ${this.currencies.length} currencies`);
|
| 71 |
+
} else {
|
| 72 |
+
throw new Error('API returned unsuccessful response');
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
} catch (error) {
|
| 76 |
+
console.error('Failed to load currencies:', error);
|
| 77 |
+
throw error;
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/**
|
| 82 |
+
* 加载汇率数据
|
| 83 |
+
*/
|
| 84 |
+
async loadRates() {
|
| 85 |
+
try {
|
| 86 |
+
const response = await fetch('/api/rates');
|
| 87 |
+
|
| 88 |
+
if (!response.ok) {
|
| 89 |
+
throw new Error(`HTTP ${response.status}`);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const data = await response.json();
|
| 93 |
+
|
| 94 |
+
if (data.success) {
|
| 95 |
+
this.rates = data.rates;
|
| 96 |
+
this.baseCurrency = data.base_currency;
|
| 97 |
+
console.log(`Loaded rates for ${Object.keys(this.rates).length} currencies`);
|
| 98 |
+
} else {
|
| 99 |
+
throw new Error('API returned unsuccessful response');
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
} catch (error) {
|
| 103 |
+
console.error('Failed to load rates:', error);
|
| 104 |
+
throw error;
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/**
|
| 109 |
+
* 渲染货币网格
|
| 110 |
+
*/
|
| 111 |
+
renderCurrencyGrid() {
|
| 112 |
+
const grid = document.getElementById('currencyGrid');
|
| 113 |
+
|
| 114 |
+
if (!grid) return;
|
| 115 |
+
|
| 116 |
+
grid.innerHTML = this.currencies.map(currency => {
|
| 117 |
+
const isPriority = this.priorityCodes.includes(currency.code);
|
| 118 |
+
const rate = this.rates[currency.code] || 0;
|
| 119 |
+
const symbol = currency.symbol || currency.code.substring(0, 2);
|
| 120 |
+
|
| 121 |
+
return `
|
| 122 |
+
<div class="currency-card ${isPriority ? 'priority' : ''}"
|
| 123 |
+
data-code="${currency.code}"
|
| 124 |
+
data-priority="${isPriority}">
|
| 125 |
+
<div class="currency-icon">${symbol}</div>
|
| 126 |
+
<div class="currency-info">
|
| 127 |
+
<div class="currency-code">${currency.code}</div>
|
| 128 |
+
<div class="currency-name" title="${currency.name}">${currency.name_cn}</div>
|
| 129 |
+
</div>
|
| 130 |
+
<div class="currency-input">
|
| 131 |
+
<input type="number"
|
| 132 |
+
id="input-${currency.code}"
|
| 133 |
+
data-code="${currency.code}"
|
| 134 |
+
placeholder="0.00"
|
| 135 |
+
step="any"
|
| 136 |
+
inputmode="decimal">
|
| 137 |
+
<div class="currency-rate">1 ${this.baseCurrency} = ${this.formatRate(rate)} ${currency.code}</div>
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
`;
|
| 141 |
+
}).join('');
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
/**
|
| 145 |
+
* 设置事件监听器
|
| 146 |
+
*/
|
| 147 |
+
setupEventListeners() {
|
| 148 |
+
const grid = document.getElementById('currencyGrid');
|
| 149 |
+
const searchInput = document.getElementById('searchInput');
|
| 150 |
+
const retryBtn = document.getElementById('retryBtn');
|
| 151 |
+
|
| 152 |
+
// ========== 输入事件(事件委托)==========
|
| 153 |
+
if (grid) {
|
| 154 |
+
// 输入事件
|
| 155 |
+
grid.addEventListener('input', (e) => {
|
| 156 |
+
if (e.target.tagName === 'INPUT') {
|
| 157 |
+
this.handleInput(e.target);
|
| 158 |
+
}
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
// 焦点事件
|
| 162 |
+
grid.addEventListener('focusin', (e) => {
|
| 163 |
+
if (e.target.tagName === 'INPUT') {
|
| 164 |
+
this.activeInput = e.target;
|
| 165 |
+
const card = e.target.closest('.currency-card');
|
| 166 |
+
if (card) {
|
| 167 |
+
card.classList.add('active');
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
grid.addEventListener('focusout', (e) => {
|
| 173 |
+
if (e.target.tagName === 'INPUT') {
|
| 174 |
+
const card = e.target.closest('.currency-card');
|
| 175 |
+
if (card) {
|
| 176 |
+
card.classList.remove('active');
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
});
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// ========== 搜索事件 ==========
|
| 183 |
+
if (searchInput) {
|
| 184 |
+
searchInput.addEventListener('input', (e) => {
|
| 185 |
+
this.filterCurrencies(e.target.value);
|
| 186 |
+
});
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
// ========== 筛选按钮 ==========
|
| 190 |
+
document.querySelectorAll('.filter-btn').forEach(btn => {
|
| 191 |
+
btn.addEventListener('click', (e) => {
|
| 192 |
+
// 更新按钮状态
|
| 193 |
+
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
| 194 |
+
e.target.classList.add('active');
|
| 195 |
+
|
| 196 |
+
// 应用筛选
|
| 197 |
+
this.currentFilter = e.target.dataset.filter;
|
| 198 |
+
this.applyFilter();
|
| 199 |
+
});
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
// ========== 重试按钮 ==========
|
| 203 |
+
if (retryBtn) {
|
| 204 |
+
retryBtn.addEventListener('click', () => {
|
| 205 |
+
this.hideError();
|
| 206 |
+
this.showLoading();
|
| 207 |
+
this.init();
|
| 208 |
+
});
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
/**
|
| 213 |
+
* 处理输入事件
|
| 214 |
+
*/
|
| 215 |
+
handleInput(input) {
|
| 216 |
+
// 清除之前的定时器
|
| 217 |
+
clearTimeout(this.debounceTimer);
|
| 218 |
+
|
| 219 |
+
// 防抖处理:100ms 后执行计算
|
| 220 |
+
this.debounceTimer = setTimeout(() => {
|
| 221 |
+
this.calculateAll(input);
|
| 222 |
+
}, 100);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
/**
|
| 226 |
+
* 计算所有货币的换算值
|
| 227 |
+
*/
|
| 228 |
+
calculateAll(sourceInput) {
|
| 229 |
+
const sourceCode = sourceInput.dataset.code;
|
| 230 |
+
const sourceValue = parseFloat(sourceInput.value);
|
| 231 |
+
|
| 232 |
+
// 如果输入为空、无效或为零,清空所有其他输入
|
| 233 |
+
if (isNaN(sourceValue) || sourceValue === 0 || sourceInput.value.trim() === '') {
|
| 234 |
+
this.clearAll(sourceInput);
|
| 235 |
+
return;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
const sourceRate = this.rates[sourceCode];
|
| 239 |
+
|
| 240 |
+
if (!sourceRate) {
|
| 241 |
+
console.warn(`Rate not found for ${sourceCode}`);
|
| 242 |
+
return;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
// 计算并更新所有其他货币
|
| 246 |
+
this.currencies.forEach(currency => {
|
| 247 |
+
if (currency.code === sourceCode) return;
|
| 248 |
+
|
| 249 |
+
const targetRate = this.rates[currency.code];
|
| 250 |
+
|
| 251 |
+
if (!targetRate) return;
|
| 252 |
+
|
| 253 |
+
// 交叉汇率计算
|
| 254 |
+
// sourceRate = 1 CNY = X source_currency
|
| 255 |
+
// targetRate = 1 CNY = Y target_currency
|
| 256 |
+
// 所以 1 source_currency = (targetRate / sourceRate) target_currency
|
| 257 |
+
const crossRate = targetRate / sourceRate;
|
| 258 |
+
const result = sourceValue * crossRate;
|
| 259 |
+
|
| 260 |
+
const input = document.getElementById(`input-${currency.code}`);
|
| 261 |
+
|
| 262 |
+
if (input) {
|
| 263 |
+
input.value = this.formatNumber(result);
|
| 264 |
+
}
|
| 265 |
+
});
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
/**
|
| 269 |
+
* 清空所有输入(除了指定的输入框)
|
| 270 |
+
*/
|
| 271 |
+
clearAll(exceptInput = null) {
|
| 272 |
+
this.currencies.forEach(currency => {
|
| 273 |
+
const input = document.getElementById(`input-${currency.code}`);
|
| 274 |
+
|
| 275 |
+
if (input && input !== exceptInput) {
|
| 276 |
+
input.value = '';
|
| 277 |
+
}
|
| 278 |
+
});
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
/**
|
| 282 |
+
* 格式化数字显示
|
| 283 |
+
*/
|
| 284 |
+
formatNumber(num) {
|
| 285 |
+
if (num === 0) return '';
|
| 286 |
+
|
| 287 |
+
// 根据数值大小选择精度
|
| 288 |
+
if (Math.abs(num) >= 1000) {
|
| 289 |
+
return num.toFixed(2);
|
| 290 |
+
} else if (Math.abs(num) >= 1) {
|
| 291 |
+
return num.toFixed(4);
|
| 292 |
+
} else if (Math.abs(num) >= 0.0001) {
|
| 293 |
+
return num.toFixed(6);
|
| 294 |
+
} else {
|
| 295 |
+
return num.toExponential(4);
|
| 296 |
+
}
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
/**
|
| 300 |
+
* 格式化汇率显示
|
| 301 |
+
*/
|
| 302 |
+
formatRate(rate) {
|
| 303 |
+
if (rate >= 1) {
|
| 304 |
+
return rate.toFixed(4);
|
| 305 |
+
} else if (rate >= 0.0001) {
|
| 306 |
+
return rate.toFixed(6);
|
| 307 |
+
} else {
|
| 308 |
+
return rate.toExponential(4);
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
/**
|
| 313 |
+
* 搜索过滤货币
|
| 314 |
+
*/
|
| 315 |
+
filterCurrencies(keyword) {
|
| 316 |
+
const cards = document.querySelectorAll('.currency-card');
|
| 317 |
+
const lowerKeyword = keyword.toLowerCase().trim();
|
| 318 |
+
|
| 319 |
+
cards.forEach(card => {
|
| 320 |
+
const code = card.dataset.code.toLowerCase();
|
| 321 |
+
const currency = this.currencies.find(c => c.code === card.dataset.code);
|
| 322 |
+
const name = currency ? currency.name.toLowerCase() : '';
|
| 323 |
+
const nameCn = currency ? currency.name_cn : '';
|
| 324 |
+
|
| 325 |
+
// 匹配代码、英文名或中文名
|
| 326 |
+
const matchesSearch = !lowerKeyword ||
|
| 327 |
+
code.includes(lowerKeyword) ||
|
| 328 |
+
name.includes(lowerKeyword) ||
|
| 329 |
+
nameCn.includes(keyword);
|
| 330 |
+
|
| 331 |
+
// 同时考虑当前筛选状态
|
| 332 |
+
const matchesFilter = this.currentFilter === 'all' ||
|
| 333 |
+
(this.currentFilter === 'priority' && card.dataset.priority === 'true');
|
| 334 |
+
|
| 335 |
+
card.classList.toggle('hidden', !(matchesSearch && matchesFilter));
|
| 336 |
+
});
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
/**
|
| 340 |
+
* 应用筛选(常用/全部)
|
| 341 |
+
*/
|
| 342 |
+
applyFilter() {
|
| 343 |
+
const searchInput = document.getElementById('searchInput');
|
| 344 |
+
const keyword = searchInput ? searchInput.value : '';
|
| 345 |
+
this.filterCurrencies(keyword);
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
/**
|
| 349 |
+
* 更新状态信息
|
| 350 |
+
*/
|
| 351 |
+
async updateStatusInfo() {
|
| 352 |
+
try {
|
| 353 |
+
const response = await fetch('/api/status');
|
| 354 |
+
const data = await response.json();
|
| 355 |
+
|
| 356 |
+
// 更新时间
|
| 357 |
+
const lastUpdateEl = document.getElementById('lastUpdate');
|
| 358 |
+
if (lastUpdateEl && data.last_update) {
|
| 359 |
+
const date = new Date(data.last_update);
|
| 360 |
+
lastUpdateEl.textContent = date.toLocaleString('zh-CN');
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
// 货币数量
|
| 364 |
+
const countEl = document.getElementById('currencyCount');
|
| 365 |
+
if (countEl) {
|
| 366 |
+
countEl.textContent = data.currencies_count || this.currencies.length;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
// 基准货币
|
| 370 |
+
const baseEl = document.getElementById('baseCurrency');
|
| 371 |
+
if (baseEl) {
|
| 372 |
+
baseEl.textContent = this.baseCurrency;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
} catch (error) {
|
| 376 |
+
console.error('Failed to update status:', error);
|
| 377 |
+
}
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
/**
|
| 381 |
+
* 显示加载状态
|
| 382 |
+
*/
|
| 383 |
+
showLoading() {
|
| 384 |
+
const loading = document.getElementById('loading');
|
| 385 |
+
const grid = document.getElementById('currencyGrid');
|
| 386 |
+
const tips = document.getElementById('tips');
|
| 387 |
+
|
| 388 |
+
if (loading) loading.classList.remove('hidden');
|
| 389 |
+
if (loading) loading.style.display = 'flex';
|
| 390 |
+
if (grid) grid.style.display = 'none';
|
| 391 |
+
if (tips) tips.style.display = 'none';
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
/**
|
| 395 |
+
* 隐藏加载状态
|
| 396 |
+
*/
|
| 397 |
+
hideLoading() {
|
| 398 |
+
const loading = document.getElementById('loading');
|
| 399 |
+
const grid = document.getElementById('currencyGrid');
|
| 400 |
+
const tips = document.getElementById('tips');
|
| 401 |
+
|
| 402 |
+
if (loading) loading.classList.add('hidden');
|
| 403 |
+
if (loading) loading.style.display = 'none';
|
| 404 |
+
if (grid) grid.style.display = 'grid';
|
| 405 |
+
if (tips) tips.style.display = 'flex';
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
/**
|
| 409 |
+
* 显示错误信息
|
| 410 |
+
*/
|
| 411 |
+
showError(message) {
|
| 412 |
+
const errorDiv = document.getElementById('errorMessage');
|
| 413 |
+
const errorText = document.getElementById('errorText');
|
| 414 |
+
const loading = document.getElementById('loading');
|
| 415 |
+
const grid = document.getElementById('currencyGrid');
|
| 416 |
+
|
| 417 |
+
if (loading) loading.style.display = 'none';
|
| 418 |
+
if (grid) grid.style.display = 'none';
|
| 419 |
+
|
| 420 |
+
if (errorDiv) {
|
| 421 |
+
errorDiv.style.display = 'flex';
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
if (errorText) {
|
| 425 |
+
errorText.textContent = message;
|
| 426 |
+
}
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
/**
|
| 430 |
+
* 隐藏错误信息
|
| 431 |
+
*/
|
| 432 |
+
hideError() {
|
| 433 |
+
const errorDiv = document.getElementById('errorMessage');
|
| 434 |
+
if (errorDiv) {
|
| 435 |
+
errorDiv.style.display = 'none';
|
| 436 |
+
}
|
| 437 |
+
}
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
// ==================== 初始化应用 ====================
|
| 442 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 443 |
+
// 创建全局实例
|
| 444 |
+
window.currencyConverter = new CurrencyConverter();
|
| 445 |
+
});
|
templates/index.html
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>汇率换算器</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/css/style.css">
|
| 8 |
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>💱</text></svg>">
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<div class="container">
|
| 12 |
+
<!-- 头部 -->
|
| 13 |
+
<header>
|
| 14 |
+
<h1>💱 汇率换算器</h1>
|
| 15 |
+
<div class="header-info">
|
| 16 |
+
<p class="update-time">
|
| 17 |
+
<span class="label">数据更新时间:</span>
|
| 18 |
+
<span id="lastUpdate">加载中...</span>
|
| 19 |
+
</p>
|
| 20 |
+
<p class="currency-count">
|
| 21 |
+
<span class="label">支持货币:</span>
|
| 22 |
+
<span id="currencyCount">-</span> 种
|
| 23 |
+
</p>
|
| 24 |
+
</div>
|
| 25 |
+
</header>
|
| 26 |
+
|
| 27 |
+
<!-- 主体内容 -->
|
| 28 |
+
<main>
|
| 29 |
+
<!-- 搜索和筛选 -->
|
| 30 |
+
<div class="toolbar">
|
| 31 |
+
<div class="search-box">
|
| 32 |
+
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 33 |
+
<circle cx="11" cy="11" r="8"/>
|
| 34 |
+
<path d="M21 21l-4.35-4.35"/>
|
| 35 |
+
</svg>
|
| 36 |
+
<input type="text" id="searchInput" placeholder="搜索货币代码或名称...">
|
| 37 |
+
</div>
|
| 38 |
+
<div class="filter-buttons">
|
| 39 |
+
<button class="filter-btn active" data-filter="all">全部</button>
|
| 40 |
+
<button class="filter-btn" data-filter="priority">常用</button>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<!-- 提示信息 -->
|
| 45 |
+
<div class="tips" id="tips">
|
| 46 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 47 |
+
<circle cx="12" cy="12" r="10"/>
|
| 48 |
+
<path d="M12 16v-4M12 8h.01"/>
|
| 49 |
+
</svg>
|
| 50 |
+
<span>在任意货币输入框中输入金额,其他货币将自动换算</span>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<!-- 加载状态 -->
|
| 54 |
+
<div class="loading" id="loading">
|
| 55 |
+
<div class="spinner"></div>
|
| 56 |
+
<p>正在加载汇率数据...</p>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<!-- 错误提示 -->
|
| 60 |
+
<div class="error-message" id="errorMessage" style="display: none;">
|
| 61 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 62 |
+
<circle cx="12" cy="12" r="10"/>
|
| 63 |
+
<path d="M15 9l-6 6M9 9l6 6"/>
|
| 64 |
+
</svg>
|
| 65 |
+
<span id="errorText">加载失败</span>
|
| 66 |
+
<button id="retryBtn" class="retry-btn">重试</button>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
<!-- 货币列表 -->
|
| 70 |
+
<div class="currency-grid" id="currencyGrid">
|
| 71 |
+
<!-- 动态生成货币卡片 -->
|
| 72 |
+
</div>
|
| 73 |
+
</main>
|
| 74 |
+
|
| 75 |
+
<!-- 底部 -->
|
| 76 |
+
<footer>
|
| 77 |
+
<p>数据来源: <a href="https://www.exchangerate-api.com/" target="_blank">ExchangeRate-API</a></p>
|
| 78 |
+
<p class="footer-note">汇率每日更新 | 基准货币: <span id="baseCurrency">CNY</span></p>
|
| 79 |
+
</footer>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<script src="/static/js/app.js"></script>
|
| 83 |
+
</body>
|
| 84 |
+
</html>
|