diff --git a/backend/app/api/market_recap.py b/backend/app/api/market_recap.py index 9cc1c65..c36dad0 100644 --- a/backend/app/api/market_recap.py +++ b/backend/app/api/market_recap.py @@ -19,7 +19,7 @@ from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel -from app.services import auction_benchmark, dragon_tiger, market_recap_reports +from app.services import auction_benchmark, dragon_tiger, market_recap_reports, preferences from app.services.market_recap import recap_market_stream logger = logging.getLogger(__name__) @@ -131,8 +131,8 @@ def list_reports(request: Request): @router.post("/reports") -def save_report(request: Request, req: SaveReportRequest): - """保存一条复盘报告。""" +def save_report(request: Request, req: SaveReportRequest, push: bool = False): + """保存一条复盘报告。push=True 或 review_push_mode=auto 时才推送到外部渠道。""" report = market_recap_reports.save_report({ "as_of": req.as_of, "focus": req.focus, @@ -141,13 +141,14 @@ def save_report(request: Request, req: SaveReportRequest): "emotion_score": req.emotion_score, "emotion_label": req.emotion_label, }) - # 推送到飞书(可选): 与定时复盘共用同一开关 review_push_enabled 与 _maybe_push_review。 + # 推送门控: manual 模式需显式 push=True; auto 模式保持归档即推。 # 内部 try/except 静默降级, 不影响归档返回值。 - from app.jobs.daily_pipeline import _maybe_push_review - _maybe_push_review(req.content, { - "as_of": req.as_of, - "emotion_label": req.emotion_label, - }) + if push or preferences.get_review_push_mode() == "auto": + from app.jobs.daily_pipeline import _maybe_push_review + _maybe_push_review(req.content, { + "as_of": req.as_of, + "emotion_label": req.emotion_label, + }) return {"ok": True, "report": report} diff --git a/backend/app/jobs/daily_pipeline.py b/backend/app/jobs/daily_pipeline.py index 41cc7f5..5ed61fd 100644 --- a/backend/app/jobs/daily_pipeline.py +++ b/backend/app/jobs/daily_pipeline.py @@ -944,9 +944,11 @@ async def _run_scheduled_review(repo) -> None: quote_service.push_review_event(json.dumps( {"type": "done", "archived": True}, ensure_ascii=False)) - # 推送到飞书(可选): 运行时读取配置, 用户改设置下次触发即生效。 + # 推送门控: review_push_mode=manual 时定时复盘只归档不推送, + # 由用户对当日报告显式确认后才推; auto 时保持既有自动推送行为。 # 失败静默降级, 不影响已归档的报告。 - _maybe_push_review(content, meta) + if _prefs.get_review_push_mode() == "auto": + _maybe_push_review(content, meta) except Exception as e: # noqa: BLE001 logger.exception("scheduled review failed: %s", e) # 兜底: 异常时通知前端停止「生成中」状态, 避免页面卡在 streaming diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 4ff13f4..45ffdda 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -684,6 +684,26 @@ def set_review_push_channels(channels: list[str]) -> list[str]: return cleaned +REVIEW_PUSH_MODES = frozenset({"auto", "manual"}) + + +def get_review_push_mode() -> str: + """复盘推送触发方式: auto=归档后自动推; manual=仅显式 push。默认 manual。 + + 定时复盘与手动保存复盘共用此开关。manual 时定时路径只归档不推送, + 手动路径需 save_report 显式传 push=True 才推。 + """ + mode = load().get("review_push_mode", "manual") + return mode if mode in REVIEW_PUSH_MODES else "manual" + + +def set_review_push_mode(mode: str) -> str: + """保存复盘推送触发方式, 白名单外的值回退 manual。""" + mode = mode if mode in REVIEW_PUSH_MODES else "manual" + save({"review_push_mode": mode}) + return mode + + # ===== 实时监控 ===== diff --git a/backend/scripts/demo_recap_push_mode.py b/backend/scripts/demo_recap_push_mode.py new file mode 100644 index 0000000..2adda71 --- /dev/null +++ b/backend/scripts/demo_recap_push_mode.py @@ -0,0 +1,30 @@ +"""复盘推送触发方式 review_push_mode 最小 demo — 验证 auto/manual 白名单与默认值。 + +运行方式(在 backend/ 目录下, 已安装依赖): + python -m scripts.demo_recap_push_mode + +隔离到临时偏好文件, 不污染真实偏好。 +""" +from __future__ import annotations + +import tempfile +from pathlib import Path + +from app.services import preferences + + +def main() -> None: + prefs_path = Path(tempfile.mkdtemp()) / "preferences.json" + preferences._path = lambda: prefs_path # noqa: SLF001 - demo 隔离 + preferences._invalidate_cache() + + print("== 复盘推送模式 review_push_mode ==") + print(f" 默认 = {preferences.get_review_push_mode()} (期望 manual)") + print(f" 设为 auto = {preferences.set_review_push_mode('auto')}") + print(f" 非法值回退 = {preferences.set_review_push_mode('bogus')} (期望 manual)") + + print("\n全部通过") + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_review_push_mode.py b/backend/tests/test_review_push_mode.py new file mode 100644 index 0000000..df8cf58 --- /dev/null +++ b/backend/tests/test_review_push_mode.py @@ -0,0 +1,32 @@ +"""复盘推送触发方式 review_push_mode 测试 — auto/manual 白名单与默认值。""" +from __future__ import annotations + +import pytest + +from app.services import preferences + + +@pytest.fixture(autouse=True) +def _isolated(tmp_path, monkeypatch): + path = tmp_path / "preferences.json" + monkeypatch.setattr(preferences, "_path", lambda: path) + preferences._invalidate_cache() + yield path + preferences._invalidate_cache() + + +def test_review_push_mode_defaults_to_manual(): + assert preferences.get_review_push_mode() == "manual" + + +def test_set_and_get_review_push_mode(): + assert preferences.set_review_push_mode("auto") == "auto" + assert preferences.get_review_push_mode() == "auto" + + assert preferences.set_review_push_mode("manual") == "manual" + assert preferences.get_review_push_mode() == "manual" + + +def test_set_review_push_mode_rejects_invalid_value(): + assert preferences.set_review_push_mode("bogus") == "manual" + assert preferences.get_review_push_mode() == "manual"