diff --git a/backend/app/api/strategy.py b/backend/app/api/strategy.py index c651ae2..42c8df4 100644 --- a/backend/app/api/strategy.py +++ b/backend/app/api/strategy.py @@ -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} # ── 监控 ───────────────────────────────────────────────────────────── diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 6bca5f8..fa0fbe9 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -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"]) diff --git a/backend/tests/test_strategy_delete.py b/backend/tests/test_strategy_delete.py new file mode 100644 index 0000000..5ab1972 --- /dev/null +++ b/backend/tests/test_strategy_delete.py @@ -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")