diff --git a/README.md b/README.md index 1c82443..9d8e09b 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ deploy: ## 数据库迁移(版本化,升级无需重写数据) -数据库内置 `meta` 表记录 **schema 版本**(程序内 `DB_VERSION`,当前为 **2**)。每次启动时比对: +数据库内置 `meta` 表记录 **schema 版本**(程序内 `DB_VERSION`,当前为 **3**)。每次启动时比对: | 情况 | 行为 | |------|------| diff --git a/backend/events.py b/backend/events.py index 15154cd..3330402 100644 --- a/backend/events.py +++ b/backend/events.py @@ -2,14 +2,29 @@ Events are scoped by user_id so that each user's display only receives updates about their own data (alarms, calendar, pomodoro, ...). + +Cross-process broadcast: +The app runs under multiple Gunicorn workers; each worker has its own +in-process EventBus AND its own SSE subscribers. A publish() on worker A +would never reach an SSE connection held by worker B. To fix this, +publish() additionally sends the event over a PostgreSQL LISTEN/NOTIFY +channel; every worker runs a listener thread that re-dispatches remote +events to its local subscribers. Oversized payloads (rare, > ~7KB) are +delivered as a ``db_changed`` signal so the display re-fetches full state. """ import json +import os import queue import threading +import psycopg2 + +_NOTIFY_CHANNEL = "iot_events" +_NOTIFY_MAX_LEN = 7000 # stay well under PostgreSQL's 8KB NOTIFY payload limit + class EventBus: - """Simple pub/sub event system with SSE support. + """Simple pub/sub event system with SSE + cross-process broadcast support. publish(event_type, data, user_id=None): when user_id is given, only subscribers registered for that user receive the event. @@ -18,6 +33,27 @@ class EventBus: def __init__(self): self._subscribers: list[tuple[int | None, queue.Queue]] = [] self._lock = threading.Lock() + self._notify_conn = None + self._notify_lock = threading.Lock() + self._listener = None + + def attach(self, db_url: str): + """Enable cross-process broadcast for THIS process: opens a NOTIFY + connection and starts a LISTEN thread. Call once per worker (start.py + / run.py). Without attach, publish() still works locally (tests).""" + if self._notify_conn is not None: + return + try: + self._notify_conn = psycopg2.connect(db_url, connect_timeout=5) + self._notify_conn.autocommit = True + except Exception: + self._notify_conn = None + return + self._listener = threading.Thread( + target=self._listen_loop, args=(db_url,), daemon=True, + name="eventbus-listener", + ) + self._listener.start() def subscribe(self, user_id: int | None = None) -> queue.Queue: """Subscribe to events. user_id=None receives ALL events (broadcast).""" @@ -31,6 +67,14 @@ class EventBus: self._subscribers = [(uid, sq) for uid, sq in self._subscribers if sq is not q] def publish(self, event_type: str, data: dict, user_id: int | None = None): + """Deliver locally AND broadcast to other workers (best-effort). + The NOTIFY carries this process's PID so our own listener thread can + skip the echo (each subscriber must receive an event exactly once).""" + self._dispatch(event_type, data, user_id) + self._broadcast(event_type, data, user_id) + + # ─── local delivery ─────────────────────── + def _dispatch(self, event_type: str, data: dict, user_id: int | None = None): msg = json.dumps({"type": event_type, "data": data}) with self._lock: dead = [] @@ -44,3 +88,77 @@ class EventBus: for item in dead: self._subscribers.remove(item) + # ─── cross-process broadcast ────────────── + def _broadcast(self, event_type: str, data: dict, user_id: int | None): + if self._notify_conn is None: + return + try: + payload = json.dumps( + {"pid": os.getpid(), "type": event_type, "data": data, + "user_id": user_id}, + default=str, + ) + if len(payload) > _NOTIFY_MAX_LEN: + # Too big for NOTIFY: receivers re-fetch full state from the DB. + payload = json.dumps( + {"pid": os.getpid(), "type": event_type, + "oversized": True, "user_id": user_id} + ) + with self._notify_lock: + cur = self._notify_conn.cursor() + cur.execute("SELECT pg_notify(%s, %s)", (_NOTIFY_CHANNEL, payload)) + self._notify_conn.commit() + cur.close() + except Exception: + # Broadcast is best-effort (local delivery already happened). + # Drop the dead connection so the next publish reconnects once. + try: + self._notify_conn.close() + except Exception: + pass + self._notify_conn = None + + def handle_remote(self, payload: str): + """Listener-thread entry: re-dispatch a remote event locally (no NOTIFY). + Events broadcast by THIS process are skipped (already delivered locally).""" + try: + msg = json.loads(payload) + if msg.get("pid") == os.getpid(): + return # echo of our own broadcast + if msg.get("oversized"): + self._dispatch("db_changed", {}, msg.get("user_id")) + else: + self._dispatch( + msg.get("type", "db_changed"), + msg.get("data") or {}, + msg.get("user_id"), + ) + except Exception: + pass + + def _listen_loop(self, db_url: str): + """Blocking LISTEN loop with reconnect on DB failure.""" + import select + import time + while True: + conn = None + try: + conn = psycopg2.connect(db_url, connect_timeout=5) + conn.autocommit = True + cur = conn.cursor() + cur.execute(f"LISTEN {_NOTIFY_CHANNEL}") + cur.close() + while True: + if select.select([conn], [], [], 1.0)[0]: + conn.poll() + while conn.notifies: + n = conn.notifies.pop(0) + self.handle_remote(n.payload) + except Exception: + time.sleep(2) # DB temporarily unavailable: retry + finally: + if conn is not None: + try: + conn.close() + except Exception: + pass diff --git a/backend/models.py b/backend/models.py index e025ba1..65cc84a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -218,6 +218,18 @@ CREATE TABLE IF NOT EXISTS pomodoro_state ( ); CREATE UNIQUE INDEX IF NOT EXISTS idx_pomodoro_state_user ON pomodoro_state(user_id); + +-- WebAuthn challenge store: one-time, short-TTL challenges shared across +-- Gunicorn workers via PostgreSQL (in-memory stores were per-process and +-- caused 'challenge was not expected' failures under multiple workers). +CREATE TABLE IF NOT EXISTS webauthn_challenges ( + id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + key TEXT NOT NULL, + challenge TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_challenges_key ON webauthn_challenges(key); """ @@ -825,14 +837,58 @@ def find_user_by_credential_id(credential_id: str, db_path: str = None) -> int | return row["user_id"] if row else None +# ─── WebAuthn challenge store ──────────────── +# Challenges are persisted in PostgreSQL so registration/login ceremonies work +# across Gunicorn workers (in-memory stores were per-process). Consumed +# atomically (DELETE ... RETURNING) and pruned by TTL. + +WEBAUTHN_CHALLENGE_TTL = 300 # 5 minutes + + +def save_webauthn_challenge(key: str, challenge: str, + ttl: int = WEBAUTHN_CHALLENGE_TTL, + db_path: str = None): + """Store a challenge (base64url string) under `key`, replacing any previous.""" + conn = get_db(_db_path(db_path)) + try: + conn.execute("DELETE FROM webauthn_challenges WHERE expires_at < ?", (int(time.time()),)) + now = int(time.time()) + conn.execute( + "INSERT INTO webauthn_challenges (key, challenge, created_at, expires_at) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT (key) DO UPDATE SET challenge=excluded.challenge, " + "created_at=excluded.created_at, expires_at=excluded.expires_at", + (key, challenge, now, now + ttl) + ) + conn.commit() + finally: + conn.close() + + +def pop_webauthn_challenge(key: str, db_path: str = None) -> str | None: + """Atomically consume and return the challenge for `key` (None when absent/expired).""" + conn = get_db(_db_path(db_path)) + try: + conn.execute("DELETE FROM webauthn_challenges WHERE expires_at < ?", (int(time.time()),)) + row = conn.execute( + "DELETE FROM webauthn_challenges WHERE key=? AND expires_at >= ? RETURNING challenge", + (key, int(time.time())) + ).fetchone() + conn.commit() + return row["challenge"] if row else None + finally: + conn.close() + + # ─── DB init / migration ───────────────────── # Stores passwords that were just generated, for the startup script to print. _EMITTED_PASSWORDS: list = [] # Current schema version supported by this program. Bump it whenever the -# SCHEMA changes so existing databases get upgraded on next start. -DB_VERSION = 2 +# SCHEMA changes (new table, column, index, constraint, ...) so existing +# databases get upgraded on next start. Rule: ANY database change ⇒ bump. +DB_VERSION = 3 def _ensure_defaults(conn): @@ -1103,7 +1159,11 @@ def init_db(db_path: str = "data.db"): ) if old_version is not None and old_version == DB_VERSION: - # Same version — no upgrade needed. Just ensure defaults exist. + # Same version — no upgrade needed. Run the idempotent DDL anyway so + # any newly-added tables (e.g. webauthn_challenges) exist on + # existing databases without bumping the schema version. + conn.executescript(SCHEMA) + conn.commit() _ensure_defaults(conn) _fix_timestamp_defaults(conn) conn.commit() diff --git a/backend/routes.py b/backend/routes.py index 33ffdd8..7dd80a0 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -323,7 +323,10 @@ def api_reauth_webauthn_begin(): if not stored_id: return jsonify({"error": "未注册 Passkey"}), 400 try: - options = begin_authentication(request, allow_credential_ids=[stored_id]) + options = begin_authentication( + request, allow_credential_ids=[stored_id], + challenge_key=f"reauth_{_cur()}", + ) return jsonify(options) except Exception as e: return jsonify({"error": str(e)}), 500 @@ -347,6 +350,7 @@ def api_reauth_webauthn_complete(): request, credential, stored_credential_id=stored_id, stored_public_key=stored_key, + challenge_key=f"reauth_{_cur()}", ) if result.get("credential_id") != stored_id: return jsonify({"error": "Passkey 凭证不匹配"}), 401 @@ -1644,6 +1648,8 @@ def set_time_config(): if not tz_str: return jsonify({"error": "timezone is required"}), 400 if set_tz(tz_str): + # 广播给该用户的显示端,立即刷新(跨 worker 由 EventBus 远程广播保证) + current_app.event_bus.publish("timezone_changed", {"timezone": tz_str}, user_id=_cur()) return jsonify({"ok": True}) return jsonify({"error": f"时区 '{tz_str}' 无效,请使用 IANA 名称(如 Asia/Shanghai)或 UTC+X 格式"}), 400 diff --git a/backend/time_util.py b/backend/time_util.py index e1a42f7..7f904b9 100644 --- a/backend/time_util.py +++ b/backend/time_util.py @@ -26,11 +26,16 @@ _TLS = threading.local() def set_thread_user(user_id): """Set the current user for this thread (scheduler per-user iteration).""" _TLS.user_id = user_id + try: + del _TLS.tz + except AttributeError: + pass def clear_thread_user(): try: del _TLS.user_id + del _TLS.tz except AttributeError: pass @@ -78,30 +83,45 @@ def _load_tz() -> "pytz.BaseTzInfo": def get_tz() -> "pytz.BaseTzInfo": - """Return the configured timezone object. Cached per user in app config.""" + """Return the configured timezone object. + + Cached per-request (Flask ``g``) and per-thread (scheduler TLS), NOT per + process: a timezone change made on one Gunicorn worker must be visible to + the next request on ANY worker immediately (a process-wide cache would + serve stale timezones to other workers). + """ try: - from flask import current_app - uid = _ctx_user_id() - if hasattr(current_app, "config") and "_timezone_cache" in current_app.config: - cache = current_app.config["_timezone_cache"] - if uid in cache: - return cache[uid] - tz = _load_tz() - if hasattr(current_app, "config"): - cache = current_app.config.setdefault("_timezone_cache", {}) - cache[uid] = tz - return tz + from flask import has_request_context, g + if has_request_context(): + cached = getattr(g, "_tz_cache", None) + if cached is not None: + return cached + tz = _load_tz() + g._tz_cache = tz + return tz except Exception: - return pytz.timezone(_DEFAULT_TZ_STR) + pass + # No request context (scheduler threads): cache per thread-user so each + # user's jobs use their own timezone without a DB hit per call. + tz = getattr(_TLS, "tz", None) + if tz is not None: + return tz + tz = _load_tz() + _TLS.tz = tz + return tz def clear_tz_cache(): - """Force reload timezone on next call. Call after timezone config changes.""" + """Force reload timezone on next call (called after timezone config changes).""" try: - from flask import current_app - current_app.config.pop("_timezone_cache", None) + from flask import g + g.__dict__.pop("_tz_cache", None) except Exception: pass + try: + del _TLS.tz + except AttributeError: + pass def set_tz(tz_str: str) -> bool: diff --git a/backend/webauthn_mgr.py b/backend/webauthn_mgr.py index 87e2794..9c3b91c 100644 --- a/backend/webauthn_mgr.py +++ b/backend/webauthn_mgr.py @@ -13,7 +13,7 @@ Uses the `webauthn` library (v2.x/v3.x). Requires HTTPS (or localhost) to function. """ from dataclasses import asdict -from webauthn.helpers import bytes_to_base64url +from webauthn.helpers import bytes_to_base64url, base64url_to_bytes from webauthn import ( generate_registration_options, verify_registration_response, @@ -64,9 +64,20 @@ RP_NAME = "Cal Display" RP_ID = None # set dynamically from request.host ORIGIN = None # set dynamically -# In-memory challenge stores -_CHALLENGES: dict = {} # "login" → challenge -_REGISTRATION_CHALLENGES: dict = {} # "reg_" → challenge +# Challenges are persisted in PostgreSQL (backend.models.webauthn_challenges) +# so a ceremony started on one Gunicorn worker can be completed on another. +# Keys: "reg_" for registration, "login" for login, +# "reauth_" for re-authentication. +from backend.models import save_webauthn_challenge, pop_webauthn_challenge # noqa: E402 + + +def _challenge_db() -> str | None: + """Resolve the current DB connection string (app config, like the managers).""" + try: + from flask import current_app + return current_app.config.get("DB_PATH") + except Exception: + return None def _get_rp_id(request) -> str: @@ -133,8 +144,11 @@ def begin_registration(request, user_id: int, display_name: str = "", exclude_credentials=exclude_credentials, ) - # Store challenge for verification - _REGISTRATION_CHALLENGES[f"reg_{user_id}"] = options.challenge + # Store challenge for verification (DB-backed, cross-worker safe, 5 min TTL) + save_webauthn_challenge( + f"reg_{user_id}", bytes_to_base64url(options.challenge), + db_path=_challenge_db(), + ) return _serialize_options(options) @@ -150,8 +164,7 @@ def complete_registration(request, credential: dict) -> dict: user_name = credential.get("user_name", "") # Derive the target user id from the registration challenge key. - challenge_key = f"reg_{user_name}" - expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None) + expected_challenge = pop_webauthn_challenge(f"reg_{user_name}", db_path=_challenge_db()) if not expected_challenge: # Self-registration fallback: the browser may not echo the user handle, # so resolve the target from the authenticated request context. @@ -159,8 +172,7 @@ def complete_registration(request, credential: dict) -> dict: from backend.auth import current_user_id uid = current_user_id() if uid is not None: - challenge_key = f"reg_{uid}" - expected_challenge = _REGISTRATION_CHALLENGES.pop(challenge_key, None) + expected_challenge = pop_webauthn_challenge(f"reg_{uid}", db_path=_challenge_db()) user_name = f"user_{uid}" except Exception: pass @@ -175,7 +187,7 @@ def complete_registration(request, credential: dict) -> dict: verification = verify_registration_response( credential=credential, - expected_challenge=expected_challenge, + expected_challenge=base64url_to_bytes(expected_challenge), expected_rp_id=rp_id, expected_origin=origin, ) @@ -192,11 +204,15 @@ def complete_registration(request, credential: dict) -> dict: # ─── Authentication (Login / Re-auth) ──────── -def begin_authentication(request, allow_credential_ids: list = None) -> dict: +def begin_authentication(request, allow_credential_ids: list = None, + challenge_key: str = "login") -> dict: """Start WebAuthn authentication. Returns options dict for the frontend. allow_credential_ids: optional list of hex credential ids to restrict the ceremony to (used for re-authentication of the current user). + challenge_key: DB key under which the challenge is stored; pass a + user-scoped key (e.g. "reauth_") for re-authentication ceremonies so + concurrent logins/reauths never clobber each other. """ rp_id = _get_rp_id(request) @@ -213,20 +229,22 @@ def begin_authentication(request, allow_credential_ids: list = None) -> dict: allow_credentials=allow_credentials, ) - # Store challenge for verification - _CHALLENGES["login"] = options.challenge + # Store challenge for verification (DB-backed, cross-worker safe) + save_webauthn_challenge(challenge_key, bytes_to_base64url(options.challenge), + db_path=_challenge_db()) return _serialize_options(options) -def complete_authentication(request, credential: dict, stored_credential_id: str, stored_public_key: str) -> dict: +def complete_authentication(request, credential: dict, stored_credential_id: str, + stored_public_key: str, challenge_key: str = "login") -> dict: """Complete WebAuthn authentication. Returns {'ok': True, 'credential_id': str} or raises on failure. """ rp_id = _get_rp_id(request) origin = _get_origin(request) - expected_challenge = _CHALLENGES.pop("login", None) + expected_challenge = pop_webauthn_challenge(challenge_key, db_path=_challenge_db()) if not expected_challenge: raise ValueError("No authentication challenge found. Please start again.") @@ -237,7 +255,7 @@ def complete_authentication(request, credential: dict, stored_credential_id: str verification = verify_authentication_response( credential=credential, - expected_challenge=expected_challenge, + expected_challenge=base64url_to_bytes(expected_challenge), expected_rp_id=rp_id, expected_origin=origin, credential_public_key=bytes.fromhex(stored_public_key), diff --git a/frontend/static/js/display.js b/frontend/static/js/display.js index a94096b..a96dc38 100644 --- a/frontend/static/js/display.js +++ b/frontend/static/js/display.js @@ -288,6 +288,12 @@ showAlarmOverlay(msg.data); // reuse alarm overlay UI switchPanel('countdown'); } + } else if (msg.type === 'timezone_changed') { + // 控制端改了账户时区 → 立即重拉状态(state.timezone 更新后时钟/闹钟兜底随之生效) + fetchState(); + } else if (msg.type === 'db_changed') { + // 超大事件(NOTIFY 放不下)→ 从数据库重拉完整状态 + fetchState(); } else if (msg.type === 'display_mode_changed' || msg.type === 'force_refresh') { location.reload(); } else if (msg.type === 'theme_changed') { diff --git a/run.py b/run.py index 0e2c688..970e15e 100644 --- a/run.py +++ b/run.py @@ -25,6 +25,9 @@ if __name__ == "__main__": if try_acquire_scheduler_lock(db_url): start_scheduler(app) + # 跨进程 SSE 广播(PG LISTEN/NOTIFY) + app.event_bus.attach(db_url) + try: bind_host = os.environ.get("FLASK_BIND", "127.0.0.1") app.run(host=bind_host, port=int(os.environ.get("FLASK_PORT", "5001")), diff --git a/start.py b/start.py index 243eaa8..1801225 100644 --- a/start.py +++ b/start.py @@ -62,6 +62,9 @@ _maybe_start_scheduler() if os.environ.get("FLASK_DISABLE_SCHEDULER", "").lower() not in ("1", "true", "yes"): start_watchdog(app, db_url) +# 跨 worker SSE 广播(PG LISTEN/NOTIFY):控制端的事件能到达任意 worker 的显示端 +app.event_bus.attach(db_url) + if __name__ == "__main__": import atexit diff --git a/tests/test_multi_user.py b/tests/test_multi_user.py index 62173e0..a58bfda 100644 --- a/tests/test_multi_user.py +++ b/tests/test_multi_user.py @@ -22,6 +22,7 @@ import shutil import sqlite3 import sys import tempfile +import threading import time import urllib.parse @@ -658,7 +659,7 @@ routes_mod.complete_registration = lambda request, credential: { "ok": True, "user_id": int(credential.get("user_name", "user_0").split("_")[-1]), "credential_id": CRED_HEX, "public_key": "abcdef", } -routes_mod.begin_authentication = lambda request, allow_credential_ids=None: {"challenge": "BB"} +routes_mod.begin_authentication = lambda request, allow_credential_ids=None, challenge_key="login": {"challenge": "BB"} def _cred_hex(credential): import base64 cid = credential.get("id", "") @@ -667,7 +668,7 @@ def _cred_hex(credential): return base64.urlsafe_b64decode(padded.encode("ascii")).hex() except Exception: return "unknown" -routes_mod.complete_authentication = lambda request, credential, stored_credential_id=None, stored_public_key="": { +routes_mod.complete_authentication = lambda request, credential, stored_credential_id=None, stored_public_key="", challenge_key="login": { "ok": True, "credential_id": _cred_hex(credential), } @@ -1073,6 +1074,90 @@ n = conn.execute("SELECT COUNT(*) FROM calendar_groups WHERE name='OK Group'").f conn.close() check("失败导入整体回滚(原子性)", n == 0) +print() +print("=" * 60) +print("TEST 21: WebAuthn challenge DB 存储(一次性/覆盖/并发原子)") +print("=" * 60) +from backend.models import save_webauthn_challenge, pop_webauthn_challenge +save_webauthn_challenge("reg_0", "ch1", db_path=db21) +check("challenge 一次性消费成功", pop_webauthn_challenge("reg_0", db_path=db21) == "ch1") +check("challenge 二次消费为 None", pop_webauthn_challenge("reg_0", db_path=db21) is None) +save_webauthn_challenge("reg_0", "a", db_path=db21) +save_webauthn_challenge("reg_0", "b", db_path=db21) +check("challenge 覆盖为最新值", pop_webauthn_challenge("reg_0", db_path=db21) == "b") +save_webauthn_challenge("race", "tok", db_path=db21) +race_results = [] +race_lock = threading.Lock() +def _consume(): + r = pop_webauthn_challenge("race", db_path=db21) + with race_lock: + race_results.append(r) +ths = [threading.Thread(target=_consume) for _ in range(8)] +[t.start() for t in ths] +[t.join() for t in ths] +check("并发消费仅 1 次成功(DELETE RETURNING 原子)", + race_results.count("tok") == 1 and race_results.count(None) == 7) + +print() +print("=" * 60) +print("TEST 22: EventBus 跨进程去重 + 时区即时更新") +print("=" * 60) +from backend.events import EventBus +import os as _os + +bus = EventBus() +q = bus.subscribe(user_id=0) +bus.publish("theme_changed", {"mode": "light"}, user_id=0) +n_local = 0 +try: + while True: + q.get(timeout=0.3) + n_local += 1 +except Exception: + pass +check("本地 publish 恰好投递 1 次", n_local == 1) +# 模拟本进程 NOTIFY echo(pid 相同)→ 必须被去重 +echo_payload = json.dumps({"pid": _os.getpid(), "type": "theme_changed", + "data": {"mode": "light"}, "user_id": 0}) +bus.handle_remote(echo_payload) +try: + q.get(timeout=0.3) + check("本进程 echo 被去重", False) +except Exception: + check("本进程 echo 被去重", True) +# 模拟其他 worker 的事件 → 应分发 +remote_payload = json.dumps({"pid": _os.getpid() + 1, "type": "force_refresh", + "data": {}, "user_id": 0}) +bus.handle_remote(remote_payload) +try: + ev = q.get(timeout=0.3) + check("远端事件正确分发", json.loads(ev)["type"] == "force_refresh") +except Exception: + check("远端事件正确分发", False) +# oversized → db_changed 信号 +oversized_payload = json.dumps({"pid": _os.getpid() + 2, "type": "custom_notification", + "oversized": True, "user_id": 0}) +bus.handle_remote(oversized_payload) +try: + ev = q.get(timeout=0.3) + check("oversized → db_changed 信号", json.loads(ev)["type"] == "db_changed") +except Exception: + check("oversized → db_changed 信号", False) + +# 时区即时更新(多 worker 场景:新请求立即读到新时区,无进程级缓存) +app22, db22 = fresh_app("t22") +pw22 = get_temp_password(db22, 0) +c22, _ = do_login(app22.test_client(), pw22) +setup_totp(c22) +r = c22.get("/api/time/config") +check("默认时区 Asia/Shanghai", r.get_json()["timezone"] == "Asia/Shanghai") +r = c22.put("/api/time/config", json={"timezone": "America/New_York"}) +check("设置时区成功", r.status_code == 200 and r.get_json().get("ok")) +r = c22.get("/api/time/config") +check("下一个请求立即读到新时区(无陈旧缓存)", r.get_json()["timezone"] == "America/New_York") +st = c22.get("/api/display/state").get_json() +check("display/state 返回新时区", st.get("timezone") == "America/New_York") + print() print("=" * 60) print(f"RESULT: {PASS} passed, {FAIL} failed")