diff --git a/backend/api/routes/info.py b/backend/api/routes/info.py index 15ddaa1e..63acaa85 100644 --- a/backend/api/routes/info.py +++ b/backend/api/routes/info.py @@ -1,6 +1,6 @@ from api.core.Gzip import register_gzip_request import json -from fastapi import APIRouter, Body, Request +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 @@ -13,6 +13,7 @@ from core.open.skillTree import get_skill_tree_info 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.openapi_data import openapi_locale_path +from api.utils.character_response import character_response_encoding, get_character_response_bytes router = APIRouter() @@ -39,14 +40,19 @@ 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): - character = createCharacter(state.alter, state.equVersion) - 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) - # info = character.getInfo() - return response(data=info) + def load_info(): + return get_redis_info(redis, f'dcalc:character:{state.alter}:{state.equVersion}', get_character) + + 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}') diff --git a/backend/api/utils/character_response.py b/backend/api/utils/character_response.py new file mode 100644 index 00000000..52de97ae --- /dev/null +++ b/backend/api/utils/character_response.py @@ -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 diff --git a/backend/tests/contract/test_calc_contract.py b/backend/tests/contract/test_calc_contract.py index 81cab551..290e4919 100644 --- a/backend/tests/contract/test_calc_contract.py +++ b/backend/tests/contract/test_calc_contract.py @@ -17,3 +17,30 @@ def test_calc_response_contract(client, alter_token, minimal_calc_config): assert isinstance(data['skills'], list) assert isinstance(data['info'], list) 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 diff --git a/backend/tests/unit/test_character_response.py b/backend/tests/unit/test_character_response.py new file mode 100644 index 00000000..cbbda41d --- /dev/null +++ b/backend/tests/unit/test_character_response.py @@ -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