This commit is contained in:
2026-08-18 15:21:53 +08:00
parent 5ccf31aa20
commit efcbc49096
4 changed files with 249 additions and 7 deletions
+13 -7
View File
@@ -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}')
+111
View File
@@ -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