mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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": "中性"}]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user