diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 2ec0fcd..39c51ea 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -29,6 +29,21 @@ router = APIRouter(prefix="/api/settings", tags=["settings"]) DEFAULT_PAID_ENDPOINT = "https://api.tickflow.org" +def _sync_financial_scheduler_caps(app_state, capset) -> None: + """把重新探测出的能力同步给财务调度器。 + + app.state.capabilities 在此已更新, 但 FinancialScheduler 在启动时捕获的是旧引用, + 需显式刷新, 否则用户升级到 Expert 后点「全部同步」仍会因调度器读旧 capset 而被拒。 + """ + fs = getattr(app_state, "financial_scheduler", None) + if fs is None: + return + try: + fs.update_capabilities(capset) + except Exception as e: # noqa: BLE001 + logging.getLogger(__name__).warning("update financial_scheduler capabilities failed: %s", e) + + class TickflowKeyIn(BaseModel): api_key: str @@ -124,6 +139,7 @@ def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict: # 立即重新探测(此时 client 已按档位判定,但首次探测必然走付费端点验证) capset = detect_capabilities(force=True) request.app.state.capabilities = capset + _sync_financial_scheduler_caps(request.app.state, capset) # ===== 2) 判定为无效 key(连单只日K都拿不到)→ 不存,清除 ===== if is_invalid_key() or base_tier_name() == "none": @@ -132,6 +148,7 @@ def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict: tf_client.reset_clients() capset = detect_capabilities(force=True) request.app.state.capabilities = capset + _sync_financial_scheduler_caps(request.app.state, capset) return { "ok": False, "reason": "invalid", @@ -188,6 +205,7 @@ def clear_tickflow_key(request: Request) -> dict: capset = detect_capabilities(force=True) request.app.state.capabilities = capset + _sync_financial_scheduler_caps(request.app.state, capset) return { "ok": True, diff --git a/backend/app/services/financial_sync.py b/backend/app/services/financial_sync.py index 983b67f..39f4102 100644 --- a/backend/app/services/financial_sync.py +++ b/backend/app/services/financial_sync.py @@ -207,11 +207,15 @@ class FinancialScheduler: 供 /api/financials/sync/* 手动同步使用, 不启动自动调度。 auto_schedule=True: 额外启动每周一次的 metrics 自动同步 (启动后 60s 首跑)。 """ + # 先记录 data_dir/capset, 即使当前无 FINANCIAL 也保留引用: + # 用户稍后在「设置」页升级到 Expert Key 时, update_capabilities() 会把新 capset + # 推进来,trigger()/run_now() 才能用上 FINANCIAL。否则 _capset 永远是 None, + # 即便 app.state.capabilities 已更新, 调度器仍报 "no FINANCIAL capability"。 + self._data_dir = data_dir + self._capset = capset if not capset.has(Cap.FINANCIAL): logger.info("FinancialScheduler skipped: no FINANCIAL capability") return - self._data_dir = data_dir - self._capset = capset # 从持久化恢复上次同步时间: 重启后前端仍能显示真实最后同步时间,而非"尚未同步" try: from app.services import preferences @@ -254,7 +258,24 @@ class FinancialScheduler: from app.services import preferences preferences.set_financial_sync_time(table, ts) except Exception as e: # noqa: BLE001 - logger.warning("persist financial_sync_time(%s) failed: %s", table, e) + logger.warning("persist financial_sync_time(%s) failed: %s", e) + + def update_capabilities(self, capset: CapabilitySet) -> None: + """刷新调度器持有的能力集。 + + 用户在「设置」页新增/清除 API Key 后, settings API 会重新探测能力并更新 + app.state.capabilities; 必须同步推给本调度器, 否则 trigger()/run_now() 仍读 + 启动时的旧 capset, 即便 app.state 已含 FINANCIAL, 调度器仍报 + "no FINANCIAL capability" 而拒绝同步 (表现为前端「全部同步」按钮闪一下无动作)。 + """ + prev = self._capset + self._capset = capset + had = bool(prev) and prev.has(Cap.FINANCIAL) + now = capset.has(Cap.FINANCIAL) + if had != now: + logger.info( + "FinancialScheduler capabilities updated: FINANCIAL %s -> %s", had, now + ) def stop(self) -> None: self._running = False diff --git a/frontend/src/pages/Financials.tsx b/frontend/src/pages/Financials.tsx index c8302bd..70741d9 100644 --- a/frontend/src/pages/Financials.tsx +++ b/frontend/src/pages/Financials.tsx @@ -108,6 +108,11 @@ export function Financials() { if (!r.synced?.started) { if (r.synced?.reason === 'already running') { toast('财务数据正在同步中,请稍候', 'success') + } else if (r.synced?.reason === 'no FINANCIAL capability') { + // 能力未就绪:通常发生在升级/刷新 Key 后调度器状态未同步 —— 提示用户检查 Key + toast('财务数据能力未就绪,请检查 API Key 或刷新页面后重试', 'error') + } else { + toast(`同步未能开始${r.synced?.reason ? `:${r.synced.reason}` : ''}`, 'error') } setSyncStartedAt(null) setSyncSingleTable(null)