From 98261248a2a9adb3fe772aea8ca86cfdd87849ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A5=BF=E7=93=9C=C2=B0?= Date: Thu, 4 Jun 2026 19:49:54 +0800 Subject: [PATCH] docs: add backend optimization plan --- .../2026-06-04-backend-optimization-design.md | 237 +++++ .../2026-06-04-backend-optimization-tasks.md | 908 ++++++++++++++++++ 2 files changed, 1145 insertions(+) create mode 100644 docs/backend/2026-06-04-backend-optimization-design.md create mode 100644 docs/backend/2026-06-04-backend-optimization-tasks.md 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: 工作区干净,所有阶段任务已独立提交。