dev
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
from api.core.Gzip import register_gzip_request
|
from api.core.Gzip import register_gzip_request
|
||||||
import json
|
import json
|
||||||
from fastapi import APIRouter, Body, Request
|
from fastapi import APIRouter, Body, Request, Response
|
||||||
from api.core.Auth import createToken
|
from api.core.Auth import createToken
|
||||||
from api.core.Response import response
|
from api.core.Response import response
|
||||||
from api.core.Redis import get_redis_info, get_redis_info_async
|
from api.core.Redis import get_redis_info, get_redis_info_async
|
||||||
@@ -13,6 +13,7 @@ from core.open.skillTree import get_skill_tree_info
|
|||||||
from api.core.exception import ResponseException
|
from api.core.exception import ResponseException
|
||||||
from api.utils.skill_detail import SKILL_DETAIL_NORMALIZE_VERSION, normalize_skill_detail, skill_detail_for_level
|
from api.utils.skill_detail import SKILL_DETAIL_NORMALIZE_VERSION, normalize_skill_detail, skill_detail_for_level
|
||||||
from api.utils.openapi_data import openapi_locale_path
|
from api.utils.openapi_data import openapi_locale_path
|
||||||
|
from api.utils.character_response import character_response_encoding, get_character_response_bytes
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -39,14 +40,19 @@ async def getToken(redis: RedisDep, alter: str, request: Request, version: str =
|
|||||||
|
|
||||||
@router.get('/character')
|
@router.get('/character')
|
||||||
async def get_character_info(request: Request, state: AltersDep, redis: RedisDep):
|
async def get_character_info(request: Request, state: AltersDep, redis: RedisDep):
|
||||||
character = createCharacter(state.alter, state.equVersion)
|
|
||||||
|
|
||||||
def get_character():
|
def get_character():
|
||||||
return character.getInfo()
|
return createCharacter(state.alter, state.equVersion).getInfo()
|
||||||
|
|
||||||
info = get_redis_info(redis, f'dcalc:character:{state.alter}:{state.equVersion}', get_character)
|
def load_info():
|
||||||
# info = character.getInfo()
|
return get_redis_info(redis, f'dcalc:character:{state.alter}:{state.equVersion}', get_character)
|
||||||
return response(data=info)
|
|
||||||
|
encoding = character_response_encoding(request.headers.get('accept-encoding'))
|
||||||
|
content = get_character_response_bytes(redis, state.alter, state.equVersion, encoding, load_info)
|
||||||
|
headers = {}
|
||||||
|
if encoding == 'gzip':
|
||||||
|
headers['Content-Encoding'] = 'gzip'
|
||||||
|
headers['Vary'] = 'Accept-Encoding'
|
||||||
|
return Response(content=content, media_type='application/json', headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@router.get('/info/oath/{suitId}/{skillId}')
|
@router.get('/info/oath/{suitId}/{skillId}')
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from collections import OrderedDict
|
||||||
|
from collections.abc import Callable
|
||||||
|
import gzip
|
||||||
|
import logging
|
||||||
|
from threading import RLock
|
||||||
|
|
||||||
|
from config.main import config
|
||||||
|
from api.core.Response import response
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('dcalc.character_response')
|
||||||
|
|
||||||
|
CHARACTER_RESPONSE_CACHE_VERSION = 'v1'
|
||||||
|
CHARACTER_RESPONSE_LOCAL_CACHE_MAX_BYTES = 16 * 1024 * 1024
|
||||||
|
|
||||||
|
CharacterInfoLoader = Callable[[], dict]
|
||||||
|
CharacterResponseCacheKey = tuple[str, str, str]
|
||||||
|
|
||||||
|
_local_cache: OrderedDict[CharacterResponseCacheKey, bytes] = OrderedDict()
|
||||||
|
_local_cache_size = 0
|
||||||
|
_local_cache_lock = RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def character_response_encoding(accept_encoding: str | None) -> str:
|
||||||
|
"""Match the application's GZipMiddleware negotiation behavior."""
|
||||||
|
return 'gzip' if 'gzip' in (accept_encoding or '').lower() else 'identity'
|
||||||
|
|
||||||
|
|
||||||
|
def character_response_cache_key(alter: str, equ_version: str, encoding: str) -> str:
|
||||||
|
return f'dcalc:character-response:{CHARACTER_RESPONSE_CACHE_VERSION}:{alter}:{equ_version}:{encoding}'
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_character_response(info: dict, encoding: str) -> bytes:
|
||||||
|
content = response(data=info).model_dump_json().encode('utf-8')
|
||||||
|
if encoding == 'gzip':
|
||||||
|
return gzip.compress(content, compresslevel=9, mtime=0)
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def clear_character_response_local_cache() -> None:
|
||||||
|
global _local_cache_size
|
||||||
|
with _local_cache_lock:
|
||||||
|
_local_cache.clear()
|
||||||
|
_local_cache_size = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _get_local_cache(key: CharacterResponseCacheKey) -> bytes | None:
|
||||||
|
with _local_cache_lock:
|
||||||
|
content = _local_cache.pop(key, None)
|
||||||
|
if content is not None:
|
||||||
|
_local_cache[key] = content
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def _set_local_cache(key: CharacterResponseCacheKey, content: bytes) -> None:
|
||||||
|
global _local_cache_size
|
||||||
|
if len(content) > CHARACTER_RESPONSE_LOCAL_CACHE_MAX_BYTES:
|
||||||
|
return
|
||||||
|
|
||||||
|
with _local_cache_lock:
|
||||||
|
previous = _local_cache.pop(key, None)
|
||||||
|
if previous is not None:
|
||||||
|
_local_cache_size -= len(previous)
|
||||||
|
_local_cache[key] = content
|
||||||
|
_local_cache_size += len(content)
|
||||||
|
|
||||||
|
while _local_cache_size > CHARACTER_RESPONSE_LOCAL_CACHE_MAX_BYTES:
|
||||||
|
_, evicted = _local_cache.popitem(last=False)
|
||||||
|
_local_cache_size -= len(evicted)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_redis_cache(redis, key: str) -> bytes | None:
|
||||||
|
if config.DEBUG_MODE or redis is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
content = redis.get(key)
|
||||||
|
return bytes(content) if content is not None else None
|
||||||
|
except Exception:
|
||||||
|
logger.warning('read character response cache failed key=%s', key, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _set_redis_cache(redis, key: str, content: bytes) -> None:
|
||||||
|
if config.DEBUG_MODE or redis is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
redis.set(key, content)
|
||||||
|
except Exception:
|
||||||
|
logger.warning('write character response cache failed key=%s', key, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_character_response_bytes(
|
||||||
|
redis,
|
||||||
|
alter: str,
|
||||||
|
equ_version: str,
|
||||||
|
encoding: str,
|
||||||
|
load_info: CharacterInfoLoader,
|
||||||
|
) -> bytes:
|
||||||
|
local_key = (alter, equ_version, encoding)
|
||||||
|
content = _get_local_cache(local_key)
|
||||||
|
if content is not None:
|
||||||
|
return content
|
||||||
|
|
||||||
|
redis_key = character_response_cache_key(alter, equ_version, encoding)
|
||||||
|
content = _get_redis_cache(redis, redis_key)
|
||||||
|
if content is None:
|
||||||
|
content = serialize_character_response(load_info(), encoding)
|
||||||
|
_set_redis_cache(redis, redis_key, content)
|
||||||
|
|
||||||
|
_set_local_cache(local_key, content)
|
||||||
|
return content
|
||||||
@@ -17,3 +17,30 @@ def test_calc_response_contract(client, alter_token, minimal_calc_config):
|
|||||||
assert isinstance(data['skills'], list)
|
assert isinstance(data['skills'], list)
|
||||||
assert isinstance(data['info'], list)
|
assert isinstance(data['info'], list)
|
||||||
assert isinstance(data['buffer'], bool)
|
assert isinstance(data['buffer'], bool)
|
||||||
|
|
||||||
|
|
||||||
|
def test_character_response_contract_with_gzip(client, alter_token):
|
||||||
|
response = client.get(
|
||||||
|
'/api/character',
|
||||||
|
headers={'alter-token': alter_token, 'accept-encoding': 'gzip'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers['content-encoding'] == 'gzip'
|
||||||
|
assert response.headers['vary'] == 'Accept-Encoding'
|
||||||
|
payload = response.json()
|
||||||
|
assert payload['code'] == 200
|
||||||
|
assert payload['message'] == 'Success'
|
||||||
|
assert set(['alter', 'skills', 'equips', 'suits']).issubset(payload['data'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_character_response_contract_without_gzip(client, alter_token):
|
||||||
|
response = client.get(
|
||||||
|
'/api/character',
|
||||||
|
headers={'alter-token': alter_token, 'accept-encoding': 'identity'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'content-encoding' not in response.headers
|
||||||
|
assert response.headers['vary'] == 'Accept-Encoding'
|
||||||
|
assert response.json()['code'] == 200
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
|
||||||
|
from api.utils.character_response import (
|
||||||
|
character_response_cache_key,
|
||||||
|
character_response_encoding,
|
||||||
|
clear_character_response_local_cache,
|
||||||
|
get_character_response_bytes,
|
||||||
|
)
|
||||||
|
from config.main import config
|
||||||
|
|
||||||
|
|
||||||
|
def setup_function():
|
||||||
|
clear_character_response_local_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_function():
|
||||||
|
clear_character_response_local_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def test_character_response_encoding_matches_gzip_middleware():
|
||||||
|
assert character_response_encoding('gzip, deflate, br') == 'gzip'
|
||||||
|
assert character_response_encoding('identity') == 'identity'
|
||||||
|
assert character_response_encoding(None) == 'identity'
|
||||||
|
|
||||||
|
|
||||||
|
def test_character_response_caches_final_gzip_bytes_locally():
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def load_info():
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return {'alter': 'weapon_master', 'skills': []}
|
||||||
|
|
||||||
|
first = get_character_response_bytes(None, 'GF.swordman_male.weapon_master', '0', 'gzip', load_info)
|
||||||
|
second = get_character_response_bytes(None, 'GF.swordman_male.weapon_master', '0', 'gzip', load_info)
|
||||||
|
|
||||||
|
assert first == second
|
||||||
|
assert calls == 1
|
||||||
|
assert json.loads(gzip.decompress(first)) == {
|
||||||
|
'code': 200,
|
||||||
|
'message': 'Success',
|
||||||
|
'data': {'alter': 'weapon_master', 'skills': []},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_character_response_caches_identity_and_gzip_separately():
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def load_info():
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return {'name': '极诣·剑魂'}
|
||||||
|
|
||||||
|
identity = get_character_response_bytes(None, 'GF.swordman_male.weapon_master', '0', 'identity', load_info)
|
||||||
|
compressed = get_character_response_bytes(None, 'GF.swordman_male.weapon_master', '0', 'gzip', load_info)
|
||||||
|
|
||||||
|
assert calls == 2
|
||||||
|
assert gzip.decompress(compressed) == identity
|
||||||
|
assert json.loads(identity)['data']['name'] == '极诣·剑魂'
|
||||||
|
|
||||||
|
|
||||||
|
def test_character_response_reuses_redis_final_bytes_after_local_cache_clear(monkeypatch):
|
||||||
|
class FakeRedis:
|
||||||
|
def __init__(self):
|
||||||
|
self.values = {}
|
||||||
|
|
||||||
|
def get(self, key):
|
||||||
|
return self.values.get(key)
|
||||||
|
|
||||||
|
def set(self, key, value):
|
||||||
|
self.values[key] = value
|
||||||
|
|
||||||
|
redis = FakeRedis()
|
||||||
|
monkeypatch.setattr(config, 'DEBUG_MODE', False)
|
||||||
|
first = get_character_response_bytes(
|
||||||
|
redis,
|
||||||
|
'GF.swordman_male.weapon_master',
|
||||||
|
'0',
|
||||||
|
'gzip',
|
||||||
|
lambda: {'alter': 'weapon_master'},
|
||||||
|
)
|
||||||
|
clear_character_response_local_cache()
|
||||||
|
|
||||||
|
def fail_to_load_info():
|
||||||
|
raise AssertionError('Redis cache hit must not rebuild character info')
|
||||||
|
|
||||||
|
second = get_character_response_bytes(
|
||||||
|
redis,
|
||||||
|
'GF.swordman_male.weapon_master',
|
||||||
|
'0',
|
||||||
|
'gzip',
|
||||||
|
fail_to_load_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
redis_key = character_response_cache_key('GF.swordman_male.weapon_master', '0', 'gzip')
|
||||||
|
assert redis.values[redis_key] == first
|
||||||
|
assert second == first
|
||||||
Reference in New Issue
Block a user