mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
Merge pull request #275 from kevin9327/fix/factor-delete-fail-closed
fix(factors): 删除因子被拒绝时定义已被删掉 (删除路径未 fail-closed)
This commit is contained in:
@@ -381,14 +381,16 @@ def delete_custom_factor(factor_id: str, request: Request, force: bool = Query(d
|
||||
data_dir = _data_dir(request)
|
||||
from app.factors.registry import get_factor
|
||||
|
||||
if get_factor(factor_id) is None and not store.delete_one(data_dir, factor_id):
|
||||
raise HTTPException(status_code=404, detail=f"因子不存在: {factor_id}")
|
||||
# 引用检查必须排在存在性判定之前: 下面用来探测「盘上是否有定义」的
|
||||
# store.delete_one 本身就会删文件, 反过来会出现「拒绝删除」但定义已被删掉。
|
||||
references = _find_references(data_dir, factor_id)
|
||||
if references and not force:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"message": "该因子仍有引用, 拒绝删除 (可带 force=true 强制)", "references": references},
|
||||
)
|
||||
if get_factor(factor_id) is None and not store.delete_one(data_dir, factor_id):
|
||||
raise HTTPException(status_code=404, detail=f"因子不存在: {factor_id}")
|
||||
try:
|
||||
unregister_factor(factor_id)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""删除自定义因子: 有引用时必须 fail-closed —— 拒绝的同时定义文件不能已经被删掉。
|
||||
|
||||
触发路径: 定义在磁盘上但没进注册表 (load_into_registry 对注册失败的定义只告警跳过,
|
||||
如复合因子的成员已被强制删除)。此时 get_factor() 为 None, 端点的 404 守卫会走到
|
||||
第二个分支, 而那个分支本身就会把文件删掉。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.factors import router
|
||||
|
||||
FACTOR_ID = "uf_orphan_delete_guard"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(tmp_path):
|
||||
"""磁盘上有一个未注册的自定义因子, 且被一个策略引用。"""
|
||||
factor_dir = tmp_path / "user_data" / "custom_factors"
|
||||
factor_dir.mkdir(parents=True)
|
||||
factor_path = factor_dir / f"{FACTOR_ID}.json"
|
||||
factor_path.write_text(
|
||||
json.dumps({
|
||||
"id": FACTOR_ID,
|
||||
"kind": "custom",
|
||||
"label": "孤儿因子",
|
||||
"formula": "rank(-ts_sum(change_pct, 5))",
|
||||
"version": 1,
|
||||
"status": "draft",
|
||||
}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
strategies_dir = tmp_path / "strategies"
|
||||
strategies_dir.mkdir(parents=True)
|
||||
(strategies_dir / "s1.json").write_text(
|
||||
json.dumps({"id": "s1", "factors": [FACTOR_ID]}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=Path(tmp_path)))
|
||||
return TestClient(app), factor_path
|
||||
|
||||
|
||||
def test_delete_with_references_keeps_definition(env):
|
||||
"""409 拒绝删除时定义必须还在盘上, 否则用户既看不到因子也无法恢复。"""
|
||||
client, factor_path = env
|
||||
|
||||
response = client.delete(f"/api/factors/custom/{FACTOR_ID}")
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"]["references"] == ["strategies/s1.json"]
|
||||
assert factor_path.exists()
|
||||
|
||||
|
||||
def test_delete_with_force_removes_definition(env):
|
||||
"""force=true 仍按原语义强制删除。"""
|
||||
client, factor_path = env
|
||||
|
||||
response = client.delete(f"/api/factors/custom/{FACTOR_ID}?force=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["removed_references"] == ["strategies/s1.json"]
|
||||
assert not factor_path.exists()
|
||||
|
||||
|
||||
def test_delete_missing_factor_returns_404(env):
|
||||
"""不存在的因子仍返回 404。"""
|
||||
client, _ = env
|
||||
|
||||
response = client.delete("/api/factors/custom/uf_not_there_at_all")
|
||||
|
||||
assert response.status_code == 404
|
||||
Reference in New Issue
Block a user