diff --git a/backend/app/api/market_recap.py b/backend/app/api/market_recap.py index c36dad0..f3c6c2e 100644 --- a/backend/app/api/market_recap.py +++ b/backend/app/api/market_recap.py @@ -122,6 +122,7 @@ class SaveReportRequest(BaseModel): summary: str = "" emotion_score: int | None = None emotion_label: str = "" + push: bool = False # 是否显式外发推送(manual 模式下需显式传 true) @router.get("/reports") @@ -131,8 +132,8 @@ def list_reports(request: Request): @router.post("/reports") -def save_report(request: Request, req: SaveReportRequest, push: bool = False): - """保存一条复盘报告。push=True 或 review_push_mode=auto 时才推送到外部渠道。""" +def save_report(request: Request, req: SaveReportRequest): + """保存一条复盘报告。req.push=True 或 review_push_mode=auto 时才推送到外部渠道。""" report = market_recap_reports.save_report({ "as_of": req.as_of, "focus": req.focus, @@ -143,7 +144,7 @@ def save_report(request: Request, req: SaveReportRequest, push: bool = False): }) # 推送门控: manual 模式需显式 push=True; auto 模式保持归档即推。 # 内部 try/except 静默降级, 不影响归档返回值。 - if push or preferences.get_review_push_mode() == "auto": + if req.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, diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index ab88012..7f31977 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -557,6 +557,7 @@ def get_preferences() -> dict: "depth_finalize_time": preferences.get_depth_finalize_time(), "review_schedule": preferences.get_review_schedule(), "review_push_channels": preferences.get_review_push_channels(), + "review_push_mode": preferences.get_review_push_mode(), **preferences.get_mining_schedule(), } @@ -1908,16 +1909,23 @@ def update_review_schedule(req: ReviewScheduleIn, request: Request) -> dict: class ReviewPushIn(BaseModel): channels: list[str] # 多选: feishu / wecom / custom / email; 空数组=不推送 + mode: str | None = None # 可选: auto=归档即推 / manual=仅显式 push; 不传则不变 @router.put("/preferences/review-push") def update_review_push(req: ReviewPushIn) -> dict: - """复盘推送渠道(多选) — 选定把复盘报告(手动生成 / 定时生成归档后)推送到哪些外部工具。 + """复盘推送设置(渠道多选 + 触发方式)。 纯偏好, 与定时复盘 / 实时行情完全独立, 常驻可单独设置。空数组=不推送。 实际推送由归档端点(POST /api/market-recap/reports)与定时任务(_run_scheduled_review) - 在归档后读取本列表逐个推送。白名单外的渠道会被过滤掉。 + 在归档后读取渠道列表, 并按 review_push_mode 决定是否外发: + - manual: 定时复盘只归档不推送, 手动保存需显式 push=true + - auto: 归档即推(行为与旧逻辑一致) + 白名单外的渠道会被过滤掉, 白名单外的 mode 值回退 manual。 """ from app.services import preferences saved = preferences.set_review_push_channels(req.channels) - return {"review_push_channels": saved} + mode = preferences.get_review_push_mode() + if req.mode is not None: + mode = preferences.set_review_push_mode(req.mode) + return {"review_push_channels": saved, "review_push_mode": mode} diff --git a/backend/tests/test_review_push_mode.py b/backend/tests/test_review_push_mode.py index df8cf58..1a9a402 100644 --- a/backend/tests/test_review_push_mode.py +++ b/backend/tests/test_review_push_mode.py @@ -1,6 +1,8 @@ -"""复盘推送触发方式 review_push_mode 测试 — auto/manual 白名单与默认值。""" +"""复盘推送触发方式 review_push_mode 测试 — auto/manual 白名单与默认值 + 推送门控。""" from __future__ import annotations +import asyncio + import pytest from app.services import preferences @@ -30,3 +32,104 @@ def test_set_and_get_review_push_mode(): def test_set_review_push_mode_rejects_invalid_value(): assert preferences.set_review_push_mode("bogus") == "manual" assert preferences.get_review_push_mode() == "manual" + + +# ── 推送门控 ──────────────────────────────────────────────────────── +# 门控语义: +# manual: 定时复盘只归档不推送; 手动保存需显式 push=True 才推 +# auto: 归档即推(与旧逻辑一致) + +def test_save_report_manual_requires_explicit_push(monkeypatch): + from app.api import market_recap + from app.jobs import daily_pipeline + + pushed: list[dict] = [] + monkeypatch.setattr( + "app.services.market_recap_reports.save_report", + lambda d: {"id": "r1"}, + ) + monkeypatch.setattr( + daily_pipeline, + "_maybe_push_review", + lambda content, meta: pushed.append(meta), + ) + preferences.set_review_push_mode("manual") + + # 默认 push=False: manual 模式下只归档, 不外发 + market_recap.save_report(None, market_recap.SaveReportRequest(as_of="2026-07-18", content="正文")) + assert pushed == [] + + # 显式 push=True: manual 模式下外发 + market_recap.save_report(None, market_recap.SaveReportRequest(as_of="2026-07-18", content="正文", push=True)) + assert pushed == [{"as_of": "2026-07-18", "emotion_label": ""}] + + +def test_save_report_auto_pushes_without_flag(monkeypatch): + from app.api import market_recap + from app.jobs import daily_pipeline + + pushed: list[dict] = [] + monkeypatch.setattr( + "app.services.market_recap_reports.save_report", + lambda d: {"id": "r1"}, + ) + monkeypatch.setattr( + daily_pipeline, + "_maybe_push_review", + lambda content, meta: pushed.append(meta), + ) + preferences.set_review_push_mode("auto") + + # auto 模式: 无需 push 标志即外发 + market_recap.save_report(None, market_recap.SaveReportRequest(as_of="2026-07-18", content="正文")) + assert pushed == [{"as_of": "2026-07-18", "emotion_label": ""}] + + +def _patch_scheduled_review(monkeypatch, pushed: list, archived: list): + """装配定时复盘的依赖: 有 AI key、流式产出固定内容、捕获归档与推送调用。""" + from app.jobs import daily_pipeline + + async def _fake_stream(*a, **k): + return "正文", {"as_of": "2026-07-18", "emotion_label": "中性"} + + monkeypatch.setattr("app.secrets_store.get_ai_key", lambda: "sk-test") + monkeypatch.setattr(daily_pipeline, "_stream_review_with_retry", _fake_stream) + monkeypatch.setattr( + "app.services.market_recap_reports.save_report", + lambda d: archived.append(d) or {"id": "r1"}, + ) + monkeypatch.setattr( + daily_pipeline, + "_maybe_push_review", + lambda content, meta: pushed.append(meta), + ) + + +def test_scheduled_review_manual_archives_without_push(monkeypatch): + from app.jobs import daily_pipeline + + pushed: list = [] + archived: list = [] + _patch_scheduled_review(monkeypatch, pushed, archived) + preferences.set_review_push_mode("manual") + + asyncio.run(daily_pipeline._run_scheduled_review(None)) + + # manual 模式: 归档发生, 但不外发 + assert len(archived) == 1 + assert pushed == [] + + +def test_scheduled_review_auto_pushes(monkeypatch): + from app.jobs import daily_pipeline + + pushed: list = [] + archived: list = [] + _patch_scheduled_review(monkeypatch, pushed, archived) + preferences.set_review_push_mode("auto") + + asyncio.run(daily_pipeline._run_scheduled_review(None)) + + # auto 模式: 归档并外发 + assert len(archived) == 1 + assert pushed == [{"as_of": "2026-07-18", "emotion_label": "中性"}] diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a0f2938..4eef6ab 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1769,6 +1769,7 @@ export interface Preferences { depth_finalize_time: { hour: number; minute: number } review_schedule: { enabled: boolean; hour: number; minute: number } review_push_channels: string[] + review_push_mode?: 'auto' | 'manual' sse_refresh_pages: Record strategy_monitor_enabled: boolean strategy_monitor_ids: string[] @@ -2148,10 +2149,10 @@ export const api = { method: 'PUT', body: JSON.stringify({ enabled, hour, minute }), }), - updateReviewPush: (channels: string[]) => - request<{ review_push_channels: string[] }>('/api/settings/preferences/review-push', { + updateReviewPush: (channels: string[], mode?: 'auto' | 'manual') => + request<{ review_push_channels: string[]; review_push_mode: 'auto' | 'manual' }>('/api/settings/preferences/review-push', { method: 'PUT', - body: JSON.stringify({ channels }), + body: JSON.stringify({ channels, mode: mode ?? null }), }), updateDepthPollingInterval: (interval: number) => request<{ depth_polling_interval: number }>('/api/settings/preferences/depth-polling-interval', { @@ -3155,6 +3156,7 @@ export const api = { reviewReportSave: (r: { as_of: string; focus?: string; content: string summary?: string; emotion_score?: number | null; emotion_label?: string + push?: boolean }) => request<{ ok: boolean; report: AiReviewReport }>('/api/market-recap/reports', { method: 'POST', body: JSON.stringify(r), diff --git a/frontend/src/pages/Review.tsx b/frontend/src/pages/Review.tsx index 518b80c..b428a22 100644 --- a/frontend/src/pages/Review.tsx +++ b/frontend/src/pages/Review.tsx @@ -118,6 +118,8 @@ export function Review() { // 推送渠道是独立的顶层偏好(多选), 与定时 / 实时行情无关, 常驻可单独设置 // []=不推送; 可多选飞书、企微、第三方 Webhook 和邮件。 const reviewPushChannels = prefs.data?.review_push_channels ?? [] + // 推送触发方式: auto=归档即推; manual=仅归档不自动外发。默认 manual。 + const reviewPushMode = prefs.data?.review_push_mode ?? 'manual' // 弹窗内的本地草稿: 开关和时间都在本地改, 点「保存」才真正提交(避免开关一拨就关弹窗) const [draft, setDraft] = useState(reviewSched) const openSchedule = useCallback(() => { @@ -149,6 +151,15 @@ export function Review() { : [...reviewPushChannels, ch] pushMut.mutate(next) }, [reviewPushChannels, pushMut]) + // 推送触发方式(独立常驻): auto/manual 即时生效, 与渠道切换一致 + const pushModeMut = useMutation({ + mutationFn: (mode: 'auto' | 'manual') => api.updateReviewPush(reviewPushChannels, mode), + onSuccess: () => { + qc.invalidateQueries({ queryKey: QK.preferences }) + toast('已更新推送触发方式', 'success') + }, + onError: () => { /* request() 已 toast */ }, + }) // 自动滚动到报告底部(streaming 时) useEffect(() => { @@ -523,8 +534,41 @@ export function Review() { + + {/* 推送触发方式: auto=归档即推 / manual=仅归档不自动外发 */} +
+ 推送触发方式 +
+ + +
+
+

- 手动或定时生成的复盘都会推送完整报告。复用「设置 → 实时监控」的渠道配置。 + {reviewPushMode === 'auto' + ? '定时与手动生成的复盘归档后都会自动推送完整报告。' + : '复盘仅归档保存,不自动外发。'} + 复用「设置 → 实时监控」的渠道配置。 {( (reviewPushChannels.includes('feishu') && !feishuConfigured) || (reviewPushChannels.includes('wecom') && !wecomConfigured)