Hugo-Jiang commited on
Commit
954be92
·
1 Parent(s): b65cf93

Add application file

Browse files
.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
- title: ExchangeRates
3
- emoji: 🦀
4
- colorFrom: red
5
- colorTo: indigo
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>