359 lines
14 KiB
Python
359 lines
14 KiB
Python
import asyncio
|
||
import json
|
||
import hashlib
|
||
from datetime import datetime
|
||
|
||
import aiohttp
|
||
from asgiref.sync import sync_to_async
|
||
from rest_framework.permissions import AllowAny
|
||
from adrf.views import APIView
|
||
from rest_framework.response import Response
|
||
from rest_framework import status
|
||
from drf_yasg.utils import swagger_auto_schema
|
||
from drf_yasg import openapi
|
||
from chunyu_project.common_schemas import success_response, error_response
|
||
|
||
from django.core.cache import caches
|
||
|
||
_cache = caches['default']
|
||
|
||
CACHE_TIMEOUT = 900
|
||
|
||
LATEST_RATES_URL = "https://open.er-api.com/v6/latest/{base}"
|
||
|
||
|
||
def _make_cache_key(prefix, *args, **kwargs):
|
||
raw = json.dumps({'args': args, 'kwargs': kwargs}, sort_keys=True, default=str)
|
||
suffix = hashlib.md5(raw.encode('utf-8')).hexdigest()
|
||
return f'currency_{prefix}_{suffix}'
|
||
|
||
|
||
async def get_cached_data(prefix, *args, **kwargs):
|
||
cache_key = _make_cache_key(prefix, *args, **kwargs)
|
||
# django-redis 为同步客户端,sync_to_async 兜底
|
||
return await sync_to_async(_cache.get)(cache_key)
|
||
|
||
|
||
async def set_cached_data(prefix, data, *args, timeout=CACHE_TIMEOUT, **kwargs):
|
||
cache_key = _make_cache_key(prefix, *args, **kwargs)
|
||
await sync_to_async(_cache.set)(cache_key, data, timeout=timeout)
|
||
|
||
|
||
class CurrencyRatesView(APIView):
|
||
"""
|
||
汇率查询视图 - 获取指定基准货币对所有支持货币的汇率
|
||
"""
|
||
permission_classes = [AllowAny]
|
||
|
||
@swagger_auto_schema(
|
||
tags=['Currency'],
|
||
operation_summary='获取最新汇率(GET方式)',
|
||
operation_description='基于 open.er-api.com 获取指定基准货币的最新汇率列表',
|
||
manual_parameters=[
|
||
openapi.Parameter(
|
||
'base',
|
||
openapi.IN_QUERY,
|
||
description='基准货币代码(如 USD, EUR, CNY),默认 USD',
|
||
type=openapi.TYPE_STRING,
|
||
required=False,
|
||
),
|
||
],
|
||
responses={200: success_response, 400: error_response, 502: error_response}
|
||
)
|
||
async def get(self, request):
|
||
base = request.GET.get('base', 'USD').strip().upper()
|
||
if not base:
|
||
base = 'USD'
|
||
return await self._fetch_rates(base)
|
||
|
||
@swagger_auto_schema(
|
||
tags=['Currency'],
|
||
operation_summary='获取最新汇率(POST方式)',
|
||
operation_description='基于 open.er-api.com 获取指定基准货币的最新汇率列表',
|
||
request_body=openapi.Schema(
|
||
type=openapi.TYPE_OBJECT,
|
||
properties={
|
||
'base': openapi.Schema(type=openapi.TYPE_STRING, description='基准货币代码(如 USD, EUR, CNY),默认 USD'),
|
||
},
|
||
),
|
||
responses={200: success_response, 400: error_response, 502: error_response}
|
||
)
|
||
async def post(self, request):
|
||
base = request.data.get('base', 'USD').strip().upper() if isinstance(request.data, dict) else 'USD'
|
||
if not base:
|
||
base = 'USD'
|
||
return await self._fetch_rates(base)
|
||
|
||
async def _fetch_rates(self, base):
|
||
base = base.upper()
|
||
|
||
cached = await get_cached_data('rates', base)
|
||
if cached is not None:
|
||
return Response(
|
||
{"code": 200, "message": "success (cached)", "data": cached},
|
||
status=status.HTTP_200_OK,
|
||
)
|
||
|
||
try:
|
||
url = LATEST_RATES_URL.format(base=base)
|
||
timeout = aiohttp.ClientTimeout(total=10)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
async with session.get(url) as response:
|
||
if response.status != 200:
|
||
return Response(
|
||
{"code": 502, "message": "汇率服务异常,请稍后重试", "data": None},
|
||
status=status.HTTP_502_BAD_GATEWAY,
|
||
)
|
||
data = await response.json()
|
||
|
||
if data.get('result') != 'success':
|
||
return Response(
|
||
{"code": 502, "message": f"汇率服务返回错误:{data.get('error-type', '未知错误')}", "data": None},
|
||
status=status.HTTP_502_BAD_GATEWAY,
|
||
)
|
||
|
||
rates_data = {
|
||
'base': data.get('base_code', base),
|
||
'rates': data.get('rates', {}),
|
||
'last_updated': data.get('time_last_update_utc', datetime.now().strftime('%Y-%m-%d %H:%M:%S')),
|
||
'next_update': data.get('time_next_update_utc', ''),
|
||
}
|
||
|
||
await set_cached_data('rates', rates_data, base)
|
||
|
||
return Response(
|
||
{"code": 200, "message": "success", "data": rates_data},
|
||
status=status.HTTP_200_OK,
|
||
)
|
||
|
||
except (aiohttp.ClientError, asyncio.TimeoutError):
|
||
return Response(
|
||
{"code": 502, "message": "请求超时,请稍后重试", "data": None},
|
||
status=status.HTTP_502_BAD_GATEWAY,
|
||
)
|
||
except Exception as e:
|
||
return Response(
|
||
{"code": 500, "message": f"服务器内部错误:{str(e)}", "data": None},
|
||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
)
|
||
|
||
|
||
class CurrenciesListView(APIView):
|
||
"""
|
||
支持的货币列表视图 - 返回所有可用的货币代码
|
||
"""
|
||
permission_classes = [AllowAny]
|
||
|
||
COMMON_CURRENCIES = {
|
||
'USD': '美元 - United States Dollar',
|
||
'EUR': '欧元 - Euro',
|
||
'GBP': '英镑 - British Pound Sterling',
|
||
'JPY': '日元 - Japanese Yen',
|
||
'CNY': '人民币 - Chinese Yuan',
|
||
'AUD': '澳元 - Australian Dollar',
|
||
'CAD': '加元 - Canadian Dollar',
|
||
'CHF': '瑞士法郎 - Swiss Franc',
|
||
'HKD': '港币 - Hong Kong Dollar',
|
||
'SGD': '新加坡元 - Singapore Dollar',
|
||
'KRW': '韩元 - South Korean Won',
|
||
'RUB': '俄罗斯卢布 - Russian Ruble',
|
||
'INR': '印度卢比 - Indian Rupee',
|
||
'THB': '泰铢 - Thai Baht',
|
||
'VND': '越南盾 - Vietnamese Dong',
|
||
}
|
||
|
||
@swagger_auto_schema(
|
||
tags=['Currency'],
|
||
operation_summary='获取支持的货币代码列表',
|
||
operation_description='返回系统常用的货币代码及名称(带缓存)',
|
||
responses={200: success_response, 500: error_response}
|
||
)
|
||
async def get(self, request):
|
||
cached = await get_cached_data('currencies')
|
||
if cached is not None:
|
||
return Response(
|
||
{"code": 200, "message": "success (cached)", "data": cached},
|
||
status=status.HTTP_200_OK,
|
||
)
|
||
|
||
try:
|
||
data = {
|
||
'currencies': self.COMMON_CURRENCIES,
|
||
'total': len(self.COMMON_CURRENCIES),
|
||
'updated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||
}
|
||
|
||
await set_cached_data('currencies', data)
|
||
|
||
return Response(
|
||
{"code": 200, "message": "success", "data": data},
|
||
status=status.HTTP_200_OK,
|
||
)
|
||
except Exception as e:
|
||
return Response(
|
||
{"code": 500, "message": f"服务器内部错误:{str(e)}", "data": None},
|
||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
)
|
||
|
||
|
||
class CurrencyConvertView(APIView):
|
||
"""
|
||
货币兑换视图 - 将一种货币的金额转换为另一种货币
|
||
"""
|
||
permission_classes = [AllowAny]
|
||
|
||
@swagger_auto_schema(
|
||
tags=['Currency'],
|
||
operation_summary='货币兑换计算(GET方式)',
|
||
operation_description='将源货币金额按最新汇率转换为目标货币金额',
|
||
manual_parameters=[
|
||
openapi.Parameter(
|
||
'from',
|
||
openapi.IN_QUERY,
|
||
description='源货币代码(如 USD)',
|
||
type=openapi.TYPE_STRING,
|
||
required=True,
|
||
),
|
||
openapi.Parameter(
|
||
'to',
|
||
openapi.IN_QUERY,
|
||
description='目标货币代码(如 CNY)',
|
||
type=openapi.TYPE_STRING,
|
||
required=True,
|
||
),
|
||
openapi.Parameter(
|
||
'amount',
|
||
openapi.IN_QUERY,
|
||
description='源货币金额(正数)',
|
||
type=openapi.TYPE_NUMBER,
|
||
required=True,
|
||
),
|
||
],
|
||
responses={200: success_response, 400: error_response, 502: error_response}
|
||
)
|
||
async def get(self, request):
|
||
from_code = request.GET.get('from', '').strip().upper()
|
||
to_code = request.GET.get('to', '').strip().upper()
|
||
amount = request.GET.get('amount', '').strip()
|
||
return await self._convert(from_code, to_code, amount)
|
||
|
||
@swagger_auto_schema(
|
||
tags=['Currency'],
|
||
operation_summary='货币兑换计算(POST方式)',
|
||
operation_description='将源货币金额按最新汇率转换为目标货币金额',
|
||
request_body=openapi.Schema(
|
||
type=openapi.TYPE_OBJECT,
|
||
required=['from', 'to', 'amount'],
|
||
properties={
|
||
'from': openapi.Schema(type=openapi.TYPE_STRING, description='源货币代码(如 USD)'),
|
||
'to': openapi.Schema(type=openapi.TYPE_STRING, description='目标货币代码(如 CNY)'),
|
||
'amount': openapi.Schema(type=openapi.TYPE_NUMBER, description='源货币金额(正数)'),
|
||
},
|
||
),
|
||
responses={200: success_response, 400: error_response, 502: error_response}
|
||
)
|
||
async def post(self, request):
|
||
from_code = request.data.get('from', '').strip().upper() if isinstance(request.data, dict) else ''
|
||
to_code = request.data.get('to', '').strip().upper() if isinstance(request.data, dict) else ''
|
||
amount = request.data.get('amount', '') if isinstance(request.data, dict) else ''
|
||
amount = str(amount).strip() if amount else ''
|
||
return await self._convert(from_code, to_code, amount)
|
||
|
||
async def _convert(self, from_code, to_code, amount):
|
||
if not from_code:
|
||
return Response(
|
||
{"code": 400, "message": "参数错误:from 不能为空", "data": None},
|
||
status=status.HTTP_400_BAD_REQUEST,
|
||
)
|
||
if not to_code:
|
||
return Response(
|
||
{"code": 400, "message": "参数错误:to 不能为空", "data": None},
|
||
status=status.HTTP_400_BAD_REQUEST,
|
||
)
|
||
if not amount:
|
||
return Response(
|
||
{"code": 400, "message": "参数错误:amount 不能为空", "data": None},
|
||
status=status.HTTP_400_BAD_REQUEST,
|
||
)
|
||
|
||
try:
|
||
amount_value = float(amount)
|
||
if amount_value < 0:
|
||
return Response(
|
||
{"code": 400, "message": "参数错误:amount 不能为负数", "data": None},
|
||
status=status.HTTP_400_BAD_REQUEST,
|
||
)
|
||
except (ValueError, TypeError):
|
||
return Response(
|
||
{"code": 400, "message": "参数错误:amount 必须是有效的数字", "data": None},
|
||
status=status.HTTP_400_BAD_REQUEST,
|
||
)
|
||
|
||
cached_rates = await get_cached_data('rates', from_code)
|
||
rates = None
|
||
|
||
if cached_rates is not None:
|
||
rates = cached_rates.get('rates')
|
||
|
||
if rates is None:
|
||
try:
|
||
url = LATEST_RATES_URL.format(base=from_code)
|
||
timeout = aiohttp.ClientTimeout(total=10)
|
||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||
async with session.get(url) as response:
|
||
if response.status != 200:
|
||
return Response(
|
||
{"code": 502, "message": "汇率服务异常,请稍后重试", "data": None},
|
||
status=status.HTTP_502_BAD_GATEWAY,
|
||
)
|
||
data = await response.json()
|
||
|
||
if data.get('result') != 'success':
|
||
return Response(
|
||
{"code": 502, "message": f"汇率服务返回错误:{data.get('error-type', '未知错误')}", "data": None},
|
||
status=status.HTTP_502_BAD_GATEWAY,
|
||
)
|
||
|
||
rates_data = {
|
||
'base': data.get('base_code', from_code),
|
||
'rates': data.get('rates', {}),
|
||
'last_updated': data.get('time_last_update_utc', datetime.now().strftime('%Y-%m-%d %H:%M:%S')),
|
||
'next_update': data.get('time_next_update_utc', ''),
|
||
}
|
||
await set_cached_data('rates', rates_data, from_code)
|
||
rates = rates_data['rates']
|
||
|
||
except (aiohttp.ClientError, asyncio.TimeoutError):
|
||
return Response(
|
||
{"code": 502, "message": "请求超时,请稍后重试", "data": None},
|
||
status=status.HTTP_502_BAD_GATEWAY,
|
||
)
|
||
except Exception as e:
|
||
return Response(
|
||
{"code": 500, "message": f"服务器内部错误:{str(e)}", "data": None},
|
||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
)
|
||
|
||
if to_code not in rates:
|
||
return Response(
|
||
{"code": 400, "message": f"不支持的目标货币代码:{to_code}", "data": None},
|
||
status=status.HTTP_400_BAD_REQUEST,
|
||
)
|
||
|
||
rate = float(rates[to_code])
|
||
converted_amount = round(amount_value * rate, 4)
|
||
|
||
result = {
|
||
'from': from_code,
|
||
'to': to_code,
|
||
'amount': amount_value,
|
||
'rate': rate,
|
||
'converted_amount': converted_amount,
|
||
'updated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||
}
|
||
|
||
return Response(
|
||
{"code": 200, "message": "success", "data": result},
|
||
status=status.HTTP_200_OK,
|
||
)
|