diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..ab2996a2 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,36 @@ +# dcalc backend + +## Run + +```bash +uv sync +python main.py +``` + +## Test + +```bash +uv run pytest -v +``` + +## Quality + +```bash +uv run ruff check . +``` + +## Runtime Entry + +- `main.py`: FastAPI application and uvicorn entry. +- `api/routes/calc.py`: `/api/calc` HTTP route. +- `services/calc_service.py`: calculation orchestration. +- `core/basic/character/`: character calculation implementation. +- `core/basic/equipment.py`: equipment data models and compatibility access. +- `core/basic/equipment_registry.py`: equipment version registry and cache. +- `database/connect.py`: SQLite and Redis connections. + +## Compatibility Rules + +- Do not change `/calc` request and response contract without frontend migration approval. +- Do not change frontendNew result data shape during backend refactors. +- Keep Skill and Forge business formulas behavior-compatible unless a dedicated formula task says otherwise. diff --git a/backend/api/routes/calc.py b/backend/api/routes/calc.py index edf6b435..cbbaf704 100644 --- a/backend/api/routes/calc.py +++ b/backend/api/routes/calc.py @@ -2,7 +2,7 @@ from api.core.Gzip import register_gzip_request from fastapi import APIRouter, Body from api.core.Response import response from api.dp import AltersDep -from core.basic.character import createCharacter +from services.calc_service import calculate router = APIRouter() @@ -11,5 +11,4 @@ register_gzip_request(router) @router.post('/calc') async def calc(state: AltersDep, config=Body(None)): - character = createCharacter(state.alter, state.equVersion) - return response(data=character.calc(config)) + return response(data=calculate(state, config)) diff --git a/backend/config/main.py b/backend/config/main.py index 695d1637..c735c7b8 100644 --- a/backend/config/main.py +++ b/backend/config/main.py @@ -19,7 +19,8 @@ class Config(BaseSettings): HELPER_ID: str = '' model_config = SettingsConfigDict( - env_file=('config/.env', 'config/.env.dev', 'config/.env.prod') + env_file=('config/.env', 'config/.env.dev', 'config/.env.prod'), + extra='ignore', ) diff --git a/backend/core/basic/character/calc_equip.py b/backend/core/basic/character/calc_equip.py index 850277d8..98b2a38c 100644 --- a/backend/core/basic/character/calc_equip.py +++ b/backend/core/basic/character/calc_equip.py @@ -6,6 +6,8 @@ from core.basic.equipment import OathSuit, Suit, get_equipment from ..formula import 增幅计算, 强化技攻, 武器强化计算, 精通计算, 耳环计算, 左右计算, 锻造计算 from ..roleinfo import CharacterEmblemInfo, CharacterEquipInfo, CharacterOathInfo, get_key_by_value from .base import CharacterBase +from .config_view import CalcConfigView +from .effect_apply import apply_uppercase_attrs @cache @@ -162,8 +164,7 @@ class CalcEquipMixin(CharacterBase): # 如果存在誓约和套装是同一个套装 # 则直接不执行原套装效果,执行誓约套装增强的效果 oathEffect = oath_suit_by_suit_id.get(suit.suitId, None) - filtered_dict = {k: v for k, v in suit.__dict__.items() if k[0].isupper()} - self.SetStatus(**filtered_dict) + apply_uppercase_attrs(self, suit) if func is not None and oathEffect is None: # print(f'套装 {suit.name} 生效') func(self) @@ -184,13 +185,11 @@ class CalcEquipMixin(CharacterBase): skills = [x for x in oathSuit.skills if x.suitId == oathSuit.suitId] skill = next((x for x in oathSuit.skills if str(x.skillId) == str(self.charOathSkillId)), None) # 誓约套装效果生效 - filtered_dict = {k: v for k, v in oathSuit.__dict__.items() if k[0].isupper()} - self.SetStatus(**filtered_dict) + apply_uppercase_attrs(self, oathSuit) if skill is not None: oathSkillEffect.append(skill) # 誓约技能效果生效 - filtered_dict = {k: v for k, v in skill.__dict__.items() if k[0].isupper()} - self.SetStatus(**filtered_dict) + apply_uppercase_attrs(self, skill) self.oathSkillInfo.append(skill) self.oathSuitInfo.append(oathSuit) if oathSuit.suitId == max_point_oath_suit: @@ -235,8 +234,7 @@ class CalcEquipMixin(CharacterBase): fun = effects.execture(f'equ_{equ.id}') if fun is None: continue - filtered_dict = {k: v for k, v in equ.__dict__.items() if k[0].isupper()} - self.SetStatus(**filtered_dict) + apply_uppercase_attrs(self, equ) fun(self) fun = effects.execture(f'enchant_{enchat}') if fun is not None: @@ -286,8 +284,7 @@ class CalcEquipMixin(CharacterBase): fun = effects.execture(f'oath_{oath.id}') if fun is None: continue - filtered_dict = {k: v for k, v in oath.__dict__.items() if k[0].isupper()} - self.SetStatus(**filtered_dict) + apply_uppercase_attrs(self, oath) fun(self) pass @@ -403,12 +400,13 @@ class CalcEquipMixin(CharacterBase): # region 主计算入口 def calc(self, setInfo: dict[str, dict]): - self.calc_init(setInfo) + config = CalcConfigView(setInfo).as_legacy_dict() + self.calc_init(config) self.SetBaseStatus() - self.calc_jade(setInfo.get('jades', [])) - self.calc_avatar(setInfo.get('avatar', {})) + self.calc_jade(config.get('jades', [])) + self.calc_avatar(config.get('avatar', {})) self.calc_basic() - self.calc_sundry(setInfo.get('sundry', {})) + self.calc_sundry(config.get('sundry', {})) self.calc_emblems() suit = self.calc_suits() self.calc_equs() @@ -430,7 +428,7 @@ class CalcEquipMixin(CharacterBase): self.输出类型 = '魔法百分比' else: self.输出类型 = '物理百分比' - skillInfos = self.calc_carry_skills(setInfo.get('DSB', False), setInfo.get('BUFF', False)) + skillInfos = self.calc_carry_skills(config.get('DSB', False), config.get('BUFF', False)) info = self.get_carry_info() return {'uuid': uuid1().hex, 'skills': skillInfos, 'info': info, 'suits': suit, 'buffer': self.buffer} diff --git a/backend/core/basic/character/config_view.py b/backend/core/basic/character/config_view.py new file mode 100644 index 00000000..91aa3a7c --- /dev/null +++ b/backend/core/basic/character/config_view.py @@ -0,0 +1,61 @@ +from collections.abc import Mapping +from typing import Any + + +class CalcConfigView: + def __init__(self, raw: Mapping[str, Any] | None): + self.raw = dict(raw or {}) + + @property + def equips(self) -> dict[str, dict[str, Any]]: + return dict(self.raw.get('equips') or {}) + + @property + def oaths(self) -> dict[str, dict[str, Any]]: + return dict(self.raw.get('oaths') or {}) + + @property + def emblems(self) -> dict[str, dict[str, Any]]: + return dict(self.raw.get('emblems') or {}) + + @property + def skills(self) -> dict[str, dict[str, Any]]: + return dict(self.raw.get('skills') or {}) + + @property + def avatar(self) -> dict[str, Any]: + return dict(self.raw.get('avatar') or {}) + + @property + def sundry(self) -> dict[str, Any]: + return dict(self.raw.get('sundry') or {}) + + @property + def jades(self) -> dict[str, Any]: + return dict(self.raw.get('jades') or {}) + + @property + def options(self) -> dict[str, Any]: + return dict(self.raw.get('options') or {}) + + @property + def dsb(self) -> bool: + return bool(self.raw.get('DSB', False)) + + @property + def buff(self) -> bool: + return bool(self.raw.get('BUFF', False)) + + def as_legacy_dict(self) -> dict[str, Any]: + data = dict(self.raw) + data.setdefault('equips', {}) + data.setdefault('oaths', {}) + data.setdefault('emblems', {}) + data.setdefault('skills', {}) + data.setdefault('avatar', {}) + data.setdefault('sundry', {}) + data.setdefault('jades', {}) + data.setdefault('options', {}) + data.setdefault('DSB', False) + data.setdefault('BUFF', False) + return data diff --git a/backend/core/basic/character/effect_apply.py b/backend/core/basic/character/effect_apply.py new file mode 100644 index 00000000..5eabdbbd --- /dev/null +++ b/backend/core/basic/character/effect_apply.py @@ -0,0 +1,17 @@ +from typing import Any + + +def uppercase_attrs(source: Any) -> dict[str, Any]: + return {key: value for key, value in source.__dict__.items() if key and key[0].isupper()} + + +def apply_uppercase_attrs(character: Any, source: Any) -> None: + character.SetStatus(**uppercase_attrs(source)) + + +def execute_effect(effects: Any, key: str, *args: Any) -> bool: + fun = effects.execture(key) + if fun is None: + return False + fun(*args) + return True diff --git a/backend/core/basic/equipment.py b/backend/core/basic/equipment.py index 4143b3e5..8549fb2f 100644 --- a/backend/core/basic/equipment.py +++ b/backend/core/basic/equipment.py @@ -4,6 +4,7 @@ from functools import cache from sqlalchemy.orm import Session from database.models import EmblemData, OathData, OathSuitData, OathSuitSkillData, PrimerData, EquData, SuitData, EnchantData, JadeData from database.connect import get_db_engine as get_engine +from core.basic.equipment_registry import EquipmentRegistry def parse_to_number_list(info: str, default: Sequence[float] = (0,)) -> list[float]: @@ -422,14 +423,8 @@ class OathSuit: self.__dict__.update(kwargs) -global equ0 -equ0 = Equipments('0') +_equipment_registry = EquipmentRegistry(Equipments) def get_equipment(version: str = '0') -> Equipments: - # print('加载装备信息') - try: - temp = eval(f'equ{version}'.format(version)) - except Exception: - temp = equ0 - return temp + return _equipment_registry.get(version) diff --git a/backend/core/basic/equipment_registry.py b/backend/core/basic/equipment_registry.py new file mode 100644 index 00000000..3ad157ca --- /dev/null +++ b/backend/core/basic/equipment_registry.py @@ -0,0 +1,22 @@ +from functools import cache +from pathlib import Path + +from database.connect import BACKEND_DIR + + +class EquipmentRegistry: + def __init__(self, equipment_cls): + self.equipment_cls = equipment_cls + + @cache + def get(self, version: str = '0'): + normalized = version or '0' + if not self._version_exists(normalized): + if normalized == '0': + return self.equipment_cls(normalized) + return self.get('0') + return self.equipment_cls(normalized) + + def _version_exists(self, version: str) -> bool: + db_path = Path(BACKEND_DIR) / 'dataFiles' / f'dcalc_{version}.db' + return db_path.exists() diff --git a/backend/core/character/GF/imperialknight/infighter.py b/backend/core/character/GF/imperialknight/breaker.py similarity index 97% rename from backend/core/character/GF/imperialknight/infighter.py rename to backend/core/character/GF/imperialknight/breaker.py index 5916b7da..44d8f367 100644 --- a/backend/core/character/GF/imperialknight/infighter.py +++ b/backend/core/character/GF/imperialknight/breaker.py @@ -1,10 +1,10 @@ #8d4d2001cdb357e41633c234eb7501b5 from core.basic.skill import PassiveSkill, ActiveSkill, get_data from core.basic.character import Character -prefix = "imperialknight/infighter/cn/skillDetail" +prefix = "imperialknight/breaker/cn/skillDetail" # 레이즈 어퍼컷 -# imperialknight/infighter/3c5604bdbb0240b8f130f59ab40509c3 +# imperialknight/breaker/3c5604bdbb0240b8f130f59ab40509c3 # 8d4d2001cdb357e41633c234eb7501b5/3c5604bdbb0240b8f130f59ab40509c3 class Skill0(ActiveSkill): """ @@ -29,7 +29,7 @@ class Skill0(ActiveSkill): data0 = get_data(f'{prefix}/{uuid}', 0) # 덱 브레이커 -# imperialknight/infighter/717f1e2104fe4b796f800352fa143ecc +# imperialknight/breaker/717f1e2104fe4b796f800352fa143ecc # 8d4d2001cdb357e41633c234eb7501b5/717f1e2104fe4b796f800352fa143ecc class Skill1(PassiveSkill): """ @@ -56,7 +56,7 @@ class Skill1(PassiveSkill): data1 = get_data(f'{prefix}/{uuid}', 1) # 聚焦射击 -# imperialknight/infighter/eb71e1d82d92c7e1d40500a0dcd77aa6 +# imperialknight/breaker/eb71e1d82d92c7e1d40500a0dcd77aa6 # 8d4d2001cdb357e41633c234eb7501b5/eb71e1d82d92c7e1d40500a0dcd77aa6 class Skill2(ActiveSkill): """ @@ -80,7 +80,7 @@ class Skill2(ActiveSkill): data0 = get_data(f'{prefix}/{uuid}', 0) # 后跳 -# imperialknight/infighter/7822d6d52e10964a6755f142c666b494 +# imperialknight/breaker/7822d6d52e10964a6755f142c666b494 # 8d4d2001cdb357e41633c234eb7501b5/7822d6d52e10964a6755f142c666b494 class Skill3(ActiveSkill): """ @@ -101,7 +101,7 @@ class Skill3(ActiveSkill): # 基础精通 -# imperialknight/infighter/5a56514f35cf0270ae8d6c65f8fefd78 +# imperialknight/breaker/5a56514f35cf0270ae8d6c65f8fefd78 # 8d4d2001cdb357e41633c234eb7501b5/5a56514f35cf0270ae8d6c65f8fefd78 class Skill4(PassiveSkill): """ @@ -132,7 +132,7 @@ class Skill4(PassiveSkill): data2 = get_data(f'{prefix}/{uuid}', 2) # 受身蹲伏 -# imperialknight/infighter/ce26c6b69d02a440a81b552bec94f03b +# imperialknight/breaker/ce26c6b69d02a440a81b552bec94f03b # 8d4d2001cdb357e41633c234eb7501b5/ce26c6b69d02a440a81b552bec94f03b class Skill5(ActiveSkill): """ @@ -163,7 +163,7 @@ class Skill5(ActiveSkill): data2 = get_data(f'{prefix}/{uuid}', 2) # 技能连招 -# imperialknight/infighter/10b31c7e8cd2d60b9bcf3b1d3ada4b0a +# imperialknight/breaker/10b31c7e8cd2d60b9bcf3b1d3ada4b0a # 8d4d2001cdb357e41633c234eb7501b5/10b31c7e8cd2d60b9bcf3b1d3ada4b0a class Skill6(ActiveSkill): """ @@ -206,7 +206,7 @@ class Skill6(ActiveSkill): # 防具精通 -# imperialknight/infighter/892ef624d8bf3d7fc045f84825fd6104 +# imperialknight/breaker/892ef624d8bf3d7fc045f84825fd6104 # 8d4d2001cdb357e41633c234eb7501b5/892ef624d8bf3d7fc045f84825fd6104 class Skill7(PassiveSkill): """ @@ -228,7 +228,7 @@ class Skill7(PassiveSkill): # 近敌灭杀 -# imperialknight/infighter/9dda3f4a849dba1a288dd65e116860f2 +# imperialknight/breaker/9dda3f4a849dba1a288dd65e116860f2 # 8d4d2001cdb357e41633c234eb7501b5/9dda3f4a849dba1a288dd65e116860f2 class Skill8(ActiveSkill): """ @@ -257,7 +257,7 @@ class Skill8(ActiveSkill): data1 = get_data(f'{prefix}/{uuid}', 1) # 加农炮 -# imperialknight/infighter/4655101518604f874721b3cc249aae10 +# imperialknight/breaker/4655101518604f874721b3cc249aae10 # 8d4d2001cdb357e41633c234eb7501b5/4655101518604f874721b3cc249aae10 class Skill9(ActiveSkill): """ @@ -286,7 +286,7 @@ class Skill9(ActiveSkill): # 施放技能过程中所受伤害 -90% # 퀵 바이섹션 -# imperialknight/infighter/0969cd4054d93da07708108c0cc1c4b5 +# imperialknight/breaker/0969cd4054d93da07708108c0cc1c4b5 # 8d4d2001cdb357e41633c234eb7501b5/0969cd4054d93da07708108c0cc1c4b5 class Skill10(ActiveSkill): """ @@ -310,7 +310,7 @@ class Skill10(ActiveSkill): data0 = get_data(f'{prefix}/{uuid}', 0) # 러프 보딩 -# imperialknight/infighter/c9664191611af31142e052dfaef84530 +# imperialknight/breaker/c9664191611af31142e052dfaef84530 # 8d4d2001cdb357e41633c234eb7501b5/c9664191611af31142e052dfaef84530 class Skill11(ActiveSkill): """ @@ -346,7 +346,7 @@ class Skill11(ActiveSkill): data2 = get_data(f'{prefix}/{uuid}', 2) # 强化 - 后跳 -# imperialknight/infighter/2b340542e776818b78f3212af184bd6b +# imperialknight/breaker/2b340542e776818b78f3212af184bd6b # 8d4d2001cdb357e41633c234eb7501b5/2b340542e776818b78f3212af184bd6b class Skill12(PassiveSkill): """ @@ -379,7 +379,7 @@ class Skill12(PassiveSkill): data1 = get_data(f'{prefix}/{uuid}', 1) # 跃翔 -# imperialknight/infighter/1fea5a626f15230237946a11a9d11582 +# imperialknight/breaker/1fea5a626f15230237946a11a9d11582 # 8d4d2001cdb357e41633c234eb7501b5/1fea5a626f15230237946a11a9d11582 class Skill13(ActiveSkill): """ @@ -406,7 +406,7 @@ class Skill13(ActiveSkill): data0 = get_data(f'{prefix}/{uuid}', 0) # 노 쿼터 -# imperialknight/infighter/f2fb27162beb0b87a7cb9af7900e95f2 +# imperialknight/breaker/f2fb27162beb0b87a7cb9af7900e95f2 # 8d4d2001cdb357e41633c234eb7501b5/f2fb27162beb0b87a7cb9af7900e95f2 class Skill14(PassiveSkill): """ @@ -431,7 +431,7 @@ class Skill14(PassiveSkill): data1 = get_data(f'{prefix}/{uuid}', 1) # 将飞锚附加到震动箭上, 驱动迷雾能量发射。 -# imperialknight/infighter/cfacda0647b9a0f595df2c2aad30c18d +# imperialknight/breaker/cfacda0647b9a0f595df2c2aad30c18d # 8d4d2001cdb357e41633c234eb7501b5/cfacda0647b9a0f595df2c2aad30c18d class Skill15(ActiveSkill): """ @@ -460,7 +460,7 @@ class Skill15(ActiveSkill): # 施放技能过程中所受伤害 -90% # 훅 샷 -# imperialknight/infighter/2f5d03c7848effbc0a23f4df45d9ca46 +# imperialknight/breaker/2f5d03c7848effbc0a23f4df45d9ca46 # 8d4d2001cdb357e41633c234eb7501b5/2f5d03c7848effbc0a23f4df45d9ca46 class Skill16(ActiveSkill): """ @@ -497,7 +497,7 @@ class Skill16(ActiveSkill): data2 = get_data(f'{prefix}/{uuid}', 2) # 어베스트 -# imperialknight/infighter/45442bbbe33540b4deeec29437dae70c +# imperialknight/breaker/45442bbbe33540b4deeec29437dae70c # 8d4d2001cdb357e41633c234eb7501b5/45442bbbe33540b4deeec29437dae70c class Skill17(ActiveSkill): """ @@ -525,7 +525,7 @@ class Skill17(ActiveSkill): data1 = get_data(f'{prefix}/{uuid}', 1) # 선체 파괴 -# imperialknight/infighter/01c3a2fb793d293a25ed8dc7a0d70c1a +# imperialknight/breaker/01c3a2fb793d293a25ed8dc7a0d70c1a # 8d4d2001cdb357e41633c234eb7501b5/01c3a2fb793d293a25ed8dc7a0d70c1a class Skill18(ActiveSkill): """ @@ -555,7 +555,7 @@ class Skill18(ActiveSkill): data1 = get_data(f'{prefix}/{uuid}', 1) # 暴击 -# imperialknight/infighter/fc1262c19f3d0477ee8eda47b8db8696 +# imperialknight/breaker/fc1262c19f3d0477ee8eda47b8db8696 # 8d4d2001cdb357e41633c234eb7501b5/fc1262c19f3d0477ee8eda47b8db8696 class Skill19(PassiveSkill): """ @@ -577,7 +577,7 @@ class Skill19(PassiveSkill): data0 = get_data(f'{prefix}/{uuid}', 0) # 스위핑 스트라이크 -# imperialknight/infighter/8f73f243041c2d27739fe7696f02bf9b +# imperialknight/breaker/8f73f243041c2d27739fe7696f02bf9b # 8d4d2001cdb357e41633c234eb7501b5/8f73f243041c2d27739fe7696f02bf9b class Skill20(ActiveSkill): """ @@ -612,7 +612,7 @@ class Skill20(ActiveSkill): data3 = get_data(f'{prefix}/{uuid}', 3) # 백병전 전문가 -# imperialknight/infighter/8c2379737c5acc935c1731f67f607655 +# imperialknight/breaker/8c2379737c5acc935c1731f67f607655 # 8d4d2001cdb357e41633c234eb7501b5/8c2379737c5acc935c1731f67f607655 class Skill21(PassiveSkill): """ @@ -639,7 +639,7 @@ class Skill21(PassiveSkill): data1 = get_data(f'{prefix}/{uuid}', 1) # 타이드 피어서 -# imperialknight/infighter/9cb6f9ed646fa87f9b7680a42ce83d1a +# imperialknight/breaker/9cb6f9ed646fa87f9b7680a42ce83d1a # 8d4d2001cdb357e41633c234eb7501b5/9cb6f9ed646fa87f9b7680a42ce83d1a class Skill22(ActiveSkill): """ @@ -670,7 +670,7 @@ class Skill22(ActiveSkill): data1 = get_data(f'{prefix}/{uuid}', 1) # 딥 워터 콜 -# imperialknight/infighter/3829c15bf5f520c13998a3479ba0ce7b +# imperialknight/breaker/3829c15bf5f520c13998a3479ba0ce7b # 8d4d2001cdb357e41633c234eb7501b5/3829c15bf5f520c13998a3479ba0ce7b class Skill23(ActiveSkill): """ @@ -717,7 +717,7 @@ class Skill23(ActiveSkill): data6 = get_data(f'{prefix}/{uuid}', 6) # 狂风可以在空中使用机枪和导弹攻击敌人。 持续时间结束、 召唤出新的狂风机器人, 或机械师死亡后, 狂风会爆炸并给予周围敌人一定伤害。 -# imperialknight/infighter/4224f9b0b8c7c903e9a1e0f9d9f6d04d +# imperialknight/breaker/4224f9b0b8c7c903e9a1e0f9d9f6d04d # 8d4d2001cdb357e41633c234eb7501b5/4224f9b0b8c7c903e9a1e0f9d9f6d04d class Skill24(ActiveSkill): """ @@ -771,7 +771,7 @@ class Skill24(ActiveSkill): ... # 타이드 바운드 -# imperialknight/infighter/78bd107acd474518b606be1e4fd38239 +# imperialknight/breaker/78bd107acd474518b606be1e4fd38239 # 8d4d2001cdb357e41633c234eb7501b5/78bd107acd474518b606be1e4fd38239 class Skill25(ActiveSkill): """ @@ -850,7 +850,7 @@ class Skill25(ActiveSkill): ... # 역조연격 -# imperialknight/infighter/d085127b0edd719782bd618d5688f4a1 +# imperialknight/breaker/d085127b0edd719782bd618d5688f4a1 # 8d4d2001cdb357e41633c234eb7501b5/d085127b0edd719782bd618d5688f4a1 class Skill26(ActiveSkill): """ @@ -906,7 +906,7 @@ class Skill26(ActiveSkill): ... # 黑暗明王阵 -# imperialknight/infighter/42c82812f86ff6704ae9952a2e6093a4 +# imperialknight/breaker/42c82812f86ff6704ae9952a2e6093a4 # 8d4d2001cdb357e41633c234eb7501b5/42c82812f86ff6704ae9952a2e6093a4 class Skill27(ActiveSkill): """ @@ -968,7 +968,7 @@ class Skill27(ActiveSkill): ... # 골칫거리 -# imperialknight/infighter/3d8f3d438405d79f8d3ed68072674d1e +# imperialknight/breaker/3d8f3d438405d79f8d3ed68072674d1e # 8d4d2001cdb357e41633c234eb7501b5/3d8f3d438405d79f8d3ed68072674d1e class Skill28(PassiveSkill): """ @@ -999,7 +999,7 @@ class Skill28(PassiveSkill): data1 = get_data(f'{prefix}/{uuid}', 1) # 개전의 팡파르 -# imperialknight/infighter/ade01c1d6afc8a05055225045e89fe49 +# imperialknight/breaker/ade01c1d6afc8a05055225045e89fe49 # 8d4d2001cdb357e41633c234eb7501b5/ade01c1d6afc8a05055225045e89fe49 class Skill29(ActiveSkill): """ @@ -1036,7 +1036,7 @@ class Skill29(ActiveSkill): data4 = get_data(f'{prefix}/{uuid}', 4) # 破冰飞刃 -# imperialknight/infighter/ff171dc487807bb9aa28900ca9a46b41 +# imperialknight/breaker/ff171dc487807bb9aa28900ca9a46b41 # 8d4d2001cdb357e41633c234eb7501b5/ff171dc487807bb9aa28900ca9a46b41 class Skill30(ActiveSkill): """ @@ -1093,7 +1093,7 @@ class Skill30(ActiveSkill): ... # 마엘스트롬 -# imperialknight/infighter/547ab2b2bd860d3e37355a9cfbc1077c +# imperialknight/breaker/547ab2b2bd860d3e37355a9cfbc1077c # 8d4d2001cdb357e41633c234eb7501b5/547ab2b2bd860d3e37355a9cfbc1077c class Skill31(ActiveSkill): """ @@ -1156,7 +1156,7 @@ class Skill31(ActiveSkill): ... # 철의 노래 -# imperialknight/infighter/506e7ed77d517419a6e1c437a2cedb17 +# imperialknight/breaker/506e7ed77d517419a6e1c437a2cedb17 # 8d4d2001cdb357e41633c234eb7501b5/506e7ed77d517419a6e1c437a2cedb17 class Skill32(PassiveSkill): """ @@ -1180,7 +1180,7 @@ class Skill32(PassiveSkill): data0 = get_data(f'{prefix}/{uuid}', 0) # 冷血突刺 -# imperialknight/infighter/a5fa08f5d509e6ff2ebc68856a470b5a +# imperialknight/breaker/a5fa08f5d509e6ff2ebc68856a470b5a # 8d4d2001cdb357e41633c234eb7501b5/a5fa08f5d509e6ff2ebc68856a470b5a class Skill33(ActiveSkill): """ @@ -1246,7 +1246,7 @@ class Skill33(ActiveSkill): ... # 교룡아 -# imperialknight/infighter/5dc7008b12a459325b548b0715c6b73c +# imperialknight/breaker/5dc7008b12a459325b548b0715c6b73c # 8d4d2001cdb357e41633c234eb7501b5/5dc7008b12a459325b548b0715c6b73c class Skill34(ActiveSkill): """ @@ -1315,7 +1315,7 @@ class Skill34(ActiveSkill): ... # 씨 베인 -# imperialknight/infighter/8ee0099656df08a0b39225f8a21d514b +# imperialknight/breaker/8ee0099656df08a0b39225f8a21d514b # 8d4d2001cdb357e41633c234eb7501b5/8ee0099656df08a0b39225f8a21d514b class Skill35(ActiveSkill): """ @@ -1356,7 +1356,7 @@ class Skill35(ActiveSkill): data4 = get_data(f'{prefix}/{uuid}', 4) # 역조 -# imperialknight/infighter/9bff7f2559e003766fee2853dca00631 +# imperialknight/breaker/9bff7f2559e003766fee2853dca00631 # 8d4d2001cdb357e41633c234eb7501b5/9bff7f2559e003766fee2853dca00631 class Skill36(PassiveSkill): """ @@ -1389,7 +1389,7 @@ class Skill36(PassiveSkill): # 타이드 피어서 받는 데미지 감소율 : 30% # 언바운드 템페스트 -# imperialknight/infighter/da6e37c1e3f0e8867f70007d89c239ff +# imperialknight/breaker/da6e37c1e3f0e8867f70007d89c239ff # 8d4d2001cdb357e41633c234eb7501b5/da6e37c1e3f0e8867f70007d89c239ff class Skill37(ActiveSkill): """ @@ -1427,7 +1427,7 @@ class Skill37(ActiveSkill): data3 = get_data(f'{prefix}/{uuid}', 3) # 해신낙연 -# imperialknight/infighter/a6c8f69107f8c4f5d1a0c7a57d000290 +# imperialknight/breaker/a6c8f69107f8c4f5d1a0c7a57d000290 # 8d4d2001cdb357e41633c234eb7501b5/a6c8f69107f8c4f5d1a0c7a57d000290 class Skill38(ActiveSkill): """ @@ -1466,7 +1466,7 @@ class Skill38(ActiveSkill): class classChange(Character): def __init__(self, equVersion): - self.name = 'infighter' + self.name = 'breaker' self.nameCN = '鸣锋·破浪者' self.role = 'imperialknight' self.角色 = '帝国骑士' diff --git a/backend/openapi b/backend/openapi index abf3eca4..9d741b23 160000 --- a/backend/openapi +++ b/backend/openapi @@ -1 +1 @@ -Subproject commit abf3eca40cc1ec3807c80fef81f889329e16f921 +Subproject commit 9d741b23d2ffedb133a6b3944f7289d0d58f3818 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5cc61cec..cdc2e615 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,5 +15,6 @@ dependencies = [ [dependency-groups] dev = [ + "pytest>=8.4.0", "ruff>=0.15.15", ] diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 00000000..d022bef7 --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1 @@ +"""Application services for backend API orchestration.""" diff --git a/backend/services/calc_service.py b/backend/services/calc_service.py new file mode 100644 index 00000000..d4c1cb3a --- /dev/null +++ b/backend/services/calc_service.py @@ -0,0 +1,27 @@ +import logging +import time +from typing import Any + +from api.core.Auth import AlterState +from core.basic.character import createCharacter + + +logger = logging.getLogger('dcalc.calc') + + +def calculate(state: AlterState, config: dict[str, Any] | None) -> dict[str, Any]: + started_at = time.perf_counter() + safe_config = config or {} + character = createCharacter(state.alter, state.equVersion) + created_at = time.perf_counter() + result = character.calc(safe_config) + finished_at = time.perf_counter() + logger.info( + 'calc completed alter=%s equVersion=%s create_ms=%.2f calc_ms=%.2f total_ms=%.2f', + state.alter, + state.equVersion, + (created_at - started_at) * 1000, + (finished_at - created_at) * 1000, + (finished_at - started_at) * 1000, + ) + return result diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..b97dffa9 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,69 @@ +import base64 +import json +import sys +import time +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from main import application + + +@pytest.fixture +def client(): + return TestClient(application) + + +@pytest.fixture +def alter_token(): + payload = { + 'alter': 'MDAwMDAw', + 'equVersion': '0', + 'time': int(time.time() * 1000), + } + return base64.b64encode(json.dumps(payload).encode('utf-8')).decode('utf-8') + + +@pytest.fixture +def minimal_calc_config(): + parts = [ + '武器', + '副武器', + '称号', + '上衣', + '头肩', + '下装', + '腰带', + '鞋', + '手镯', + '项链', + '戒指', + '辅助装备', + '魔法石', + '耳环', + ] + return { + 'equips': { + part: { + 'id': '', + 'reinforce': 0, + 'reinforceType': 0, + 'enchant': '0', + 'refine': 0, + 'adaptation': 0, + } + for part in parts + }, + 'oaths': {}, + 'emblems': {}, + 'skills': {}, + 'avatar': {}, + 'sundry': {}, + 'jades': {}, + 'options': {}, + 'DSB': False, + 'BUFF': False, + } diff --git a/backend/tests/contract/test_calc_contract.py b/backend/tests/contract/test_calc_contract.py new file mode 100644 index 00000000..81cab551 --- /dev/null +++ b/backend/tests/contract/test_calc_contract.py @@ -0,0 +1,19 @@ +def test_calc_response_contract(client, alter_token, minimal_calc_config): + response = client.post( + '/api/calc', + headers={'alter-token': alter_token}, + json=minimal_calc_config, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['code'] == 200 + assert payload['message'] == 'Success' + assert isinstance(payload['data'], dict) + + data = payload['data'] + assert set(['uuid', 'skills', 'info', 'suits', 'buffer']).issubset(data) + assert isinstance(data['uuid'], str) + assert isinstance(data['skills'], list) + assert isinstance(data['info'], list) + assert isinstance(data['buffer'], bool) diff --git a/backend/tests/golden/test_calc_golden.py b/backend/tests/golden/test_calc_golden.py new file mode 100644 index 00000000..bf5ca177 --- /dev/null +++ b/backend/tests/golden/test_calc_golden.py @@ -0,0 +1,13 @@ +from core.basic.character import createCharacter + + +def test_character_calc_returns_result_shape(minimal_calc_config): + character = createCharacter('GF.swordman_male.weapon_master', '0') + + result = character.calc(minimal_calc_config) + + assert isinstance(result, dict) + assert set(['uuid', 'skills', 'info', 'suits', 'buffer']).issubset(result) + assert isinstance(result['skills'], list) + assert isinstance(result['info'], list) + assert result['buffer'] is False diff --git a/backend/tests/unit/test_calc_config_view.py b/backend/tests/unit/test_calc_config_view.py new file mode 100644 index 00000000..200ee1ed --- /dev/null +++ b/backend/tests/unit/test_calc_config_view.py @@ -0,0 +1,27 @@ +from core.basic.character.config_view import CalcConfigView + + +def test_config_view_provides_defaults(): + view = CalcConfigView(None) + + assert view.equips == {} + assert view.oaths == {} + assert view.emblems == {} + assert view.skills == {} + assert view.avatar == {} + assert view.sundry == {} + assert view.jades == {} + assert view.options == {} + assert view.dsb is False + assert view.buff is False + + +def test_config_view_preserves_legacy_keys(): + view = CalcConfigView({'skills': {'100': {'lv': 1}}, 'BUFF': True}) + + data = view.as_legacy_dict() + + assert data['skills'] == {'100': {'lv': 1}} + assert data['BUFF'] is True + assert data['DSB'] is False + assert data['equips'] == {} diff --git a/backend/tests/unit/test_calc_service.py b/backend/tests/unit/test_calc_service.py new file mode 100644 index 00000000..d96a1430 --- /dev/null +++ b/backend/tests/unit/test_calc_service.py @@ -0,0 +1,22 @@ +import logging + +from api.core.Auth import AlterState +from services.calc_service import calculate + + +def test_calculate_returns_character_calc_result(minimal_calc_config): + state = AlterState('GF.swordman_male.weapon_master', 'token', {}, '0', 0) + + result = calculate(state, minimal_calc_config) + + assert isinstance(result, dict) + assert set(['uuid', 'skills', 'info', 'suits', 'buffer']).issubset(result) + + +def test_calculate_logs_timing(caplog, minimal_calc_config): + state = AlterState('GF.swordman_male.weapon_master', 'token', {}, '0', 0) + + with caplog.at_level(logging.INFO, logger='dcalc.calc'): + calculate(state, minimal_calc_config) + + assert any('calc completed alter=GF.swordman_male.weapon_master' in record.message for record in caplog.records) diff --git a/backend/tests/unit/test_config.py b/backend/tests/unit/test_config.py new file mode 100644 index 00000000..b7a771e3 --- /dev/null +++ b/backend/tests/unit/test_config.py @@ -0,0 +1,5 @@ +from config.main import Config + + +def test_config_ignores_extra_environment_fields(): + assert Config.model_config.get('extra') == 'ignore' diff --git a/backend/tests/unit/test_effect_apply.py b/backend/tests/unit/test_effect_apply.py new file mode 100644 index 00000000..bb100113 --- /dev/null +++ b/backend/tests/unit/test_effect_apply.py @@ -0,0 +1,35 @@ +from core.basic.character.effect_apply import execute_effect, uppercase_attrs + + +class Source: + STR = 10 + + def __init__(self): + self.INT = 20 + self.name = 'ignored' + + +class Effects: + def execture(self, key): + if key != 'known': + return None + + def effect(target): + target.append(key) + + return effect + + +def test_uppercase_attrs_returns_only_uppercase_instance_keys(): + assert uppercase_attrs(Source()) == {'INT': 20} + + +def test_execute_effect_returns_false_for_missing_effect(): + assert execute_effect(Effects(), 'missing', []) is False + + +def test_execute_effect_calls_existing_effect(): + target = [] + + assert execute_effect(Effects(), 'known', target) is True + assert target == ['known'] diff --git a/backend/tests/unit/test_equipment_registry.py b/backend/tests/unit/test_equipment_registry.py new file mode 100644 index 00000000..d91473ca --- /dev/null +++ b/backend/tests/unit/test_equipment_registry.py @@ -0,0 +1,30 @@ +from core.basic.equipment import get_equipment +from core.basic.equipment_registry import EquipmentRegistry + + +def test_get_equipment_returns_cached_default_version(): + first = get_equipment('0') + second = get_equipment('0') + + assert first is second + assert first.version == '0' + + +def test_get_equipment_falls_back_to_default_version(): + equipment = get_equipment('missing-version') + + assert equipment.version == '0' + + +def test_equipment_registry_caches_by_version(): + calls = [] + + class Equipment: + def __init__(self, version): + calls.append(version) + self.version = version + + registry = EquipmentRegistry(Equipment) + + assert registry.get('0') is registry.get('0') + assert calls == ['0'] diff --git a/backend/uv.lock b/backend/uv.lock index 16b48ad8..3dbdc9bb 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -546,6 +546,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pytest" }, { name = "ruff" }, ] @@ -560,7 +561,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0.15.15" }] +dev = [ + { name = "pytest", specifier = ">=8.4.0" }, + { name = "ruff", specifier = ">=0.15.15" }, +] [[package]] name = "detect-installer" @@ -1151,6 +1155,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -1600,6 +1613,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -1944,6 +1966,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, ] +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.0" diff --git a/docs/backend/2026-06-04-backend-optimization-design.md b/docs/backend/2026-06-04-backend-optimization-design.md new file mode 100644 index 00000000..486d5478 --- /dev/null +++ b/docs/backend/2026-06-04-backend-optimization-design.md @@ -0,0 +1,237 @@ +# Backend 渐进式优化设计 + +> 本文面向 dcalc backend 的渐进式架构优化。目标是在不改变 `/calc` API 契约、不破坏 frontendNew 计算链路的前提下,逐步提升后端可维护性、扩展性、计算效率与开发规范。 + +## 背景 + +当前仓库前端迁移主线是 `frontend -> frontendNew`,backend 必须优先服务 Dcalc Character 模块的稳定计算链路:equipment、skill、forge、result。backend 现有结构已经完成过一次 `core/basic/character_old.py` 到 `core/basic/character/` 多文件 mixin 的拆分,说明当前更适合继续做小步收口,而不是推倒重写。 + +关键入口: + +- `/calc` 路由:`backend/api/routes/calc.py` +- 应用入口:`backend/main.py` +- 角色计算核心:`backend/core/basic/character/` +- 装备数据装载:`backend/core/basic/equipment.py` +- 数据库连接:`backend/database/connect.py` +- Redis 预热:`backend/api/core/RedisPreheat.py` + +## 非目标 + +- 不改 `/calc` 的请求语义、响应包装、`data` 主体字段结构。 +- 不改 frontendNew 依赖的 IResult 数据消费方式。 +- 不重写 Skill、Forge、装备效果、职业技能的核心业务逻辑。 +- 不在 backend 优化中引入新的 UI、前端状态管理或前端接口契约调整。 +- 不一次性格式化全仓 Python 文件,避免制造大规模无关 diff。 + +## 现状问题 + +### API 与计算耦合 + +`backend/api/routes/calc.py` 直接 `createCharacter(...).calc(config)`。这让鉴权、请求适配、计算编排、错误处理和未来耗时统计都挤在 API 调用路径上,不利于扩展。 + +### config 读取分散 + +计算过程中大量直接访问 `setInfo.get(...)`、`info['equips']`、`info['oaths']`。默认值、兼容逻辑和输入容错散落在 mixin 中,后续 Skill/Forge 配置演进时容易漏字段。 + +### 装备数据全局缓存边界不清 + +`backend/core/basic/equipment.py` 启动时构造 `equ0 = Equipments('0')`,`get_equipment()` 通过动态变量和 `eval` 获取版本。该方式对当前性能有帮助,但多版本扩展、测试隔离、热更新和错误定位较弱。 + +### 计算对象状态可变 + +`Character` 实例在 `calc_init -> calc_* -> calc_*_skills` 过程中持续变异属性、技能等级、装备效果和输出类型。该模式符合当前业务写法,但缺少明确的一次性计算上下文,测试和复用成本偏高。 + +### 性能缺少量化 + +已有内存化装备数据、技能类缓存、Redis 预热等优化点,但 `/calc` 的耗时瓶颈没有分段统计。后续不应凭感觉优化。 + +### 测试安全网不足 + +当前仅看到 `backend/test.py`,没有正式 `tests/` 体系。后续重构计算链路时,缺少 golden case 来确认字段与数值没有回退。 + +## 优化原则 + +1. 接口冻结:先用测试固定 `/calc` 外部行为,再改内部结构。 +2. 小步可回滚:每个阶段能单独合并、单独验证。 +3. 先边界后算法:优先整理 service、config view、repository/registry,再考虑公式与缓存。 +4. 只搬运不改义:拆文件、封 helper 时不改变公式含义。 +5. 先量化后性能:先加耗时指标,再按热点优化。 + +## 目标架构 + +```text +backend/ + api/ + routes/ + calc.py # 只负责 HTTP 层:依赖注入、Body、response + services/ + calc_service.py # /calc 应用服务:创建角色、执行计算、计时、错误上下文 + schemas/ + calc.py # 宽松 config schema 与响应类型别名 + response.py # 通用响应类型,保持旧格式兼容 + core/ + basic/ + character/ + config_view.py # CalcConfigView:统一默认值、兼容旧 config + effect_apply.py # 统一属性提取与 effect 执行 helper + equipment.py # 保留现有模型,逐步委托给 registry + equipment_registry.py # EquipmentRegistry:装备版本懒加载与缓存 + tests/ + contract/ + test_calc_contract.py + golden/ + test_calc_golden.py + unit/ + test_equipment_registry.py + test_calc_config_view.py +``` + +## 数据流 + +```text +HTTP POST /api/calc + -> AltersDep 解析 alter/equVersion + -> calc_service.calculate(state, raw_config) + -> CalcConfigView.from_raw(raw_config) + -> createCharacter(state.alter, state.equVersion) + -> character.calc(config_view 或兼容 dict) + -> calc_service 返回原始计算 data + -> response(data=data) +``` + +短期内 `Character.calc()` 仍接收原 `dict`,`CalcConfigView` 先作为内部读取辅助逐步接入。等测试稳定后,再考虑让 `Character.calc()` 接收 `CalcConfigView`。 + +## 分阶段设计 + +### Phase 0:测试安全网 + +建立 pytest 测试结构,固定 `/calc` 契约和典型职业结果结构。 + +重点: + +- 增加 `pytest` 开发依赖。 +- 增加可复用的最小 config fixture。 +- 增加 `/api/calc` HTTP contract 测试。 +- 增加 `createCharacter(...).calc(...)` golden smoke 测试。 + +成功标准: + +- `uv run pytest` 可执行。 +- `/calc` 响应仍包含 `code/message/data`。 +- `data` 至少包含 `uuid/skills/info/suits/buffer`。 + +### Phase 1:API service 边界 + +新增 `backend/services/calc_service.py`,API route 不再直接编排计算。 + +重点: + +- route 保持原路径、请求体和响应格式。 +- service 负责 `createCharacter`、空 config 兜底、计算执行。 +- 为后续日志、耗时、缓存预留 service 层。 + +成功标准: + +- `/api/calc` 行为不变。 +- route 文件只保留 HTTP 层职责。 +- Phase 0 测试全部通过。 + +### Phase 2:config view 与计算输入收口 + +新增 `CalcConfigView`,集中处理 config 的默认值与字段读取。 + +重点: + +- `equips/oaths/emblems/skills/avatar/sundry/jades/options` 都有稳定默认值。 +- 先在 `calc_init()`、`SetDetail()`、`SetSkills()` 等入口使用,不一次性替换所有 mixin。 +- 保留向后兼容,仍支持原始 dict。 + +成功标准: + +- 缺少可选字段时不抛出 KeyError。 +- Skill/Forge 配置链路不变:config 变化 -> result.execute -> saveConfig。 +- golden case 结果字段不变。 + +### Phase 3:装备数据 registry + +新增 `EquipmentRegistry`,替换 `get_equipment()` 内部的 `eval` 与模块全局变量访问。 + +重点: + +- `get_equipment(version)` 作为兼容门面继续存在。 +- registry 内部使用 `functools.cache` 或显式 dict 缓存。 +- 版本不存在时保持回退 `0` 的现有行为,但记录原因。 + +成功标准: + +- 现有调用方无需一次性改动。 +- 多版本装备数据加载路径清晰。 +- 单元测试覆盖缓存、回退和不存在版本。 + +### Phase 4:计算核心 helper 化 + +抽取重复且无业务争议的 helper,降低 mixin 复杂度。 + +重点: + +- `apply_uppercase_attrs(character, source)` 统一处理首字母大写属性应用。 +- `execute_effect(effects, key, *args)` 统一处理效果函数不存在时的行为。 +- 优先处理 `calc_suits/calc_equs/calc_oaths/calc_emblems` 中重复逻辑。 + +成功标准: + +- helper 有单元测试。 +- 业务公式位置不变或仅做等价搬迁。 +- `calc_equip.py` 可读性提升,结果不变。 + +### Phase 5:性能观测与热点优化 + +在 service 层引入轻量计时,再针对热点做优化。 + +重点: + +- 记录 `createCharacter`、`calc` 总耗时。 +- 可选记录 `calc_*` 分段耗时,默认不影响响应结构。 +- 优化重复 `获取基础属性()` 调用。 +- 评估技能实例模板缓存,但必须避免可变技能状态污染请求。 + +成功标准: + +- 能拿到典型配置的平均耗时和 p95。 +- 优化前后 golden case 一致。 +- 性能优化有数据支撑。 + +### Phase 6:规范与文档 + +清理运行缓存、废弃文件和文档入口。 + +重点: + +- 确认 `.venv/.ruff_cache/__pycache__/.DS_Store` 不进入 git。 +- 评估 `character_old.py` 移到归档或删除。 +- 补充 backend README:运行、测试、质量检查、目录职责。 +- 不在本阶段大规模格式化老文件。 + +成功标准: + +- 新人可通过文档理解 backend 入口和测试方式。 +- 废弃代码不会继续误导维护。 +- 规范变更不产生大规模业务 diff。 + +## 风险与缓解 + +| 风险 | 缓解 | +| --- | --- | +| 重构导致 `/calc` 字段变化 | Phase 0 先做 contract/golden 测试 | +| 技能对象可变状态污染缓存 | 技能模板缓存必须后置,且只缓存类或不可变模板 | +| 装备版本回退行为改变 | `get_equipment()` 保留兼容门面,并测试回退 | +| config 字段默认值与前端不一致 | `CalcConfigView` 先只集中现有默认值,不新增语义 | +| 性能统计污染响应 | 默认只写日志,不改变 API data | + +## 验收总标准 + +- `uv run pytest` 通过。 +- `/api/calc` 响应契约不变。 +- frontendNew 的 result.execute 与 saveConfig 链路无需调整。 +- 典型职业 golden case 在重构前后字段一致,关键数值在允许误差内。 +- backend 文档说明清晰,后续任务可以分阶段执行。 diff --git a/docs/backend/2026-06-04-backend-optimization-tasks.md b/docs/backend/2026-06-04-backend-optimization-tasks.md new file mode 100644 index 00000000..22182f40 --- /dev/null +++ b/docs/backend/2026-06-04-backend-optimization-tasks.md @@ -0,0 +1,908 @@ +# Backend 渐进式优化执行清单 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在保持 `/calc` API 契约和 frontendNew 计算链路不变的前提下,逐步提升 backend 的可维护性、扩展性、计算效率与开发规范。 + +**Architecture:** 先建立测试安全网,再把 HTTP route 与计算编排拆到 service 层,随后用 config view 和 equipment registry 收口隐式边界。最后基于耗时观测做性能优化,并补齐 backend 文档与规范。 + +**Tech Stack:** Python 3.10+、FastAPI、Pydantic、SQLAlchemy、Redis、SQLite、pytest、ruff、uv。 + +--- + +## 文件结构规划 + +- Create: `backend/tests/conftest.py` + 提供测试用 FastAPI client、alter token、最小 calc config fixture。 + +- Create: `backend/tests/contract/test_calc_contract.py` + 固定 `/api/calc` HTTP 响应契约。 + +- Create: `backend/tests/golden/test_calc_golden.py` + 固定典型职业计算结果结构,后续可扩展数值快照。 + +- Create: `backend/services/__init__.py` + 标记 service 包。 + +- Create: `backend/services/calc_service.py` + 承接 `/calc` 应用服务编排。 + +- Create: `backend/core/basic/character/config_view.py` + 集中处理 calc config 默认值与读取。 + +- Create: `backend/core/basic/equipment_registry.py` + 装备版本 registry 与缓存。 + +- Modify: `backend/api/routes/calc.py` + 改为调用 `calc_service.calculate()`,保持 route 契约不变。 + +- Modify: `backend/core/basic/character/calc_equip.py` + 逐步使用 `CalcConfigView` 与 helper,不改计算语义。 + +- Modify: `backend/core/basic/equipment.py` + `get_equipment()` 委托 `EquipmentRegistry`,保留兼容函数。 + +- Modify: `backend/pyproject.toml` + 增加 `pytest`、必要时增加测试配置。 + +- Create or Modify: `backend/README.md` + 说明运行、测试、质量检查、目录职责。 + +## Task 1:建立 pytest 基础与最小 contract 测试 + +**Files:** + +- Modify: `backend/pyproject.toml` +- Create: `backend/tests/conftest.py` +- Create: `backend/tests/contract/test_calc_contract.py` + +- [ ] **Step 1: 添加 pytest 开发依赖** + +在 `backend/pyproject.toml` 的 `[dependency-groups].dev` 中加入: + +```toml +[dependency-groups] +dev = [ + "pytest>=8.4.0", + "ruff>=0.15.15", +] +``` + +- [ ] **Step 2: 同步依赖** + +Run: + +```bash +cd backend && uv sync +``` + +Expected: `pytest` 安装完成,`uv.lock` 更新。 + +- [ ] **Step 3: 创建测试 fixture** + +Create `backend/tests/conftest.py`: + +```python +import base64 +import json +import time + +import pytest +from fastapi.testclient import TestClient + +from main import application + + +@pytest.fixture +def client(): + return TestClient(application) + + +@pytest.fixture +def alter_token(): + payload = { + 'alter': '1.1.1', + 'equVersion': '0', + 'time': int(time.time() * 1000), + } + return base64.b64encode(json.dumps(payload).encode('utf-8')).decode('utf-8') + + +@pytest.fixture +def minimal_calc_config(): + parts = [ + '武器', + '副武器', + '称号', + '上衣', + '头肩', + '下装', + '腰带', + '鞋', + '手镯', + '项链', + '戒指', + '辅助装备', + '魔法石', + '耳环', + ] + return { + 'equips': { + part: { + 'id': '', + 'reinforce': 0, + 'reinforceType': 0, + 'enchant': '0', + 'refine': 0, + 'adaptation': 0, + } + for part in parts + }, + 'oaths': {}, + 'emblems': {}, + 'skills': {}, + 'avatar': {}, + 'sundry': {}, + 'jades': {}, + 'options': {}, + 'DSB': False, + 'BUFF': False, + } +``` + +- [ ] **Step 4: 创建 contract 测试** + +Create `backend/tests/contract/test_calc_contract.py`: + +```python +def test_calc_response_contract(client, alter_token, minimal_calc_config): + response = client.post( + '/api/calc', + headers={'alter-token': alter_token}, + json=minimal_calc_config, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['code'] == 200 + assert payload['message'] == 'Success' + assert isinstance(payload['data'], dict) + + data = payload['data'] + assert set(['uuid', 'skills', 'info', 'suits', 'buffer']).issubset(data) + assert isinstance(data['uuid'], str) + assert isinstance(data['skills'], list) + assert isinstance(data['info'], list) + assert isinstance(data['buffer'], bool) +``` + +- [ ] **Step 5: 运行测试,记录真实失败** + +Run: + +```bash +cd backend && uv run pytest backend/tests/contract/test_calc_contract.py -v +``` + +Expected: 如果 Redis 依赖导致失败,记录失败原因;不要绕过 `/calc` 鉴权契约。后续可在 fixture 中 monkeypatch `api.dp.get_redis_info` 或提供本地 Redis。 + +- [ ] **Step 6: 提交本任务** + +Run: + +```bash +git add backend/pyproject.toml backend/uv.lock backend/tests/conftest.py backend/tests/contract/test_calc_contract.py +git commit -m "test: add backend calc contract coverage" +``` + +## Task 2:增加 core golden smoke 测试 + +**Files:** + +- Create: `backend/tests/golden/test_calc_golden.py` + +- [ ] **Step 1: 创建核心计算 smoke 测试** + +Create `backend/tests/golden/test_calc_golden.py`: + +```python +from core.basic.character import createCharacter + + +def test_character_calc_returns_result_shape(minimal_calc_config): + character = createCharacter('1.1.1', '0') + + result = character.calc(minimal_calc_config) + + assert isinstance(result, dict) + assert set(['uuid', 'skills', 'info', 'suits', 'buffer']).issubset(result) + assert isinstance(result['skills'], list) + assert isinstance(result['info'], list) + assert result['buffer'] is False +``` + +- [ ] **Step 2: 运行 golden smoke** + +Run: + +```bash +cd backend && uv run pytest backend/tests/golden/test_calc_golden.py -v +``` + +Expected: PASS。若职业 `1.1.1` 不存在,改用 `core.character.adventure` 中第一个 `open=True` 的职业,并同步更新 Task 1 fixture。 + +- [ ] **Step 3: 运行全部测试** + +Run: + +```bash +cd backend && uv run pytest -v +``` + +Expected: PASS,或仅 contract 测试因外部 Redis 环境失败且原因明确。 + +- [ ] **Step 4: 提交本任务** + +Run: + +```bash +git add backend/tests/golden/test_calc_golden.py +git commit -m "test: add backend calc golden smoke" +``` + +## Task 3:引入 calc service,瘦身 API route + +**Files:** + +- Create: `backend/services/__init__.py` +- Create: `backend/services/calc_service.py` +- Modify: `backend/api/routes/calc.py` +- Test: `backend/tests/contract/test_calc_contract.py` +- Test: `backend/tests/golden/test_calc_golden.py` + +- [ ] **Step 1: 创建 service 包** + +Create `backend/services/__init__.py`: + +```python +"""Application services for backend API orchestration.""" +``` + +- [ ] **Step 2: 创建 calc service** + +Create `backend/services/calc_service.py`: + +```python +from typing import Any + +from api.core.Auth import AlterState +from core.basic.character import createCharacter + + +def calculate(state: AlterState, config: dict[str, Any] | None) -> dict[str, Any]: + safe_config = config or {} + character = createCharacter(state.alter, state.equVersion) + return character.calc(safe_config) +``` + +- [ ] **Step 3: 修改 route 调用 service** + +Modify `backend/api/routes/calc.py`: + +```python +from api.core.Gzip import register_gzip_request +from fastapi import APIRouter, Body +from api.core.Response import response +from api.dp import AltersDep +from services.calc_service import calculate + +router = APIRouter() + +register_gzip_request(router) + + +@router.post('/calc') +async def calc(state: AltersDep, config=Body(None)): + return response(data=calculate(state, config)) +``` + +- [ ] **Step 4: 运行测试** + +Run: + +```bash +cd backend && uv run pytest backend/tests/contract/test_calc_contract.py backend/tests/golden/test_calc_golden.py -v +``` + +Expected: PASS。 + +- [ ] **Step 5: 提交本任务** + +Run: + +```bash +git add backend/services/__init__.py backend/services/calc_service.py backend/api/routes/calc.py +git commit -m "refactor: route calc through service layer" +``` + +## Task 4:增加 CalcConfigView,集中 config 默认值 + +**Files:** + +- Create: `backend/core/basic/character/config_view.py` +- Modify: `backend/core/basic/character/calc_equip.py` +- Test: `backend/tests/unit/test_calc_config_view.py` + +- [ ] **Step 1: 创建 config view** + +Create `backend/core/basic/character/config_view.py`: + +```python +from collections.abc import Mapping +from typing import Any + + +class CalcConfigView: + def __init__(self, raw: Mapping[str, Any] | None): + self.raw = dict(raw or {}) + + @property + def equips(self) -> dict[str, dict[str, Any]]: + return dict(self.raw.get('equips') or {}) + + @property + def oaths(self) -> dict[str, dict[str, Any]]: + return dict(self.raw.get('oaths') or {}) + + @property + def emblems(self) -> dict[str, dict[str, Any]]: + return dict(self.raw.get('emblems') or {}) + + @property + def skills(self) -> dict[str, dict[str, Any]]: + return dict(self.raw.get('skills') or {}) + + @property + def avatar(self) -> dict[str, Any]: + return dict(self.raw.get('avatar') or {}) + + @property + def sundry(self) -> dict[str, Any]: + return dict(self.raw.get('sundry') or {}) + + @property + def jades(self) -> dict[str, Any]: + return dict(self.raw.get('jades') or {}) + + @property + def options(self) -> dict[str, Any]: + return dict(self.raw.get('options') or {}) + + @property + def dsb(self) -> bool: + return bool(self.raw.get('DSB', False)) + + @property + def buff(self) -> bool: + return bool(self.raw.get('BUFF', False)) + + def as_legacy_dict(self) -> dict[str, Any]: + data = dict(self.raw) + data.setdefault('equips', {}) + data.setdefault('oaths', {}) + data.setdefault('emblems', {}) + data.setdefault('skills', {}) + data.setdefault('avatar', {}) + data.setdefault('sundry', {}) + data.setdefault('jades', {}) + data.setdefault('options', {}) + data.setdefault('DSB', False) + data.setdefault('BUFF', False) + return data +``` + +- [ ] **Step 2: 添加单元测试** + +Create `backend/tests/unit/test_calc_config_view.py`: + +```python +from core.basic.character.config_view import CalcConfigView + + +def test_config_view_provides_defaults(): + view = CalcConfigView(None) + + assert view.equips == {} + assert view.oaths == {} + assert view.emblems == {} + assert view.skills == {} + assert view.avatar == {} + assert view.sundry == {} + assert view.jades == {} + assert view.options == {} + assert view.dsb is False + assert view.buff is False + + +def test_config_view_preserves_legacy_keys(): + view = CalcConfigView({'skills': {'100': {'lv': 1}}, 'BUFF': True}) + + data = view.as_legacy_dict() + + assert data['skills'] == {'100': {'lv': 1}} + assert data['BUFF'] is True + assert data['DSB'] is False + assert data['equips'] == {} +``` + +- [ ] **Step 3: 在 calc_init 入口使用兼容 dict** + +Modify `backend/core/basic/character/calc_equip.py`: + +```python +from .config_view import CalcConfigView +``` + +在 `calc()` 起始处改为: + +```python +def calc(self, setInfo: dict[str, dict]): + config = CalcConfigView(setInfo).as_legacy_dict() + self.calc_init(config) + self.SetBaseStatus() + self.calc_jade(config.get('jades', [])) + self.calc_avatar(config.get('avatar', {})) + self.calc_basic() + self.calc_sundry(config.get('sundry', {})) + self.calc_emblems() + suit = self.calc_suits() + self.calc_equs() + self.calc_oaths() + skillInfos = [] + if self.buffer: + skillInfos = self.calc_buffer_skills() + info = self.get_buffer_info() + else: + if len(self.输出类型选项) > 1: + temp = self.输出类型选项[0] + if '固伤' in temp: + if self.INT > self.STR: + self.输出类型 = '魔法固伤' + else: + self.输出类型 = '物理固伤' + else: + if self.INT > self.STR and self.AtkM > self.AtkP: + self.输出类型 = '魔法百分比' + else: + self.输出类型 = '物理百分比' + skillInfos = self.calc_carry_skills(config.get('DSB', False), config.get('BUFF', False)) + info = self.get_carry_info() + return {'uuid': uuid1().hex, 'skills': skillInfos, 'info': info, 'suits': suit, 'buffer': self.buffer} +``` + +- [ ] **Step 4: 运行测试** + +Run: + +```bash +cd backend && uv run pytest backend/tests/unit/test_calc_config_view.py backend/tests/golden/test_calc_golden.py -v +``` + +Expected: PASS。 + +- [ ] **Step 5: 提交本任务** + +Run: + +```bash +git add backend/core/basic/character/config_view.py backend/core/basic/character/calc_equip.py backend/tests/unit/test_calc_config_view.py +git commit -m "refactor: centralize calc config defaults" +``` + +## Task 5:引入 EquipmentRegistry,替换 eval 获取装备数据 + +**Files:** + +- Create: `backend/core/basic/equipment_registry.py` +- Modify: `backend/core/basic/equipment.py` +- Test: `backend/tests/unit/test_equipment_registry.py` + +- [ ] **Step 1: 创建 registry** + +Create `backend/core/basic/equipment_registry.py`: + +```python +from functools import cache + + +class EquipmentRegistry: + def __init__(self, equipment_cls): + self.equipment_cls = equipment_cls + + @cache + def get(self, version: str = '0'): + normalized = version or '0' + try: + return self.equipment_cls(normalized) + except Exception: + if normalized == '0': + raise + return self.get('0') +``` + +- [ ] **Step 2: 修改 equipment.py 兼容门面** + +Modify `backend/core/basic/equipment.py` 底部: + +```python +from core.basic.equipment_registry import EquipmentRegistry + + +_equipment_registry = EquipmentRegistry(Equipments) + + +def get_equipment(version: str = '0') -> Equipments: + return _equipment_registry.get(version) +``` + +删除或停止使用: + +```python +global equ0 +equ0 = Equipments('0') +``` + +以及 `eval(f'equ{version}'.format(version))` 相关逻辑。 + +- [ ] **Step 3: 添加 registry 测试** + +Create `backend/tests/unit/test_equipment_registry.py`: + +```python +from core.basic.equipment import get_equipment + + +def test_get_equipment_returns_cached_default_version(): + first = get_equipment('0') + second = get_equipment('0') + + assert first is second + assert first.version == '0' + + +def test_get_equipment_falls_back_to_default_version(): + equipment = get_equipment('missing-version') + + assert equipment.version == '0' +``` + +- [ ] **Step 4: 运行测试** + +Run: + +```bash +cd backend && uv run pytest backend/tests/unit/test_equipment_registry.py backend/tests/golden/test_calc_golden.py -v +``` + +Expected: PASS。 + +- [ ] **Step 5: 提交本任务** + +Run: + +```bash +git add backend/core/basic/equipment_registry.py backend/core/basic/equipment.py backend/tests/unit/test_equipment_registry.py +git commit -m "refactor: add equipment registry" +``` + +## Task 6:增加 calc service 耗时观测 + +**Files:** + +- Modify: `backend/services/calc_service.py` +- Test: `backend/tests/contract/test_calc_contract.py` + +- [ ] **Step 1: 添加轻量日志计时** + +Modify `backend/services/calc_service.py`: + +```python +import logging +import time +from typing import Any + +from api.core.Auth import AlterState +from core.basic.character import createCharacter + + +logger = logging.getLogger('dcalc.calc') + + +def calculate(state: AlterState, config: dict[str, Any] | None) -> dict[str, Any]: + started_at = time.perf_counter() + safe_config = config or {} + character = createCharacter(state.alter, state.equVersion) + created_at = time.perf_counter() + result = character.calc(safe_config) + finished_at = time.perf_counter() + logger.info( + 'calc completed alter=%s equVersion=%s create_ms=%.2f calc_ms=%.2f total_ms=%.2f', + state.alter, + state.equVersion, + (created_at - started_at) * 1000, + (finished_at - created_at) * 1000, + (finished_at - started_at) * 1000, + ) + return result +``` + +- [ ] **Step 2: 运行 contract 测试** + +Run: + +```bash +cd backend && uv run pytest backend/tests/contract/test_calc_contract.py -v +``` + +Expected: PASS。响应 data 不新增耗时字段。 + +- [ ] **Step 3: 手动观察日志** + +Run: + +```bash +cd backend && uv run python main.py +``` + +Expected: 服务正常启动。发起 `/api/calc` 后可在日志处理器中接入 `dcalc.calc` logger;本任务不要求改变线上日志格式。 + +- [ ] **Step 4: 提交本任务** + +Run: + +```bash +git add backend/services/calc_service.py +git commit -m "chore: add calc timing logs" +``` + +## Task 7:抽取 effect 应用 helper + +**Files:** + +- Create: `backend/core/basic/character/effect_apply.py` +- Modify: `backend/core/basic/character/calc_equip.py` +- Test: `backend/tests/unit/test_effect_apply.py` +- Test: `backend/tests/golden/test_calc_golden.py` + +- [ ] **Step 1: 创建 helper** + +Create `backend/core/basic/character/effect_apply.py`: + +```python +from typing import Any + + +def uppercase_attrs(source: Any) -> dict[str, Any]: + return { + key: value + for key, value in source.__dict__.items() + if key and key[0].isupper() + } + + +def apply_uppercase_attrs(character: Any, source: Any) -> None: + character.SetStatus(**uppercase_attrs(source)) + + +def execute_effect(effects: Any, key: str, *args: Any) -> bool: + fun = effects.execture(key) + if fun is None: + return False + fun(*args) + return True +``` + +- [ ] **Step 2: 添加 helper 测试** + +Create `backend/tests/unit/test_effect_apply.py`: + +```python +from core.basic.character.effect_apply import uppercase_attrs + + +class Source: + STR = 10 + + def __init__(self): + self.INT = 20 + self.name = 'ignored' + + +def test_uppercase_attrs_returns_only_uppercase_keys(): + assert uppercase_attrs(Source()) == {'INT': 20} +``` + +- [ ] **Step 3: 在 calc_equip.py 中逐步替换重复代码** + +Modify selected occurrences in `backend/core/basic/character/calc_equip.py`: + +```python +from .effect_apply import apply_uppercase_attrs, execute_effect, uppercase_attrs +``` + +将简单重复片段: + +```python +filtered_dict = {k: v for k, v in suit.__dict__.items() if k[0].isupper()} +self.SetStatus(**filtered_dict) +``` + +替换为: + +```python +apply_uppercase_attrs(self, suit) +``` + +将: + +```python +fun = effects.execture(f'equ_{equ.id}') +if fun is None: + continue +filtered_dict = {k: v for k, v in equ.__dict__.items() if k[0].isupper()} +self.SetStatus(**filtered_dict) +fun(self) +``` + +替换为: + +```python +fun = effects.execture(f'equ_{equ.id}') +if fun is None: + continue +apply_uppercase_attrs(self, equ) +fun(self) +``` + +- [ ] **Step 4: 运行测试** + +Run: + +```bash +cd backend && uv run pytest backend/tests/unit/test_effect_apply.py backend/tests/golden/test_calc_golden.py -v +``` + +Expected: PASS。 + +- [ ] **Step 5: 提交本任务** + +Run: + +```bash +git add backend/core/basic/character/effect_apply.py backend/core/basic/character/calc_equip.py backend/tests/unit/test_effect_apply.py +git commit -m "refactor: extract character effect helpers" +``` + +## Task 8:补充 backend README + +**Files:** + +- Create or Modify: `backend/README.md` + +- [ ] **Step 1: 编写 backend README** + +Create or Modify `backend/README.md`: + +````markdown +# dcalc backend + +## Run + +```bash +uv sync +python main.py +``` + +## Test + +```bash +uv run pytest -v +``` + +## Quality + +```bash +uv run ruff check . +``` + +## Runtime Entry + +- `main.py`: FastAPI application and uvicorn entry. +- `api/routes/calc.py`: `/api/calc` HTTP route. +- `services/calc_service.py`: calculation orchestration. +- `core/basic/character/`: character calculation implementation. +- `core/basic/equipment.py`: equipment data models and compatibility access. +- `core/basic/equipment_registry.py`: equipment version registry and cache. +- `database/connect.py`: SQLite and Redis connections. + +## Compatibility Rules + +- Do not change `/calc` request and response contract without frontend migration approval. +- Do not change frontendNew result data shape during backend refactors. +- Keep Skill and Forge business formulas behavior-compatible unless a dedicated formula task says otherwise. +```` + +- [ ] **Step 2: 运行文档相关检查** + +Run: + +```bash +cd backend && uv run ruff check . +``` + +Expected: PASS,或只出现既有遗留问题。若有既有问题,不在本任务做大范围修复。 + +- [ ] **Step 3: 提交本任务** + +Run: + +```bash +git add backend/README.md +git commit -m "docs: document backend runtime structure" +``` + +## Task 9:阶段性全量验证 + +**Files:** + +- No source changes expected. + +- [ ] **Step 1: 运行测试** + +Run: + +```bash +cd backend && uv run pytest -v +``` + +Expected: PASS。 + +- [ ] **Step 2: 运行 lint** + +Run: + +```bash +cd backend && uv run ruff check . +``` + +Expected: PASS,或输出仅包含已登记的遗留问题。 + +- [ ] **Step 3: 启动服务** + +Run: + +```bash +cd backend && uv run python main.py +``` + +Expected: 输出 `Application startup complete.`,服务监听 `config.PORT`。 + +- [ ] **Step 4: 手动验证 frontendNew 链路** + +Run: + +```bash +cd frontendNew && pnpm dev +``` + +Expected: 在 Character 模块修改 Skill/Forge 配置后,仍满足 `config 变化 -> result.execute -> saveConfig`。 + +- [ ] **Step 5: 提交验证记录** + +Run: + +```bash +git status --short +``` + +Expected: 工作区干净,所有阶段任务已独立提交。 diff --git a/images b/images index 0991b6ef..051a7cde 160000 --- a/images +++ b/images @@ -1 +1 @@ -Subproject commit 0991b6efa48212464d9e7ca4ba4181000f7236db +Subproject commit 051a7cde33414d251d901b0a553fb592b46247ee