修复策略文件无法删除问题

This commit is contained in:
shy3130
2026-07-22 11:21:13 +08:00
parent 2c14fde788
commit 2d9fe4a4af
3 changed files with 218 additions and 18 deletions
+83 -18
View File
@@ -6,6 +6,7 @@ from __future__ import annotations
import ast
import json
import logging
import math
import re
from dataclasses import asdict
@@ -25,6 +26,7 @@ from app.strategy.monitor import StrategyMonitorService
from app.strategy.prompt_builder import build_step1, build_step2
router = APIRouter(prefix="/api/strategies", tags=["strategies"])
logger = logging.getLogger(__name__)
# ── Helpers ──────────────────────────────────────────────────────────
@@ -56,6 +58,57 @@ def _invalidate_strategy_runtime(request: Request) -> None:
monitor_engine.invalidate_strategy_state()
def _cleanup_deleted_strategy(request: Request, strategy_id: str) -> list[str]:
"""尽力清理删除后的派生状态, 清理失败不应把已成功的源文件删除变成 500。"""
from app.services import preferences
from app.strategy import monitor_rules
data_dir = _data_dir(request)
warnings: list[str] = []
try:
strategy_config.delete_override(data_dir, strategy_id)
except Exception as e:
warnings.append(f"覆盖配置清理失败: {e}")
try:
_invalidate_strategy_runtime(request)
except Exception as e:
warnings.append(f"运行缓存清理失败: {e}")
try:
monitored_ids = preferences.get_strategy_monitor_ids()
if strategy_id in monitored_ids:
preferences.set_realtime_monitor_config({
"strategy_monitor_ids": [sid for sid in monitored_ids if sid != strategy_id],
})
except Exception as e:
warnings.append(f"监控偏好清理失败: {e}")
try:
rules_changed = False
for rule in monitor_rules.load_all(data_dir):
if (
rule.get("type") == "strategy"
and rule.get("strategy_id") == strategy_id
and rule.get("enabled", True)
):
rule = dict(rule)
rule["enabled"] = False
monitor_rules.save_one(data_dir, rule)
rules_changed = True
monitor_engine = getattr(request.app.state, "monitor_engine", None)
if rules_changed and monitor_engine is not None:
monitor_engine.set_rules(monitor_rules.load_all(data_dir))
except Exception as e:
warnings.append(f"关联监控清理失败: {e}")
for warning in warnings:
logger.warning("delete strategy %s: %s", strategy_id, warning)
return warnings
def _safe(result_dict: dict) -> dict:
rows = result_dict.get("rows", [])
for r in rows:
@@ -763,34 +816,46 @@ async def ai_save(req: AISaveRequest, request: Request):
@router.delete("/{strategy_id}")
def delete_strategy(strategy_id: str, request: Request):
"""删除自定义策略 — 清除 .py 文件 + overrides + 热重载。内置策略不可删除。"""
"""删除自定义策略 — 清除源文件、运行时注册和关联状态。内置策略不可删除。"""
engine = _get_engine(request)
try:
s = engine.get(strategy_id)
except ValueError:
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在")
except ValueError as e:
raise HTTPException(status_code=404, detail=f"策略 {strategy_id} 不存在") from e
if s.source == "builtin":
raise HTTPException(status_code=403, detail="内置策略不可删除")
data_dir = _data_dir(request)
path = s.file_path
previous_code = path.read_text(encoding="utf-8") if path and path.exists() else None
if path and path.exists():
path.unlink()
try:
engine.reload()
except Exception as e:
if path is not None and previous_code is not None:
path.write_text(previous_code, encoding="utf-8")
engine.reload()
raise HTTPException(status_code=400, detail=f"策略删除失败: {e}") from e
data_dir = _data_dir(request)
if path is None or s.source not in {"custom", "ai"}:
raise HTTPException(status_code=400, detail="策略源文件路径无效, 无法删除")
override_path = data_dir / "user_data" / "strategy_overrides" / f"{strategy_id}.json"
override_path.unlink(missing_ok=True)
_invalidate_strategy_runtime(request)
return {"ok": True}
try:
allowed_dir = (data_dir / "strategies" / s.source).resolve()
resolved_path = path.resolve()
except (OSError, RuntimeError) as e:
raise HTTPException(status_code=409, detail=f"无法访问策略文件: {e}") from e
if not resolved_path.is_relative_to(allowed_dir):
raise HTTPException(status_code=400, detail="策略源文件不在用户策略目录, 拒绝删除")
try:
resolved_path.unlink(missing_ok=True)
except OSError as e:
reason = e.strerror or str(e)
raise HTTPException(
status_code=409,
detail=(
f"无法删除策略文件 {resolved_path.name}: {reason}"
"请确认数据目录可写; Docker 部署请检查数据卷不是只读挂载。"
),
) from e
# 删除只影响当前策略, 全量 reload 会让其他损坏或重复 ID 的文件阻塞本次删除。
engine.unregister(strategy_id)
warnings = _cleanup_deleted_strategy(request, strategy_id)
return {"ok": True, "warnings": warnings}
# ── 监控 ─────────────────────────────────────────────────────────────
+11
View File
@@ -419,6 +419,17 @@ class StrategyEngine:
def has(self, strategy_id: str) -> bool:
return strategy_id in self._strategies
def unregister(self, strategy_id: str) -> bool:
"""从运行时注册表移除单个策略, 不重新加载其他策略文件。"""
if strategy_id not in self._strategies:
return False
strategies = dict(self._strategies)
strategies.pop(strategy_id)
self._strategies = strategies
with self._realtime_matrix_lock:
self._realtime_matrices.clear()
return True
@staticmethod
def validate_context(strategy: StrategyDef, context: StrategyDataContext) -> None:
asset_types = strategy.meta.get("asset_types", ["stock"])
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from app.api.strategy import delete_strategy
from app.services import preferences
from app.strategy import monitor_rules
from app.strategy.engine import StrategyEngine
def _strategy_code(strategy_id: str) -> str:
return f'''import polars as pl
META = {{
"id": "{strategy_id}",
"name": "{strategy_id}",
"asset_types": ["stock"],
"timeframes": ["1d"],
}}
def filter(df, params):
return pl.lit(True)
'''
class _MonitorEngine:
def __init__(self) -> None:
self.invalidations = 0
self.rules: list[dict] = []
def invalidate_strategy_state(self) -> None:
self.invalidations += 1
def set_rules(self, rules: list[dict]) -> None:
self.rules = rules
def _request(data_dir: Path, engine: StrategyEngine, monitor: _MonitorEngine | None = None):
repo = SimpleNamespace(store=SimpleNamespace(data_dir=data_dir))
state = SimpleNamespace(repo=repo, strategy_engine=engine, monitor_engine=monitor)
return SimpleNamespace(app=SimpleNamespace(state=state))
def test_delete_strategy_is_not_blocked_by_another_broken_file(monkeypatch, tmp_path):
custom_dir = tmp_path / "strategies" / "custom"
custom_dir.mkdir(parents=True)
strategy_path = custom_dir / "target.py"
strategy_path.write_text(_strategy_code("target"), encoding="utf-8")
engine = StrategyEngine(strategy_dirs=[custom_dir])
# 模拟用户目录中另有一份损坏策略。旧删除逻辑的全量 reload 会因此回滚并返回 500。
broken_path = custom_dir / "broken.py"
broken_path.write_text("this is not valid python", encoding="utf-8")
with pytest.raises(ValueError, match="strategy reload failed"):
engine.reload()
assert engine.has("target")
override_path = tmp_path / "user_data" / "strategy_overrides" / "target.json"
override_path.parent.mkdir(parents=True)
override_path.write_text("{}", encoding="utf-8")
cache_path = tmp_path / "user_data" / "strategy_cache.json"
cache_path.write_text("{}", encoding="utf-8")
rule = monitor_rules.normalize({
"id": "mr_target",
"name": "策略监控 · target",
"type": "strategy",
"strategy_id": "target",
"scope": "all",
"conditions": [],
})
monitor_rules.save_one(tmp_path, rule)
preference_updates: list[dict] = []
monkeypatch.setattr(preferences, "get_strategy_monitor_ids", lambda: ["target", "other"])
monkeypatch.setattr(
preferences,
"set_realtime_monitor_config",
lambda config: preference_updates.append(config) or config,
)
monitor = _MonitorEngine()
result = delete_strategy("target", _request(tmp_path, engine, monitor))
assert result == {"ok": True, "warnings": []}
assert not strategy_path.exists()
assert broken_path.exists()
assert not engine.has("target")
assert not override_path.exists()
assert not cache_path.exists()
assert preference_updates == [{"strategy_monitor_ids": ["other"]}]
saved_rule = monitor_rules.load_one(tmp_path, "mr_target")
assert saved_rule is not None and saved_rule["enabled"] is False
assert monitor.invalidations == 1
assert monitor.rules and monitor.rules[0]["enabled"] is False
def test_delete_strategy_reports_read_only_volume_without_unregistering(monkeypatch, tmp_path):
custom_dir = tmp_path / "strategies" / "custom"
custom_dir.mkdir(parents=True)
strategy_path = custom_dir / "target.py"
strategy_path.write_text(_strategy_code("target"), encoding="utf-8")
engine = StrategyEngine(strategy_dirs=[custom_dir])
request = _request(tmp_path, engine)
original_unlink = Path.unlink
def blocked_unlink(path: Path, *args, **kwargs):
if path == strategy_path:
raise PermissionError(30, "Read-only file system", str(path))
return original_unlink(path, *args, **kwargs)
monkeypatch.setattr(Path, "unlink", blocked_unlink)
with pytest.raises(HTTPException) as exc_info:
delete_strategy("target", request)
assert exc_info.value.status_code == 409
assert "Docker" in exc_info.value.detail
assert "只读挂载" in exc_info.value.detail
assert strategy_path.exists()
assert engine.has("target")