feat(preferences): review_push_mode 端点与前端开关配套

- settings: GET 返回 review_push_mode, PUT review-push 支持 mode 入参
- market_recap: push 移入 SaveReportRequest body(显式外发)
- Review 页: 推送触发方式 auto/manual 开关
- 门控测试: 手动/定时两条路径的 manual/auto 推送断言
This commit is contained in:
sc
2026-09-06 16:20:42 +08:00
parent 905fb8846c
commit 9d55c237ed
5 changed files with 169 additions and 11 deletions
+4 -3
View File
@@ -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,
+11 -3
View File
@@ -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}
+104 -1
View File
@@ -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": "中性"}]
+5 -3
View File
@@ -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<string, boolean>
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),
+45 -1
View File
@@ -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() {
</span>
</button>
</div>
{/* 推送触发方式: auto=归档即推 / manual=仅归档不自动外发 */}
<div className="mt-2 flex items-center justify-between">
<span className="text-[11px] text-foreground"></span>
<div className="flex items-center gap-0.5 rounded-btn bg-base p-0.5">
<button
type="button"
disabled={pushModeMut.isPending}
onClick={() => pushModeMut.mutate('auto')}
className={cn(
'rounded-btn px-2 py-0.5 text-[10px] transition-colors disabled:opacity-50',
reviewPushMode === 'auto' ? 'bg-accent text-white' : 'text-muted hover:text-secondary',
)}
>
</button>
<button
type="button"
disabled={pushModeMut.isPending}
onClick={() => pushModeMut.mutate('manual')}
className={cn(
'rounded-btn px-2 py-0.5 text-[10px] transition-colors disabled:opacity-50',
reviewPushMode === 'manual' ? 'bg-accent text-white' : 'text-muted hover:text-secondary',
)}
>
</button>
</div>
</div>
<p className="mt-1.5 text-[10px] leading-relaxed text-muted/70">
{reviewPushMode === 'auto'
? '定时与手动生成的复盘归档后都会自动推送完整报告。'
: '复盘仅归档保存,不自动外发。'}
{(
(reviewPushChannels.includes('feishu') && !feishuConfigured)
|| (reviewPushChannels.includes('wecom') && !wecomConfigured)