This commit is contained in:
2026-08-18 16:13:07 +08:00
parent efcbc49096
commit 4de893ca94
12 changed files with 906 additions and 506 deletions
+49 -22
View File
@@ -1,18 +1,50 @@
import inspect
import json
import logging
from api.core.Gzip import gzip_zip, gzip_unzip
from config.main import config
logger = logging.getLogger('dcalc.redis_cache')
async def get_redis_info_async(redis, key, fun, expire = 0, without_gzip=False):
def _read_cached(redis, key, without_gzip):
content = redis.get(key)
if content is None:
return None
return json.loads(content if without_gzip else gzip_unzip(content))
def _write_cached(redis, key, info, expire, without_gzip):
content = json.dumps(info) if without_gzip else gzip_zip(json.dumps(info))
if expire > 0:
redis.set(key, content, ex=expire)
else:
redis.set(key, content)
async def _read_cached_async(redis, key, without_gzip):
content = await redis.get(key)
if content is None:
return None
return json.loads(content if without_gzip else gzip_unzip(content))
async def _write_cached_async(redis, key, info, expire, without_gzip):
content = json.dumps(info) if without_gzip else gzip_zip(json.dumps(info))
if expire > 0:
await redis.set(key, content, ex=expire)
else:
await redis.set(key, content)
async def get_redis_info_async(redis, key, fun, expire=0, without_gzip=False):
info = None
if not config.DEBUG_MODE and redis:
try:
if without_gzip:
info = json.loads(redis.get(key))
else:
info = json.loads(gzip_unzip(redis.get(key)))
info = await _read_cached_async(redis, key, without_gzip)
except Exception:
logger.warning('read Redis cache failed key=%s', key, exc_info=True)
info = None
if info is None:
if inspect.iscoroutinefunction(fun):
@@ -20,31 +52,26 @@ async def get_redis_info_async(redis, key, fun, expire = 0, without_gzip=False):
else:
info = fun()
if not config.DEBUG_MODE and redis and info is not None:
if without_gzip:
redis.set(key, json.dumps(info))
else:
redis.set(key, gzip_zip(json.dumps(info)))
if expire > 0:
redis.expire(key, expire)
try:
await _write_cached_async(redis, key, info, expire, without_gzip)
except Exception:
logger.warning('write Redis cache failed key=%s', key, exc_info=True)
return info
def get_redis_info(redis, key, fun, expire = 0, without_gzip=False):
def get_redis_info(redis, key, fun, expire=0, without_gzip=False):
info = None
if not config.DEBUG_MODE and redis:
try:
if without_gzip:
info = json.loads(redis.get(key))
else:
info = json.loads(gzip_unzip(redis.get(key)))
info = _read_cached(redis, key, without_gzip)
except Exception:
logger.warning('read Redis cache failed key=%s', key, exc_info=True)
info = None
if info is None:
info = fun()
if not config.DEBUG_MODE and redis and info is not None:
if without_gzip:
redis.set(key, json.dumps(info))
else:
redis.set(key, gzip_zip(json.dumps(info)))
if expire > 0:
redis.expire(key, expire)
try:
_write_cached(redis, key, info, expire, without_gzip)
except Exception:
logger.warning('write Redis cache failed key=%s', key, exc_info=True)
return info
+4 -3
View File
@@ -1,19 +1,20 @@
import time
from typing import Annotated
from redis import Redis
from sqlalchemy.orm import Session
from redis.asyncio import Redis as AsyncRedis
from fastapi import Depends, Header
from api.core.exception import ResponseException
from core.character.adventure import get_adv_info
from database.connect import get_db, get_redis
from database.connect import get_async_redis, get_redis
from api.core.Auth import AlterState
import base64
import json
from api.core.Redis import get_redis_info
SessionDep = Annotated[Session, Depends(get_db)]
RedisDep = Annotated[Redis, Depends(get_redis)]
AsyncRedisDep = Annotated[AsyncRedis, Depends(get_async_redis)]
def alterToken(redis: RedisDep, alter_token: str | None = Header(None)):
+32 -33
View File
@@ -1,4 +1,5 @@
import asyncio
import logging
from api.types.colg import HistoryGold, OfficialGold, RealTimeGold
from api.core.Gzip import register_gzip_request
@@ -6,7 +7,7 @@ import json
from fastapi import APIRouter, Query
from api.core.Response import response
from api.core.Redis import get_redis_info, get_redis_info_async
from api.dp import RedisDep
from api.dp import AsyncRedisDep, RedisDep
from typing import Annotated
import requests
from config.main import config
@@ -15,6 +16,7 @@ from api.core.Response import Return as Response
import aiohttp
router = APIRouter()
logger = logging.getLogger('dcalc.colg')
register_gzip_request(router)
@@ -39,6 +41,29 @@ payload_helper = {
'token': config.HELPER_TOKEN,
}
OFFICIAL_GOLD_URL = 'https://dzhu.qq.com/zangyi/activity/app'
OFFICIAL_GOLD_REGIONS = ('1', '2', '3A', '3B', '4', '5', '6', '7')
async def _fetch_official_gold() -> list[dict]:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
async def fetch_region(region: str) -> dict:
form = {**payload_helper, 'serverNo': region}
async with session.post(OFFICIAL_GOLD_URL, data=form) as resp:
resp.raise_for_status()
payload = await resp.json(content_type=None)
data = payload['data']
return {
'updataTime': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'region': f'跨{region}',
'goldDealRank': [int(item['price']) for item in data['godDealRank']],
'goldTopDealRank': [int(item['price']) for item in data['godTopDealRank']],
}
return await asyncio.gather(*(fetch_region(region) for region in OFFICIAL_GOLD_REGIONS))
def get_date_str(timestamp: int) -> str:
utc_time = datetime.fromtimestamp(timestamp, tz=timezone.utc)
@@ -47,7 +72,7 @@ def get_date_str(timestamp: int) -> str:
@router.get('/gold/realtime/', operation_id='realtimeGold', response_model=Response[list[RealTimeGold]])
async def get_real_time_gold(redis: RedisDep) -> Response[list[RealTimeGold]]:
def get_real_time_gold(redis: RedisDep) -> Response[list[RealTimeGold]]:
"""
返回所有跨区的实时金价信息
"""
@@ -74,7 +99,7 @@ async def get_real_time_gold(redis: RedisDep) -> Response[list[RealTimeGold]]:
@router.get('/gold/history/', operation_id='historyGold', response_model=Response[HistoryGold] | Response[None])
async def get_history_gold(region: Annotated[int, Query(..., description='跨区id')], timeRange: Annotated[int, Query(..., description='时间跨度天数')] = 7) -> Response[HistoryGold] | Response[None]:
def get_history_gold(region: Annotated[int, Query(..., description='跨区id')], timeRange: Annotated[int, Query(..., description='时间跨度天数')] = 7) -> Response[HistoryGold] | Response[None]:
"""
获取历史金价数据
@@ -146,7 +171,7 @@ async def get_history_gold(region: Annotated[int, Query(..., description='跨区
@router.get('/gold/official/', operation_id='officialGold', response_model=Response[list[OfficialGold]])
async def get_official_gold(redis: RedisDep) -> Response[list[OfficialGold]]:
async def get_official_gold(redis: AsyncRedisDep) -> Response[list[OfficialGold]]:
"""
获取DNF官方金币寄售实时数据
@@ -156,36 +181,10 @@ async def get_official_gold(redis: RedisDep) -> Response[list[OfficialGold]]:
- 该接口会返回所有跨区的官方金币寄售数据,包括最低成交价排名前5的价格和最高成交价排名前5的价格
- 数据来源于DNF官方提供的接口,可能会有一定的延迟和不稳定性
"""
official_url = 'https://dzhu.qq.com/zangyi/activity/app'
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
key = 'openapi:colg:real_time_gold_officical'
list = ['1', '2', '3A', '3B', '4', '5', '6', '7']
try:
async def get_data():
datas = []
async with aiohttp.ClientSession() as session:
tasks = []
for i in list:
payload_helper['serverNo'] = i
query = '&'.join(f'{k}={v}' for k, v in payload_helper.items())
tasks.append(session.post(official_url, data=query, headers=headers, timeout=10))
responses = await asyncio.gather(*tasks)
for index, resp in enumerate(responses):
res = json.loads(await resp.read())
datas.append(
{
'updataTime': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'region': f'跨{list[index]}',
'goldDealRank': [int(i.get('price')) for i in res['data']['godDealRank']],
'goldTopDealRank': [int(i.get('price')) for i in res['data']['godTopDealRank']],
}
)
return datas
except Exception as e:
print(e)
data = await get_redis_info_async(redis, key, _fetch_official_gold, 30 * 60)
except Exception:
logger.exception('fetch official gold failed')
return response(data=[])
# 设置半小时过期一次
data = await get_redis_info_async(redis, key, get_data, 30 * 60)
return response(data=data)
+10 -10
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Body, Request, Response
from api.core.Auth import createToken
from api.core.Response import response
from api.core.Redis import get_redis_info, get_redis_info_async
from api.dp import RedisDep, AltersDep
from api.dp import AsyncRedisDep, RedisDep, AltersDep
from core.basic.equipment import get_equipment
from core.character.adventure import get_adv_info, get_adv_list
from core.basic.character import createCharacter
@@ -21,7 +21,7 @@ register_gzip_request(router)
@router.get('/adventure')
async def get_adventure_info(request: Request, redis: RedisDep, name: str = None):
def get_adventure_info(request: Request, redis: RedisDep, name: str = None):
adventure_info = get_redis_info(redis, 'dcalc:adventure', get_adv_list)
if name:
total = [item for job in adventure_info for item in job.get('children', [])]
@@ -31,7 +31,7 @@ async def get_adventure_info(request: Request, redis: RedisDep, name: str = None
@router.get('/token/get/{alter}')
async def getToken(redis: RedisDep, alter: str, request: Request, version: str = None, equVersion: str = '0'):
def getToken(redis: RedisDep, alter: str, request: Request, version: str = None, equVersion: str = '0'):
if get_adv_info(alter) is None:
raise ResponseException('无效的职业')
token = createToken(alter, equVersion, redis)
@@ -39,7 +39,7 @@ async def getToken(redis: RedisDep, alter: str, request: Request, version: str =
@router.get('/character')
async def get_character_info(request: Request, state: AltersDep, redis: RedisDep):
def get_character_info(request: Request, state: AltersDep, redis: RedisDep):
def get_character():
return createCharacter(state.alter, state.equVersion).getInfo()
@@ -56,7 +56,7 @@ async def get_character_info(request: Request, state: AltersDep, redis: RedisDep
@router.get('/info/oath/{suitId}/{skillId}')
async def get_oath_info(state: AltersDep, suitId: str, skillId: str, rarity: str, lv: int, redis: RedisDep):
def get_oath_info(state: AltersDep, suitId: str, skillId: str, rarity: str, lv: int, redis: RedisDep):
def get_info():
skill = None
version = state.equVersion
@@ -73,7 +73,7 @@ async def get_oath_info(state: AltersDep, suitId: str, skillId: str, rarity: str
@router.get('/skill/{skillId}/{level}')
async def get_skill_info(state: AltersDep, skillId: str, level: int, redis: RedisDep):
def get_skill_info(state: AltersDep, skillId: str, level: int, redis: RedisDep):
"""
获取技能信息
"""
@@ -99,7 +99,7 @@ async def get_skill_info(state: AltersDep, skillId: str, level: int, redis: Redi
@router.get('/dnfhelper/{uid}/list')
async def get_dnfhelper_list(uid: str, redis: RedisDep):
async def get_dnfhelper_list(uid: str, redis: AsyncRedisDep):
cache_key = f'dnfhelper:{uid}'
async def get_list():
@@ -111,7 +111,7 @@ async def get_dnfhelper_list(uid: str, redis: RedisDep):
@router.get('/dnfhelper/{uid}/{id}/')
async def get_dnfhelper_detail(uid: str, id: str, state: AltersDep, redis: RedisDep):
async def get_dnfhelper_detail(uid: str, id: str, state: AltersDep, redis: AsyncRedisDep):
cache_key = f'dnfhelper:{uid}:{id}'
async def get_detail():
@@ -123,7 +123,7 @@ async def get_dnfhelper_detail(uid: str, id: str, state: AltersDep, redis: Redis
@router.get('/dnfhelper/{uid}/')
async def get_info_by_dnfhelper(uid: str, state: AltersDep, redis: RedisDep):
async def get_info_by_dnfhelper(uid: str, state: AltersDep):
try:
result = await get_user_info_by_id(uid, state)
return response(data=result)
@@ -134,7 +134,7 @@ async def get_info_by_dnfhelper(uid: str, state: AltersDep, redis: RedisDep):
@router.post('/skillTree/')
async def get_skill_by_code(state: AltersDep, code=Body(None)):
def get_skill_by_code(state: AltersDep, code=Body(None)):
try:
skills = get_skill_tree_info(code.get('code', ''))
if skills.get('job', {}).get('class', '') != state.alter.replace('_carry', ''):
+5 -5
View File
@@ -31,7 +31,7 @@ register_gzip_request(router)
@router.get('/skills/', operation_id='skillsList', response_model=SearchSkillResponse)
async def get_skills_list(
def get_skills_list(
redis: RedisDep,
skillName: Annotated[str | None, Query(description='技能名称,支持模糊查询')] = None,
) -> SearchSkillResponse:
@@ -62,7 +62,7 @@ async def get_skills_list(
@router.get('/jobInfo/', operation_id='jobsInfo', response_model=JobInfoResponse)
async def get_jobs_info(
def get_jobs_info(
redis: RedisDep,
jobName: Annotated[str, Query(..., description='职业名称,支持模糊查询')] = None,
) -> JobInfoResponse:
@@ -98,7 +98,7 @@ async def get_jobs_info(
@router.get('/{jobId}/{jobGrowId}/skills/', operation_id='skillsByJob', response_model=SkillsListResponse)
async def get_cn_skills_by_job(
def get_cn_skills_by_job(
redis: RedisDep,
jobId: Annotated[str, Path(..., description='职业')],
jobGrowId: Annotated[str, Path(..., description='转职')],
@@ -122,7 +122,7 @@ async def get_cn_skills_by_job(
@router.get('/{jobId}/{jobGrowId}/{skillId}/', operation_id='skillDetail', response_model=SkillDetailResponse)
async def get_cn_skill_info(
def get_cn_skill_info(
redis: RedisDep,
jobId: Annotated[str, Path(..., description='职业')],
jobGrowId: Annotated[str, Path(..., description='转职')],
@@ -164,7 +164,7 @@ async def get_cn_skill_info(
@router.get('/skillData/summary/', operation_id='skillDataSummary', response_model=SkillDataSummaryResponse)
async def get_skill_data_summary(
def get_skill_data_summary(
redis: RedisDep,
job: Annotated[str, Query(description='职业')],
skills: Annotated[
+1 -3
View File
@@ -1,3 +1 @@
import redis
pool = redis.ConnectionPool(host='localhost', port=6379, decode_responses=True)
"""Database connection helpers live in :mod:`database.connect`."""
+27 -11
View File
@@ -1,11 +1,17 @@
from functools import cache
import redis
import redis.asyncio as async_redis
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import NullPool
from sqlalchemy.engine import Engine
from config.main import BACKEND_DIR, config
RedisPool = redis.ConnectionPool.from_url(config.REDIS_URL)
# redis-py 8 defaults to RESP3. Keep the existing RESP2 wire contract so the
# client remains compatible with older Redis servers and proxies.
RedisPool = redis.ConnectionPool.from_url(config.REDIS_URL, protocol=2)
AsyncRedisPool = async_redis.ConnectionPool.from_url(config.REDIS_URL, protocol=2)
_db_engines: set[Engine] = set()
def get_redis_client():
@@ -17,16 +23,26 @@ def get_redis():
yield redis_conn
async def get_async_redis():
redis_conn = async_redis.Redis(connection_pool=AsyncRedisPool)
try:
yield redis_conn
finally:
await redis_conn.aclose()
@cache
def get_db_engine(version='0'):
db_path = BACKEND_DIR / 'dataFiles' / f'dcalc_{version}.db'
return create_engine(
f'sqlite:///{db_path}',
connect_args={'check_same_thread': False},
poolclass=NullPool,
)
engine = create_engine(f'sqlite:///{db_path}')
_db_engines.add(engine)
return engine
def get_db():
with Session(get_db_engine()) as session:
yield session
async def close_connection_pools() -> None:
RedisPool.close()
await AsyncRedisPool.aclose()
for engine in _db_engines:
engine.dispose()
_db_engines.clear()
get_db_engine.cache_clear()
+5 -1
View File
@@ -10,6 +10,7 @@ from api.core import Exceptions, Gzip, Middleware
from api.core.Logging import configure_logging
from api.main import api_router
from config.main import config
from database.connect import close_connection_pools
configure_logging()
@@ -19,7 +20,10 @@ async def lifespan(_: FastAPI):
print('dcalc is starting...')
print('Application starting...')
print('Application startup complete.')
yield
try:
yield
finally:
await close_connection_pools()
application = FastAPI(docs_url='/doc', redoc_url=None, lifespan=lifespan)
+9 -5
View File
@@ -5,16 +5,20 @@ description = "A backend project for dcalc, a calculator application."
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"aiohttp>=3.13.3",
"fastapi[all]>=0.136.3",
"aiohttp>=3.14.3",
"fastapi[all]>=0.141.1",
"fastmcp>=3.3.1",
"redis>=5.2.1",
"requests>=2.32.3",
"sqlalchemy>=2.0.38",
"pydantic>=2.13.4",
"pydantic-settings>=2.15.0",
"redis>=8.1.0",
"requests>=2.34.2",
"sqlalchemy>=2.0.52",
"uvicorn[standard]>=0.52.3",
]
[dependency-groups]
dev = [
"httpx2>=2.10.0",
"pytest>=8.4.0",
"ruff>=0.15.15",
]
+65
View File
@@ -0,0 +1,65 @@
import asyncio
from api.routes import colg
class FakeResponse:
def __init__(self, region):
self.region = region
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, traceback):
return False
def raise_for_status(self):
return None
async def json(self, content_type=None):
await asyncio.sleep(0)
return {
'data': {
'godDealRank': [{'price': '100'}, {'price': '101'}],
'godTopDealRank': [{'price': '200'}, {'price': '201'}],
}
}
class FakeSession:
def __init__(self, calls, **kwargs):
self.calls = calls
self.kwargs = kwargs
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, traceback):
return False
def post(self, url, data):
self.calls.append((url, data))
return FakeResponse(data['serverNo'])
def test_fetch_official_gold_uses_async_http_with_isolated_forms(monkeypatch):
calls = []
sessions = []
def create_session(**kwargs):
session = FakeSession(calls, **kwargs)
sessions.append(session)
return session
monkeypatch.setattr(colg.aiohttp, 'ClientSession', create_session)
result = asyncio.run(colg._fetch_official_gold())
assert [data['serverNo'] for _, data in calls] == list(colg.OFFICIAL_GOLD_REGIONS)
assert all(url == colg.OFFICIAL_GOLD_URL for url, _ in calls)
assert len({id(data) for _, data in calls}) == len(colg.OFFICIAL_GOLD_REGIONS)
assert [item['region'] for item in result] == [f'跨{region}' for region in colg.OFFICIAL_GOLD_REGIONS]
assert result[0]['goldDealRank'] == [100, 101]
assert result[0]['goldTopDealRank'] == [200, 201]
assert sessions[0].kwargs['timeout'].total == 10
assert 'serverNo' not in colg.payload_helper
+64
View File
@@ -0,0 +1,64 @@
import asyncio
import json
from api.core.Redis import get_redis_info, get_redis_info_async
from config.main import config
from database.connect import AsyncRedisPool, RedisPool
class FakeRedis:
def __init__(self, value=None, fail_write=False):
self.value = value
self.fail_write = fail_write
self.set_calls = []
def get(self, key):
return self.value
def set(self, key, value, **kwargs):
if self.fail_write:
raise RuntimeError('Redis unavailable')
self.set_calls.append((key, value, kwargs))
class FakeAsyncRedis(FakeRedis):
async def get(self, key):
return self.value
async def set(self, key, value, **kwargs):
super().set(key, value, **kwargs)
def test_shared_pool_keeps_resp2_compatibility():
assert RedisPool.connection_kwargs['protocol'] == 2
assert AsyncRedisPool.connection_kwargs['protocol'] == 2
def test_sync_cache_sets_value_and_ttl_in_one_command(monkeypatch):
monkeypatch.setattr(config, 'DEBUG_MODE', False)
redis = FakeRedis()
result = get_redis_info(redis, 'key', lambda: {'value': 1}, expire=60, without_gzip=True)
assert result == {'value': 1}
assert redis.set_calls == [('key', json.dumps(result), {'ex': 60})]
def test_sync_cache_write_failure_does_not_fail_request(monkeypatch):
monkeypatch.setattr(config, 'DEBUG_MODE', False)
redis = FakeRedis(fail_write=True)
assert get_redis_info(redis, 'key', lambda: {'value': 1}, without_gzip=True) == {'value': 1}
def test_async_cache_awaits_async_redis_io(monkeypatch):
monkeypatch.setattr(config, 'DEBUG_MODE', False)
redis = FakeAsyncRedis()
async def load():
return {'value': 1}
result = asyncio.run(get_redis_info_async(redis, 'key', load, expire=60, without_gzip=True))
assert result == {'value': 1}
assert redis.set_calls == [('key', json.dumps(result), {'ex': 60})]
+635 -413
View File
File diff suppressed because it is too large Load Diff