diff --git a/.dockerignore b/.dockerignore index 58d5f1d..b08c538 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,6 +9,7 @@ __pycache__/ *.log runtime/ data/cache/ +data/backups/ data/private-mentor-skills/ data/*.db data/*.db-shm diff --git a/DOCKER_DEPLOY.md b/DOCKER_DEPLOY.md index 378050d..4626e5a 100644 --- a/DOCKER_DEPLOY.md +++ b/DOCKER_DEPLOY.md @@ -14,13 +14,29 @@ v xiaobai-review 容器 :8765 |-- /app 只读应用代码 + | `-- backend/features/heaven/assets/heaven_knowledge.json + | 镜像内 seed(不受 data 挂载遮盖) `-- /app/data 宿主机 ./data 持久化挂载 + |-- review.db + |-- iching_zh.json + `-- heaven_knowledge.json 优先读取;缺失时回退到上方 seed ``` 账号、加密后的公共数据 Token、平台模型 API Key、生辰资料、行情快照和复盘数据均在 `data/review.db`。解密密钥来自 `.env` 中的 `APP_ENCRYPTION_KEY`。数据库与 密钥必须成对备份,任意一个丢失都无法恢复账号内的加密资料。 +问天静态知识文件: + +- `data/iching_zh.json`、`data/heaven_knowledge.json` 纳入 Git 与镜像构建; + `.dockerignore` 不排除这两个文件(只排除 `data/*.db`、`data/cache/` 等运行时产物)。 +- Compose 把宿主机 `./data` 整目录挂到 `/app/data`,会遮盖镜像里同路径文件。 + 因此宿主机 `data/` 应保留上述两个 JSON;若只缺 `heaven_knowledge.json`, + 服务会回退读取镜像内 + `backend/features/heaven/assets/heaven_knowledge.json`,解势仍可用。 +- 持久化位置:正式环境以宿主机项目目录下的 `./data/heaven_knowledge.json` 为准; + 补文件后无需改代码,重启容器即可加载。 + 管理员私有的问师 Skill 保存在宿主机 `data/private-mentor-skills/`。该目录随 `data` 挂载进入容器,但被 Git 与 Docker 构建上下文排除,不会进入 Gitea 或镜像。私有 Skill 只对管理员账号返回和开放调用,也会随本指南的 `data` 备份一起保存。 @@ -149,7 +165,32 @@ docker compose restart xiaobai-review docker compose down ``` -### 使用 Gitea 更新程序(推荐) +### 镜像构建的唯一安全入口(2026-08 HEL-235 起) + +生产机 `192.168.200.11` 上的 `/opt/1panel/docker/compose/xiaobaifupan` 只是历史文件树: +不是 Git 仓库、内容停在旧提交、与线上镜像不一致,且其 `compose.yaml` 会把构建结果打进 +`xiaobai-review:latest`。**禁止在该目录(或任何服务器工作树)里 `docker build` / +`docker compose build`**,否则会把已上线功能悄悄打回旧版。 + +唯一安全构建方式是在有仓库检出、能免密 SSH 到部署机的机器上运行: + +```bash +tools/build_image.sh <提交号> <镜像tag> +# 示例:tools/build_image.sh cefc86917d89 verify-hel235-cefc869 +``` + +该脚本的行为约束: + +- 先 `git fetch`,再把提交号解析为完整 SHA,解析失败立即中止,绝不使用本地脏状态或服务器旧目录; +- 构建前读取当前线上容器镜像的 `org.opencontainers.image.revision`,用 Git 祖先关系确认候选提交包含线上全部历史;落后 `main`、旁支或错误提交会直接退出,并打印线上提交、候选提交、文件差异和将丢失的提交; +- 镜像 tag 必须以 `-<提交短号7位>` 结尾(如 `hel234-cefc869`),禁止 `latest`、`rollback-*`; +- 通过 `git archive <提交> | ssh 部署机 docker build -` 流式构建,服务器上不存在构建用工作树; +- 构建后回读镜像 label 里的 `org.opencontainers.image.revision`,与预期提交不一致则删除镜像并中止; +- 每次构建在部署机 `~/xiaobai-build/BUILD_LOG.tsv` 留痕,可追溯每个镜像的来源提交。 + +构建只产出镜像,不启动、不替换任何容器;换版用新 tag 起新容器,回滚用既有镜像 tag 重跑。 + +### 使用 Gitea 更新程序(旧方式,生产机禁用) 代码仓库为: @@ -174,7 +215,9 @@ cd /opt/xiaobai-review `data/private-mentor-skills/` 复制到服务器项目的同名 `data` 目录,并保持目录仅由 部署账号和容器运行用户读取。该内容不会通过 Gitea 同步。 -每次更新前先创建 SQLite 一致性备份,再拉取并重建容器: +每次更新前先创建 SQLite 一致性备份,再拉取并重建容器(注意:`docker compose up -d --build` +从服务器本地工作树构建,仅适用于来源可信的全新环境;生产机 `192.168.200.11` 禁用, +请用 `tools/build_image.sh` 构建后换容器): ```bash cd /opt/xiaobai-review @@ -189,7 +232,10 @@ curl --fail http://127.0.0.1:8765/api/health 数据库迁移会在新容器启动时自动执行。若 `git pull --ff-only` 提示本地代码有修改, 先用 `git status` 查明原因,不要用强制重置覆盖 `.env` 或 `data`。 -### 不使用 Git 时更新 +### 不使用 Git 时更新(生产机禁用) + +`docker compose build` 会从服务器本地目录构建,来源提交不可追溯。生产机 +`192.168.200.11` 上禁止使用本节方式,一律改用上一节的 `tools/build_image.sh`。 重新上传代码后执行: diff --git a/Dockerfile b/Dockerfile index d9dd589..6ce7869 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,10 @@ COPY requirements.txt ./ RUN python -m pip install --no-cache-dir -r requirements.txt COPY --chown=xiaobai:xiaobai . . -RUN mkdir -p /app/data && chown -R xiaobai:xiaobai /app/data +RUN mkdir -p /app/data && chown -R xiaobai:xiaobai /app/data \ + && test -f /app/data/heaven_knowledge.json \ + && test -f /app/data/iching_zh.json \ + && test -f /app/backend/features/heaven/assets/heaven_knowledge.json USER xiaobai diff --git a/backend/bootstrap/config.py b/backend/bootstrap/config.py index 58813bd..3ed5980 100644 --- a/backend/bootstrap/config.py +++ b/backend/bootstrap/config.py @@ -18,6 +18,8 @@ TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,128}$") USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_\-\u4e00-\u9fff]{3,30}$") SESSION_COOKIE = "xiaobai_session" SESSION_MAX_AGE = 30 * 24 * 60 * 60 +DEVICE_COOKIE = "xiaobai_device" +DEVICE_MAX_AGE = 180 * 24 * 60 * 60 def load_local_env() -> None: diff --git a/backend/data/providers/tushare_dashboard.py b/backend/data/providers/tushare_dashboard.py index b7e98c1..c2fb4b4 100644 --- a/backend/data/providers/tushare_dashboard.py +++ b/backend/data/providers/tushare_dashboard.py @@ -41,13 +41,16 @@ class DashboardMixin: raise TushareError(f"No daily data returned for {trade_date}") notices: list[str] = [] + limit_data_source = "official" try: limit_rows = self._load_limit_lists(trade_date) previous_limit_rows = self._load_limit_type(previous_trade_date, "U") if not limit_rows: + limit_data_source = "derived" notices.append("涨跌停高级接口当日数据尚未更新,已使用日线数据推算。") limit_rows = self._derive_limits(trade_date, daily) except TushareError as exc: + limit_data_source = "derived" notices.append(f"涨跌停高级接口不可用,已使用日线数据推算:{exc}") limit_rows = self._derive_limits(trade_date, daily) previous_daily = self._load_daily(previous_trade_date) @@ -79,6 +82,7 @@ class DashboardMixin: "trade_date": _display_date(trade_date), "previous_trade_date": _display_date(previous_trade_date), "source": "tushare", + "limit_data_source": limit_data_source, "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "notice": ";".join(notices), }, diff --git a/backend/database/migrations/__init__.py b/backend/database/migrations/__init__.py index 694558f..71755cf 100644 --- a/backend/database/migrations/__init__.py +++ b/backend/database/migrations/__init__.py @@ -2,6 +2,7 @@ from .m0001_adopt_legacy import MIGRATION as M0001_ADOPT_LEGACY from .m0002_job_runs import MIGRATION as M0002_JOB_RUNS from .m0003_llm_audit import MIGRATION as M0003_LLM_AUDIT from .m0004_mentor_notes import MIGRATION as M0004_MENTOR_NOTES +from .m0005_account_switch_grants import MIGRATION as M0005_ACCOUNT_SWITCH_GRANTS from .runner import Migration, MigrationError, MigrationRunner MIGRATIONS = ( @@ -9,6 +10,7 @@ MIGRATIONS = ( M0002_JOB_RUNS, M0003_LLM_AUDIT, M0004_MENTOR_NOTES, + M0005_ACCOUNT_SWITCH_GRANTS, ) __all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"] diff --git a/backend/database/migrations/m0005_account_switch_grants.py b/backend/database/migrations/m0005_account_switch_grants.py new file mode 100644 index 0000000..e63f245 --- /dev/null +++ b/backend/database/migrations/m0005_account_switch_grants.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import sqlite3 + +from backend.database.migrations.runner import Migration + + +def create_account_switch_grants(connection: sqlite3.Connection) -> None: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS account_switch_grants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_hash TEXT NOT NULL, + user_id INTEGER NOT NULL, + granted_at TEXT NOT NULL, + last_used_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + UNIQUE (device_hash, user_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS idx_account_switch_grants_device + ON account_switch_grants(device_hash, last_used_at DESC) + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS idx_account_switch_grants_expiry + ON account_switch_grants(expires_at) + """ + ) + + +MIGRATION = Migration( + version="0005", + name="create_account_switch_grants", + action=create_account_switch_grants, + signature="account-switch-grants:v1:device,user,granted,used,expires,unique", +) diff --git a/backend/features/accounts/application.py b/backend/features/accounts/application.py index 6608e6b..1d227e9 100644 --- a/backend/features/accounts/application.py +++ b/backend/features/accounts/application.py @@ -28,11 +28,11 @@ class AccountApplicationMixin: def update_membership(self, payload: dict[str, Any]) -> None: self.accounts.update_membership(payload) - def register_account(self, username: str, password: str) -> dict[str, Any]: - return self.accounts.register(username, password) + def register_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]: + return self.accounts.register(username, password, device_hash) - def login_account(self, username: str, password: str) -> dict[str, Any]: - return self.accounts.login(username, password) + def login_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]: + return self.accounts.login(username, password, device_hash) def change_password(self, current_password: str, new_password: str) -> None: self.accounts.change_password(current_password, new_password) diff --git a/backend/features/accounts/http.py b/backend/features/accounts/http.py index 4bc282e..22aafeb 100644 --- a/backend/features/accounts/http.py +++ b/backend/features/accounts/http.py @@ -1,49 +1,108 @@ from __future__ import annotations import json +import secrets from http import HTTPStatus +from backend.features.accounts.security import token_hash + class AccountHttpMixin: + def _device_hash(self) -> str: + raw = self.device_token() + return token_hash(raw) if raw else "" + + def _ensure_device_token(self) -> str: + return self.device_token() or secrets.token_urlsafe(32) + + def _auth_success_headers(self, session_token: str, device_raw: str) -> list[tuple[str, str]]: + return [ + ("Set-Cookie", self.session_cookie(session_token)), + ("Set-Cookie", self.device_cookie(device_raw)), + ] + + def _send_authenticated_session(self, result: dict, status: HTTPStatus, device_raw: str) -> None: + self.send_json( + { + "ok": True, + "authenticated": True, + "user": result["user"], + "csrf_token": result["csrf_token"], + }, + status, + self._auth_success_headers(result["session_token"], device_raw), + ) + def auth_register(self) -> None: try: body = self.read_json_body() + device_raw = self._ensure_device_token() result = self.application_service.register_account( str(body.get("username") or ""), str(body.get("password") or ""), + token_hash(device_raw), ) - self.send_json( - { - "ok": True, - "authenticated": True, - "user": result["user"], - "csrf_token": result["csrf_token"], - }, - HTTPStatus.CREATED, - {"Set-Cookie": self.session_cookie(result["session_token"])}, - ) + self._send_authenticated_session(result, HTTPStatus.CREATED, device_raw) except (ValueError, json.JSONDecodeError) as exc: self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) def auth_login(self) -> None: try: body = self.read_json_body() + device_raw = self._ensure_device_token() result = self.application_service.login_account( str(body.get("username") or ""), str(body.get("password") or ""), + token_hash(device_raw), ) - self.send_json( - { - "ok": True, - "authenticated": True, - "user": result["user"], - "csrf_token": result["csrf_token"], - }, - headers={"Set-Cookie": self.session_cookie(result["session_token"])}, - ) + self._send_authenticated_session(result, HTTPStatus.OK, device_raw) except (ValueError, json.JSONDecodeError) as exc: self.send_json({"error": str(exc)}, HTTPStatus.UNAUTHORIZED) + def auth_accounts(self) -> None: + current_user_id = None + if self.require_auth(send_error=False): + current_user_id = int(self.auth_user["id"]) + payload = self.application_service.accounts.list_device_accounts( + self._device_hash(), + current_user_id, + ) + self.send_json({"ok": True, **payload}) + + def auth_switch(self) -> None: + try: + body = self.read_json_body() + try: + user_id = int(body.get("user_id")) + except (TypeError, ValueError): + user_id = 0 + device_raw = self.device_token() + result = self.application_service.accounts.switch_account( + token_hash(device_raw) if device_raw else "", + user_id, + ) + self._send_authenticated_session( + result, + HTTPStatus.OK, + device_raw or self._ensure_device_token(), + ) + except PermissionError as exc: + self.send_json({"error": str(exc)}, HTTPStatus.UNAUTHORIZED) + except (ValueError, json.JSONDecodeError) as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + + def auth_forget(self) -> None: + try: + body = self.read_json_body() + try: + user_id = int(body.get("user_id")) + except (TypeError, ValueError): + user_id = 0 + self.application_service.accounts.forget_account(self._device_hash(), user_id) + except (ValueError, json.JSONDecodeError): + pass + self.send_json({"ok": True}) + def auth_me(self) -> None: service = self.application_service if not self.require_auth(send_error=False): @@ -55,6 +114,15 @@ class AccountHttpMixin: } ) return + headers = None + if not self.device_token(): + device_raw = secrets.token_urlsafe(32) + service.accounts.remember_account( + token_hash(device_raw), + int(self.auth_user["id"]), + fresh=True, + ) + headers = [("Set-Cookie", self.device_cookie(device_raw))] self.send_json( { "ok": True, @@ -66,15 +134,19 @@ class AccountHttpMixin: "membership": service.membership(), }, "csrf_token": str(self.auth_user["csrf_token"]), - } + }, + headers=headers, ) def auth_logout(self) -> None: raw_token = self.session_token() + user_id = int(getattr(self, "auth_user", {}).get("id") or 0) if raw_token: - from backend.features.accounts.security import token_hash - self.application_service.database.delete_session(token_hash(raw_token)) + self.application_service.accounts.revoke_current_device_grant( + self._device_hash(), + user_id, + ) self.send_json( {"ok": True}, headers={"Set-Cookie": self.session_cookie("", clear=True)}, diff --git a/backend/features/accounts/repository.py b/backend/features/accounts/repository.py index 98cc534..04bb60c 100644 --- a/backend/features/accounts/repository.py +++ b/backend/features/accounts/repository.py @@ -234,3 +234,96 @@ class AccountRepositoryMixin: (user_id,), ) return cursor.rowcount > 0 + + def cleanup_expired_switch_grants(self, now: str) -> int: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM account_switch_grants WHERE expires_at <= ?", + (now,), + ) + return int(cursor.rowcount) + + def list_switch_grants(self, device_hash: str, now: str) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute( + """ + SELECT u.id, u.username, u.role, u.llm_mode, u.membership_status, + u.membership_plan, u.membership_starts_at, u.membership_expires_at, + u.created_at, g.last_used_at, g.granted_at, g.expires_at + FROM account_switch_grants AS g + JOIN users AS u ON u.id = g.user_id + WHERE g.device_hash = ? AND g.expires_at > ? + ORDER BY g.last_used_at DESC, g.id DESC + """, + (device_hash, now), + ).fetchall() + return [dict(row) for row in rows] + + def get_switch_grant(self, device_hash: str, user_id: int) -> dict[str, Any] | None: + with self.connect() as connection: + row = connection.execute( + """ + SELECT device_hash, user_id, granted_at, last_used_at, expires_at + FROM account_switch_grants + WHERE device_hash = ? AND user_id = ? + """, + (device_hash, user_id), + ).fetchone() + return dict(row) if row else None + + def upsert_switch_grant( + self, + device_hash: str, + user_id: int, + granted_at: str, + last_used_at: str, + expires_at: str, + ) -> None: + with self.connect() as connection: + connection.execute( + """ + INSERT INTO account_switch_grants + (device_hash, user_id, granted_at, last_used_at, expires_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(device_hash, user_id) DO UPDATE SET + granted_at = excluded.granted_at, + last_used_at = excluded.last_used_at, + expires_at = excluded.expires_at + """, + (device_hash, user_id, granted_at, last_used_at, expires_at), + ) + + def prune_switch_grants(self, device_hash: str, keep: int) -> int: + with self.connect() as connection: + rows = connection.execute( + """ + SELECT id FROM account_switch_grants + WHERE device_hash = ? + ORDER BY last_used_at DESC, id DESC + """, + (device_hash,), + ).fetchall() + extra = [int(row["id"]) for row in rows[keep:]] + if not extra: + return 0 + connection.execute( + f"DELETE FROM account_switch_grants WHERE id IN ({','.join('?' * len(extra))})", + extra, + ) + return len(extra) + + def delete_switch_grant(self, device_hash: str, user_id: int) -> bool: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM account_switch_grants WHERE device_hash = ? AND user_id = ?", + (device_hash, user_id), + ) + return cursor.rowcount > 0 + + def delete_switch_grants_for_user(self, user_id: int) -> int: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM account_switch_grants WHERE user_id = ?", + (user_id,), + ) + return int(cursor.rowcount) diff --git a/backend/features/accounts/routes.py b/backend/features/accounts/routes.py index 89af383..a5921b9 100644 --- a/backend/features/accounts/routes.py +++ b/backend/features/accounts/routes.py @@ -6,6 +6,9 @@ class AccountRoutesMixin: if parsed.path == "/api/auth/me": self.auth_me() return True + if parsed.path == "/api/auth/accounts": + self.auth_accounts() + return True return False def _handle_accounts_get(self, parsed) -> bool: diff --git a/backend/features/accounts/service.py b/backend/features/accounts/service.py index aa6958c..47d5a75 100644 --- a/backend/features/accounts/service.py +++ b/backend/features/accounts/service.py @@ -77,15 +77,22 @@ class AccountService: access = self.access_supplier() or self.database.user_access(self.current_user_id) or {} return self.membership_for_access(access) - def register(self, username: str, password: str) -> dict[str, Any]: + GRANT_SLIDE_DAYS = 30 + GRANT_HARD_DAYS = 180 + MAX_GRANTS_PER_DEVICE = 5 + SWITCH_REAUTH_MESSAGE = "该账号需重新验证" + + def register(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]: username = username.strip() self.validate_input(username, password) with self.auth_lock: salt, password_digest = hash_password(password) user = self.database.create_user(username, salt, password_digest) - return self.create_session(user) + result = self.create_session(user) + self.remember_account(device_hash, int(user["id"]), fresh=True) + return result - def login(self, username: str, password: str) -> dict[str, Any]: + def login(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]: username = username.strip() if not username or not password: raise ValueError("账号名和密码不能为空。") @@ -96,7 +103,9 @@ class AccountService: str(user.get("password_hash") or ""), ): raise ValueError("账号名或密码不正确。") - return self.create_session(user) + result = self.create_session(user) + self.remember_account(device_hash, int(user["id"]), fresh=True) + return result def change_password(self, current_password: str, new_password: str) -> None: current_password = str(current_password or "") @@ -112,6 +121,86 @@ class AccountService: salt, digest = hash_password(new_password) if not self.database.update_user_password(self.current_user_id, salt, digest): raise ValueError("账号不存在。") + self.database.delete_switch_grants_for_user(self.current_user_id) + + @staticmethod + def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + @classmethod + def _iso(cls, value: datetime) -> str: + return value.isoformat(timespec="seconds") + + def remember_account(self, device_hash: str, user_id: int, *, fresh: bool = False) -> None: + if not device_hash or user_id <= 0: + return + now = self._utc_now() + now_text = self._iso(now) + self.database.cleanup_expired_switch_grants(now_text) + existing = None if fresh else self.database.get_switch_grant(device_hash, user_id) + granted_at = parse_iso_datetime(existing["granted_at"]) if existing else now + if granted_at is None: + granted_at = now + expires = min( + now + timedelta(days=self.GRANT_SLIDE_DAYS), + granted_at + timedelta(days=self.GRANT_HARD_DAYS), + ) + if not existing: + self.database.prune_switch_grants(device_hash, self.MAX_GRANTS_PER_DEVICE - 1) + self.database.upsert_switch_grant( + device_hash, + user_id, + self._iso(granted_at), + now_text, + self._iso(expires), + ) + + def list_device_accounts( + self, device_hash: str, current_user_id: int | None = None + ) -> dict[str, Any]: + if not device_hash: + return {"accounts": [], "current_user_id": current_user_id} + now_text = self._iso(self._utc_now()) + self.database.cleanup_expired_switch_grants(now_text) + accounts = [] + for row in self.database.list_switch_grants(device_hash, now_text): + accounts.append( + { + "user_id": int(row["id"]), + "username": str(row["username"]), + "role": str(row.get("role") or "user"), + "membership": self.membership_for_access(row), + "last_used_at": str(row.get("last_used_at") or ""), + } + ) + return {"accounts": accounts, "current_user_id": current_user_id} + + def switch_account(self, device_hash: str, user_id: int) -> dict[str, Any]: + if not device_hash or user_id <= 0: + raise PermissionError(self.SWITCH_REAUTH_MESSAGE) + now = self._utc_now() + now_text = self._iso(now) + self.database.cleanup_expired_switch_grants(now_text) + grant = self.database.get_switch_grant(device_hash, user_id) + expires = parse_iso_datetime(grant.get("expires_at")) if grant else None + if not grant or not expires or expires <= now: + if grant: + self.database.delete_switch_grant(device_hash, user_id) + raise PermissionError(self.SWITCH_REAUTH_MESSAGE) + user = self.database.user_access(user_id) + if not user: + raise PermissionError(self.SWITCH_REAUTH_MESSAGE) + result = self.create_session(user) + self.remember_account(device_hash, user_id) + return result + + def forget_account(self, device_hash: str, user_id: int) -> None: + if device_hash and user_id > 0: + self.database.delete_switch_grant(device_hash, user_id) + + def revoke_current_device_grant(self, device_hash: str, user_id: int) -> None: + if device_hash and user_id > 0: + self.database.delete_switch_grant(device_hash, user_id) def create_session(self, user: dict[str, Any]) -> dict[str, Any]: session_token = secrets.token_urlsafe(32) diff --git a/backend/features/heaven/assets/heaven_knowledge.json b/backend/features/heaven/assets/heaven_knowledge.json new file mode 100644 index 0000000..402b390 --- /dev/null +++ b/backend/features/heaven/assets/heaven_knowledge.json @@ -0,0 +1,117 @@ +{ + "version": "2026.08.05-5", + "sources": { + "zhouyi": { + "title": "周易经文与十翼", + "scope": "卦辞、爻辞、彖传、象传", + "kind": "public_domain_primary", + "note": "观势与观心只引用本项目已校录的卦爻原文,不把现代网络释文当作原典。" + }, + "jingfang": { + "title": "京氏易传", + "scope": "八宫与纳甲体系来源", + "kind": "public_domain_traditional", + "note": "确定性程序采用京房纳甲、八宫世应的通行排法。" + }, + "huozhulin": { + "title": "火珠林", + "scope": "纳甲筮法、六亲与日月关系", + "kind": "public_domain_traditional", + "note": "用于观心规则脉络,不直接复制后世简化断语。" + }, + "zengshan": { + "title": "增删卜易", + "scope": "用神、世应、动变、日月旺衰", + "kind": "public_domain_traditional", + "note": "只采用可明确编码且有一致输入条件的规则;争议规则单独标记。" + }, + "neijing": { + "title": "黄帝内经·素问运气七篇", + "scope": "五运、司天在泉、主客气与运气关系", + "kind": "public_domain_primary", + "note": "观气将原典关系转成当日自我观察语言,不宣称对股价存在因果作用。" + } + }, + "trend": { + "method": "本卦说明当下结构,实际动爻说明变化关节,之卦说明所趋结构;多动爻全部保留,不以固定口诀删去用户实际得到的爻。", + "rules": { + "stable": "无动爻时以本卦整体、上下卦关系和大象为主,说明结构的延续条件,不把静止等同于永远不变。", + "single": "一爻动时以该爻的时位、爻辞和象辞为变化核心,并用之卦检查变化后的结构。", + "multiple": "多爻动时逐一保留相关爻义,先找共同方向与冲突,再结合之卦给出有条件的倾向;不得用固定套话把不同动爻压成同一结论。" + } + }, + "fortune": { + "principle": "先立中运与司天在泉的年纲,再察当前客气加临主气,最后以日辰说明当日触发;不使用产品权重推导传统结论。", + "movement": { + "太过": "太过表示该运之气偏于有余,解释时同时观察其本气表现与对所胜、所生关系的牵动,不直接等同于吉或凶。", + "不及": "不及表示该运之气偏于不足,解释时同时观察其所不胜来乘与所生受累的可能,不直接等同于弱势结论。" + }, + "qi": { + "厥阴风木": "厥阴取风木之动,侧重疏泄、升发、变化与不定;偏盛时可表现为动摇、急变或升散不收。", + "少阴君火": "少阴取君火之明与热,侧重显化、温煦和内在驱动;偏盛时容易躁热,受制时则显而不畅。", + "太阴湿土": "太阴取湿土之濡与承载,侧重黏滞、蓄积和转化;偏盛时容易困重迟缓,得化时则能承接。", + "少阳相火": "少阳取相火之行与枢转,侧重外达、加速和往来;偏盛时容易浮越躁动,受阻时表现为枢机不利。", + "阳明燥金": "阳明取燥金之收与清肃,侧重收敛、裁决和边界;偏盛时容易干急严峻,得润时则清明有序。", + "太阳寒水": "太阳取寒水之藏与凝,侧重潜藏、收引和下行;偏盛时容易凝滞退缩,得温时则蓄势有根。" + }, + "relations": { + "same": "客主同气表示同类气相并,重点看是否相得而彰,还是同气偏盛而亢;不能机械判为有利。", + "guest_generates_host": "客生主表示来气生助时令本气,气机较易衔接;仍需观察生助是否过度及年纲是否承接。", + "host_generates_guest": "主生客表示时令本气向来气流转,有相生也有外泄;不能只取相生而忽略主气受耗。", + "guest_controls_host": "客克主表示来气制约主气,传统称客胜为从;重点解释外来变化居上及原有节律受制。", + "host_controls_guest": "主克客表示主气制约来气,传统称主胜为逆;重点解释时令与来气相持而不把相克直接断凶。" + }, + "day_trigger": "日辰只说明当日关系如何被触发,不与中运、司天在泉或主客气并列重复计权。", + "industry_boundary": "五行对应行业只作传统取象:可以说明本次已经出现的五行之气对相应行业形成的象征性关注、节奏或约束,但不得读取或猜测行业实时行情,不得预测涨跌,也不得把取象写成投资推荐。", + "personal_boundary": "personal.natal_day_master才是用户本命日主;today_relative_to_natal_day_master中的pillars是当日历法,stem_relations只是当日年、月、日三柱天干相对本命日主的确定性关系标签。只能使用本次检索到的关系释义,不得自行重算十神、扩展五行生克、使用藏干、库气或支的燥湿属性,也不得把当日日柱称为用户命局,或由这些字段推断命局中某一十神偏重、身强身弱或喜用神。", + "personal_relations": { + "比肩": "比肩作为当日天干关系标签,只提示用户可能更在意自主判断、同类比较或坚持原有立场;不能据此判断命局强弱或现实事件。", + "劫财": "劫财作为当日天干关系标签,只提示用户留意精力、注意力或可支配资源在同类事项间的分流与竞争感;不等同于破财或他人争夺。", + "食神": "食神作为当日天干关系标签,只提示用户留意表达、输出、舒缓与完成感;不等同于收益或确定的轻松结果。", + "伤官": "伤官作为当日天干关系标签,只提示用户留意质疑规则、急于表达或追求自主空间的倾向;不等同于冲突或违规。", + "偏财": "偏财作为当日天干关系标签,只提示用户留意机会分配、灵活取舍与非固定资源的吸引力;不等同于意外获利。", + "正财": "正财作为当日天干关系标签,只提示用户更关注可核对的结果、资源边界和务实落地;不等同于必得收益或现金变化。", + "七杀": "七杀作为当日天干关系标签,只提示用户留意紧迫感、外部压力和快速决断冲动;不等同于危险必然发生。", + "正官": "正官作为当日天干关系标签,只提示用户更在意规则、责任、秩序和可交付标准;不等同于结果必然受控。", + "偏印": "偏印作为当日天干关系标签,只提示用户留意内省、非惯常信息和反复推敲的倾向;不等同于退缩、失眠或方向错误。", + "正印": "正印作为当日天干关系标签,只提示用户更在意依据、支持、学习和安全边界;不等同于必然获得帮助。" + } + }, + "heart": { + "presets": { + "trade": "关于我心中的这笔交易,此刻最需要看清的机会、阻碍与风险是什么?", + "mind": "此刻影响我交易判断的情绪、执念或盲点是什么?", + "unthemed": "不设具体问题,只观此刻一念。" + }, + "focus": { + "trade": "以世爻、应爻、妻财爻及实际动变为主要检索对象,同时检查兄弟、官鬼和子孙的生克,不把任何单一六亲固定判吉凶。", + "mind": "以世爻和实际动爻为主,观察官鬼所示压力、子孙所示舒解及内外生克;不把心境问题强行翻译成价格方向。", + "unthemed": "不强选事项用神,以本卦、世爻、实际动爻和之卦作一般观照,不猜测用户没有提出的问题。", + "custom": "先依据用户明确写出的股票交易问题选择相关六亲;无法明确归类时退回世爻、动爻和卦变的一般解释,不擅自补全问题。" + }, + "evidence_order": [ + "用户问题与预设来源", + "本卦及卦宫", + "世应与所问相关六亲", + "月建日辰、旬空及冲合生克", + "实际动爻与变爻", + "之卦与整体卦义", + "六神辅助象义" + ], + "limits": "六神只作辅助象义;空亡、月破、日冲、合冲刑害均需结合用神、世应和动变,不得单项宣布结果。", + "semantics": { + "self_response": "世爻表示求测者当前立场与承受状态,应爻表示所问事项的外部一端或对照面。应爻不是固定的合作方、庄家或资金方;只有用户问题明确给出该角色时,才可作对应解释。", + "calendar": "月建与日辰用于判断爻在起卦时刻的承受、生扶和制约。旬空表示该爻所象征的条件当下可能未落实、难发挥或有名无实,但不能单凭旬空判失败,也不能用填实日期预测何时涨跌或行动。月破、日冲、六合、六冲、六害和相刑同样必须与世应、相关六亲及动变合看。", + "movement": "动爻说明关系正在变化;变爻说明变化后的承接方向。回头生、回头克和原变爻生克只描述力量关系,不自动对应现实中的借贷、融资、合作或某个具体人物。进神退神只说明同类地支变化的进退趋势,不直接宣布价格方向。", + "six_spirits": "六神只补充表达色彩,不单独定成败。青龙不必然有利,白虎不必然紧急或凶险,朱雀不必然等同口舌,玄武不必然等同欺骗,勾陈与螣蛇也不得脱离爻位、六亲和动变独断。", + "timing_boundary": "观心不作应期预测。可以说明某项条件在起卦时刻尚未落实或受制,但不得给出未来若干日、某干支日、出空或填实后必然发生什么。", + "relatives": { + "兄弟": "兄弟是与卦宫五行同类的关系。在股票交易问题中可作为竞争、同类力量或资源分流的候选象义,但不直接等同合作方、亏损或他人拿走资金。", + "子孙": "子孙是卦宫所生的关系,可作为舒缓、产出、执行后的释放或对压力的制衡候选象义,但不直接等同收益、资金提供方或确定的利好。", + "妻财": "妻财是卦宫所克的关系,在股票交易问题中可作为价值、收益预期、持仓利益或可支配资源的候选象义,但不直接等同现金、融资、自有资金或必得之财。", + "官鬼": "官鬼是克制卦宫的关系,可作为压力、风险、规则约束或担忧的候选象义,但不直接等同借贷、坏消息、疾病或必然损失。", + "父母": "父母是生助卦宫的关系,可作为信息、依据、计划、规则、凭据或保护条件的候选象义,但不直接等同政策、合同或某一条消息。" + } + } + } +} diff --git a/backend/features/heaven/http.py b/backend/features/heaven/http.py index 0defe12..1ccda62 100644 --- a/backend/features/heaven/http.py +++ b/backend/features/heaven/http.py @@ -3,15 +3,24 @@ from __future__ import annotations import json from http import HTTPStatus +from backend.features.heaven.knowledge import HeavenKnowledgeError + class HeavenHttpMixin: + def _send_heaven_client_error(self, exc: Exception) -> None: + payload: dict = {"error": str(exc)} + code = getattr(exc, "error_code", None) + if code: + payload["code"] = str(code) + self.send_json(payload, HTTPStatus.BAD_REQUEST) + def heaven_hexagram(self) -> None: try: body = self.read_json_body() result = self.application_service.heaven_hexagram(body.get("lines")) self.send_json({"ok": True, "hexagram": result}) except (ValueError, json.JSONDecodeError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + self._send_heaven_client_error(exc) def heaven_personal(self) -> None: try: @@ -19,12 +28,12 @@ class HeavenHttpMixin: result = self.application_service.heaven_personal(body) self.send_json({"ok": True, "personal": result}) except (ValueError, json.JSONDecodeError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + self._send_heaven_client_error(exc) def heaven_interpret(self) -> None: try: body = self.read_json_body() result = self.application_service.heaven_interpret(body) self.send_json({"ok": True, **result}) - except (ValueError, json.JSONDecodeError) as exc: - self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + except (HeavenKnowledgeError, ValueError, json.JSONDecodeError) as exc: + self._send_heaven_client_error(exc) diff --git a/backend/features/heaven/knowledge.py b/backend/features/heaven/knowledge.py index be19dec..6d16e44 100644 --- a/backend/features/heaven/knowledge.py +++ b/backend/features/heaven/knowledge.py @@ -2,12 +2,23 @@ from __future__ import annotations import json from functools import lru_cache +from pathlib import Path from typing import Any from backend.bootstrap.config import APP_DIR KNOWLEDGE_FILE = APP_DIR / "data" / "heaven_knowledge.json" +# Baked into the image outside the ./data bind mount so volume overlay cannot hide it. +KNOWLEDGE_SEED_FILE = Path(__file__).resolve().parent / "assets" / "heaven_knowledge.json" + + +class HeavenKnowledgeError(ValueError): + """Structured knowledge-file failure surfaced to HTTP as Chinese API errors.""" + + def __init__(self, message: str, *, code: str) -> None: + super().__init__(message) + self.error_code = code def prepare_heaven_context(mode: str, calculation: dict[str, Any]) -> dict[str, Any]: @@ -379,9 +390,49 @@ def _line_record(line: dict[str, Any]) -> dict[str, Any]: } +def resolve_heaven_knowledge_path() -> Path: + """Prefer the persisted data-dir file; fall back to the image-baked seed.""" + if KNOWLEDGE_FILE.is_file(): + return KNOWLEDGE_FILE + if KNOWLEDGE_SEED_FILE.is_file(): + return KNOWLEDGE_SEED_FILE + raise HeavenKnowledgeError( + "问天知识文件缺失:未找到 heaven_knowledge.json。" + "请确认宿主机 data 目录或镜像内 seed 文件完整。", + code="heaven_knowledge_missing", + ) + + @lru_cache(maxsize=1) def _knowledge_catalog() -> dict[str, Any]: - payload = json.loads(KNOWLEDGE_FILE.read_text(encoding="utf-8")) + path = resolve_heaven_knowledge_path() + try: + raw = path.read_text(encoding="utf-8") + except OSError as exc: + raise HeavenKnowledgeError( + f"问天知识文件无法读取({path.name}):{exc.strerror or exc}", + code="heaven_knowledge_missing", + ) from exc + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + raise HeavenKnowledgeError( + f"问天知识文件 JSON 损坏({path.name}),无法解析:" + f"第 {exc.lineno} 行附近。", + code="heaven_knowledge_invalid", + ) from exc + if not isinstance(payload, dict): + raise HeavenKnowledgeError( + f"问天知识文件格式不正确({path.name}):根节点必须是对象。", + code="heaven_knowledge_invalid", + ) if not payload.get("version") or not isinstance(payload.get("sources"), dict): - raise ValueError("问天知识库格式不完整。") + raise HeavenKnowledgeError( + f"问天知识库格式不完整({path.name}):缺少 version 或 sources。", + code="heaven_knowledge_invalid", + ) return payload + + +def clear_heaven_knowledge_cache() -> None: + _knowledge_catalog.cache_clear() diff --git a/backend/features/market/backfill_history.py b/backend/features/market/backfill_history.py new file mode 100644 index 0000000..29f06b5 --- /dev/null +++ b/backend/features/market/backfill_history.py @@ -0,0 +1,202 @@ +"""Auditable recent-trading-day snapshot backfill helpers. + +Planning and backup stay free of provider imports so feature boundary tests remain green. +The service layer supplies open trading dates from the live calendar and executes sync. +""" + +from __future__ import annotations + +import sqlite3 +from datetime import date, datetime, timedelta +from pathlib import Path +from typing import Any, Iterable + + +MAX_RANGE_TRADING_DAYS = 15 +MAX_RECENT_TRADING_DAYS = 60 +DEFAULT_RECENT_TRADING_DAYS = 60 + +# Tables touched by a successful historical dashboard sync. User / token / model +# tables must never appear here. +SNAPSHOT_BACKFILL_WRITE_TABLES = frozenset( + { + "dashboard_snapshots", + "data_snapshots", + "sync_runs", + } +) + + +def clamp_recent_lookback(lookback: int) -> int: + value = int(lookback) + if value < 1: + raise ValueError("回补交易日数量至少为 1。") + if value > MAX_RECENT_TRADING_DAYS: + raise ValueError(f"单次最多回补最近 {MAX_RECENT_TRADING_DAYS} 个交易日。") + return value + + +def calendar_window_start(end_date: str, lookback: int) -> str: + """Natural-day lower bound large enough to cover lookback open sessions.""" + end = datetime.strptime(end_date, "%Y%m%d").date() + span = max(40, int(lookback * 2) + 20) + return (end - timedelta(days=span)).strftime("%Y%m%d") + + +def select_open_trade_dates( + calendar_rows: Iterable[dict[str, Any]], + end_date: str, + lookback: int, +) -> list[str]: + """Pick the last ``lookback`` open SSE sessions on or before ``end_date``.""" + lookback = clamp_recent_lookback(lookback) + end = normalize_compact_date(end_date) + open_dates = sorted( + { + normalize_compact_date(str(row.get("cal_date") or "")) + for row in calendar_rows + if int(row.get("is_open") or 0) == 1 and row.get("cal_date") + } + ) + open_dates = [item for item in open_dates if item <= end] + if not open_dates: + raise ValueError("交易日历未返回可用交易日,请检查行情 Token。") + return open_dates[-lookback:] + + +def select_open_trade_dates_in_range( + calendar_rows: Iterable[dict[str, Any]], + start_date: str, + end_date: str, + *, + maximum: int = MAX_RANGE_TRADING_DAYS, +) -> tuple[list[str], list[str]]: + """Return (open_dates, skipped_non_trading_days) inside an inclusive range.""" + start = normalize_compact_date(start_date) + end = normalize_compact_date(end_date) + if start > end: + raise ValueError("开始日期不能晚于结束日期。") + open_set = { + normalize_compact_date(str(row.get("cal_date") or "")) + for row in calendar_rows + if int(row.get("is_open") or 0) == 1 and row.get("cal_date") + } + open_dates: list[str] = [] + skipped: list[str] = [] + cursor = datetime.strptime(start, "%Y%m%d").date() + last = datetime.strptime(end, "%Y%m%d").date() + while cursor <= last: + compact = cursor.strftime("%Y%m%d") + if compact in open_set: + open_dates.append(compact) + else: + skipped.append(compact) + cursor += timedelta(days=1) + if len(open_dates) > maximum: + raise ValueError(f"单次最多回补 {maximum} 个交易日。") + return open_dates, skipped + + +def classify_snapshot_coverage( + trade_dates: list[str], + existing_dates: Iterable[str], +) -> dict[str, Any]: + present_set = { + normalize_compact_date(item) + for item in existing_dates + if item + } + present = [item for item in trade_dates if item in present_set] + missing = [item for item in trade_dates if item not in present_set] + return { + "trade_dates": list(trade_dates), + "present": present, + "missing": missing, + "present_count": len(present), + "missing_count": len(missing), + } + + +def create_sqlite_backup( + source_path: Path, + backup_dir: Path, + *, + label: str = "pre-backfill", + stamped_at: datetime | None = None, +) -> Path: + """Create a timestamped SQLite backup via the native backup API.""" + source = Path(source_path) + if not source.exists(): + raise FileNotFoundError(f"数据库不存在:{source}") + stamp = (stamped_at or datetime.now().astimezone()).strftime("%Y%m%d-%H%M%S") + safe_label = "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in label).strip("-") or "backup" + backup_dir = Path(backup_dir) + backup_dir.mkdir(parents=True, exist_ok=True) + target = backup_dir / f"review-{safe_label}-{stamp}.db" + source_conn = sqlite3.connect(f"file:{source}?mode=ro", uri=True) + try: + target_conn = sqlite3.connect(target) + try: + source_conn.backup(target_conn) + target_conn.commit() + finally: + target_conn.close() + finally: + source_conn.close() + return target + + +def display_date(compact: str) -> str: + value = normalize_compact_date(compact) + return f"{value[:4]}-{value[4:6]}-{value[6:8]}" + + +def normalize_compact_date(value: str) -> str: + compact = str(value or "").replace("-", "").strip() + if len(compact) != 8 or not compact.isdigit(): + raise ValueError("日期格式应为 YYYY-MM-DD。") + datetime.strptime(compact, "%Y%m%d") + return compact + + +def build_backfill_audit( + *, + mode: str, + end_date: str, + lookback: int | None, + coverage: dict[str, Any], + skipped_non_trading_days: list[str] | None = None, + backup_path: str | None = None, + dry_run: bool = False, + results: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + results = list(results or []) + succeeded = [row for row in results if row.get("status") == "success"] + skipped = [row for row in results if row.get("status") == "skipped"] + failed = [row for row in results if row.get("status") == "failed"] + return { + "ok": not failed, + "mode": mode, + "dry_run": dry_run, + "end_date": display_date(end_date), + "lookback": lookback, + "backup_path": backup_path, + "write_tables": sorted(SNAPSHOT_BACKFILL_WRITE_TABLES), + "trade_dates": [display_date(item) for item in coverage.get("trade_dates") or []], + "present": [display_date(item) for item in coverage.get("present") or []], + "missing": [display_date(item) for item in coverage.get("missing") or []], + "skipped_non_trading_days": [ + display_date(item) for item in (skipped_non_trading_days or []) + ], + "present_count": int(coverage.get("present_count") or 0), + "missing_count": int(coverage.get("missing_count") or 0), + "results": results, + "succeeded_count": len(succeeded), + "skipped_count": len(skipped), + "failed_count": len(failed), + "created_dates": [ + str(row.get("trade_date") or "") + for row in succeeded + if row.get("action") == "created" + ], + } diff --git a/backend/features/market/repository.py b/backend/features/market/repository.py index 7492e41..15c4828 100644 --- a/backend/features/market/repository.py +++ b/backend/features/market/repository.py @@ -227,6 +227,31 @@ class MarketRepositoryMixin: result.append(payload) return result + def list_snapshot_trade_dates( + self, + start_date: str = "", + end_date: str = "", + ) -> list[str]: + clauses: list[str] = [] + parameters: list[Any] = [] + if start_date: + clauses.append("trade_date >= ?") + parameters.append(start_date) + if end_date: + clauses.append("trade_date <= ?") + parameters.append(end_date) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + with self.connect() as connection: + rows = connection.execute( + f""" + SELECT trade_date FROM dashboard_snapshots + {where} + ORDER BY trade_date + """, + parameters, + ).fetchall() + return [str(row["trade_date"]) for row in rows] + def start_sync(self, trade_date: str, source: str) -> int: started_at = datetime.now().astimezone().isoformat(timespec="seconds") with self.connect() as connection: diff --git a/backend/features/market/service.py b/backend/features/market/service.py index 935037e..e372b8f 100644 --- a/backend/features/market/service.py +++ b/backend/features/market/service.py @@ -3,9 +3,11 @@ from __future__ import annotations import copy import re from datetime import date, datetime, time as dt_time, timedelta +from pathlib import Path from typing import Any from backend.bootstrap.config import ( + DATA_DIR, normalize_date, tushare_code, validate_stock_code, @@ -13,6 +15,17 @@ from backend.bootstrap.config import ( ) from backend.data.providers.ifind_client import IfindError from backend.data.providers.tushare_client import TushareClient, TushareError +from backend.features.market.backfill_history import ( + DEFAULT_RECENT_TRADING_DAYS, + MAX_RANGE_TRADING_DAYS, + build_backfill_audit, + calendar_window_start, + classify_snapshot_coverage, + create_sqlite_backup, + display_date, + select_open_trade_dates, + select_open_trade_dates_in_range, +) from backend.features.market.charts import ChartDataError from backend.features.market.insights import MarketInsightsService from backend.features.sentiment.engine import SENTIMENT_ENGINE_VERSION @@ -185,6 +198,11 @@ class MarketServiceMixin: raise TushareError("公共行情尚未配置") dashboard = self._tushare_client().dashboard(normalized_date) + if (dashboard.get("meta") or {}).get("limit_data_source") == "derived": + raise TushareError( + str((dashboard.get("meta") or {}).get("notice") or "官方涨跌停数据尚未返回") + ) + dashboard["meta"]["source"] = source dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date) dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date) @@ -890,31 +908,226 @@ class MarketServiceMixin: "intraday": intraday_points, } - def backfill(self, start_date: str, end_date: str) -> list[dict[str, Any]]: - start = datetime.strptime(normalize_date(start_date), "%Y%m%d").date() - end = datetime.strptime(normalize_date(end_date), "%Y%m%d").date() - if start > end: - raise ValueError("开始日期不能晚于结束日期。") - weekdays = [] - current = start - while current <= end: - if current.weekday() < 5: - weekdays.append(current) - current += timedelta(days=1) - if len(weekdays) > 15: - raise ValueError("单次最多回补 15 个工作日。") - results = [] - for day in weekdays: - dashboard = self.sync_dashboard(day.strftime("%Y%m%d")) + def backfill( + self, + start_date: str = "", + end_date: str = "", + *, + lookback: int | None = None, + dry_run: bool = False, + force: bool = False, + create_backup: bool = True, + ) -> dict[str, Any]: + """Backfill dashboard snapshots for real trading days only. + + - Date-range mode keeps the admin UI contract (max 15 open sessions). + - Recent mode fills the last N open sessions (default/max 60). + Weekends and holidays are reported as skipped non-trading days, not errors. + """ + if not self.configured: + raise ValueError("公共行情尚未配置,无法回补历史快照。") + normalized_end = normalize_date(end_date or date.today().isoformat()) + if lookback is not None or not (start_date and end_date): + target_lookback = ( + DEFAULT_RECENT_TRADING_DAYS if lookback is None else int(lookback) + ) + return self.backfill_recent_trading_days( + end_date=normalized_end, + lookback=target_lookback, + dry_run=dry_run, + force=force, + create_backup=create_backup, + ) + return self._backfill_date_range( + start_date=normalize_date(start_date), + end_date=normalized_end, + dry_run=dry_run, + force=force, + create_backup=create_backup, + ) + + def backfill_recent_trading_days( + self, + end_date: str = "", + lookback: int = DEFAULT_RECENT_TRADING_DAYS, + *, + dry_run: bool = False, + force: bool = False, + create_backup: bool = True, + ) -> dict[str, Any]: + normalized_end = normalize_date(end_date or date.today().isoformat()) + trade_dates = self._load_recent_open_trade_dates(normalized_end, lookback) + existing = self.database.list_snapshot_trade_dates( + trade_dates[0], trade_dates[-1] + ) + coverage = classify_snapshot_coverage(trade_dates, existing) + return self._execute_snapshot_backfill( + mode="recent", + end_date=normalized_end, + lookback=lookback, + coverage=coverage, + skipped_non_trading_days=[], + dry_run=dry_run, + force=force, + create_backup=create_backup, + ) + + def _backfill_date_range( + self, + start_date: str, + end_date: str, + *, + dry_run: bool = False, + force: bool = False, + create_backup: bool = True, + ) -> dict[str, Any]: + window_start = calendar_window_start(end_date, MAX_RANGE_TRADING_DAYS) + calendar_rows = self._tushare_client().query( + "trade_cal", + { + "exchange": "SSE", + "start_date": min(window_start, start_date), + "end_date": end_date, + }, + "cal_date,is_open,pretrade_date", + ) + trade_dates, skipped = select_open_trade_dates_in_range( + calendar_rows, + start_date, + end_date, + maximum=MAX_RANGE_TRADING_DAYS, + ) + if not trade_dates: + raise ValueError("选定区间内没有交易日,周末或节假日无需回补。") + existing = self.database.list_snapshot_trade_dates(trade_dates[0], trade_dates[-1]) + coverage = classify_snapshot_coverage(trade_dates, existing) + return self._execute_snapshot_backfill( + mode="range", + end_date=end_date, + lookback=None, + coverage=coverage, + skipped_non_trading_days=skipped, + dry_run=dry_run, + force=force, + create_backup=create_backup, + ) + + def _load_recent_open_trade_dates(self, end_date: str, lookback: int) -> list[str]: + start_date = calendar_window_start(end_date, lookback) + calendar_rows = self._tushare_client().query( + "trade_cal", + { + "exchange": "SSE", + "start_date": start_date, + "end_date": end_date, + }, + "cal_date,is_open,pretrade_date", + ) + return select_open_trade_dates(calendar_rows, end_date, lookback) + + def _execute_snapshot_backfill( + self, + *, + mode: str, + end_date: str, + lookback: int | None, + coverage: dict[str, Any], + skipped_non_trading_days: list[str], + dry_run: bool, + force: bool, + create_backup: bool, + ) -> dict[str, Any]: + targets = list(coverage["trade_dates"] if force else coverage["missing"]) + backup_path: str | None = None + if create_backup and not dry_run and targets: + backup = create_sqlite_backup( + Path(self.database.path), + DATA_DIR / "backups", + label=f"pre-{mode}-backfill", + ) + backup_path = str(backup) + + results: list[dict[str, Any]] = [] + if dry_run: + for trade_date in coverage["trade_dates"]: + exists = trade_date in coverage["present"] + if exists and not force: + status = "skipped" + action = "exists" + else: + status = "planned" + action = "refresh" if exists else "create" + results.append( + { + "requested_date": display_date(trade_date), + "trade_date": display_date(trade_date), + "status": status, + "action": action, + } + ) + return build_backfill_audit( + mode=mode, + end_date=end_date, + lookback=lookback, + coverage=coverage, + skipped_non_trading_days=skipped_non_trading_days, + backup_path=backup_path, + dry_run=True, + results=results, + ) + + present_before = set(coverage["present"]) + for trade_date in targets: + existed = trade_date in present_before + try: + dashboard = self.sync_dashboard(trade_date) + actual = normalize_date( + str(dashboard.get("meta", {}).get("trade_date") or trade_date) + ) + results.append( + { + "requested_date": display_date(trade_date), + "trade_date": display_date(actual), + "status": "success", + "action": "refreshed" if existed else "created", + "source": dashboard.get("meta", {}).get("source"), + "records": self._record_count(dashboard), + } + ) + except Exception as exc: + results.append( + { + "requested_date": display_date(trade_date), + "trade_date": display_date(trade_date), + "status": "failed", + "action": "refresh" if existed else "create", + "error": str(exc), + } + ) + + for trade_date in coverage["present"]: + if force: + continue results.append( { - "requested_date": day.isoformat(), - "trade_date": dashboard["meta"]["trade_date"], - "source": dashboard["meta"]["source"], - "records": self._record_count(dashboard), + "requested_date": display_date(trade_date), + "trade_date": display_date(trade_date), + "status": "skipped", + "action": "exists", } ) - return results + + results.sort(key=lambda row: str(row.get("requested_date") or "")) + return build_backfill_audit( + mode=mode, + end_date=end_date, + lookback=lookback, + coverage=coverage, + skipped_non_trading_days=skipped_non_trading_days, + backup_path=backup_path, + dry_run=False, + results=results, + ) def _stock_identity(self, code: str, trade_date: str) -> tuple[str, str]: snapshot = self.database.get_snapshot(trade_date) or {} @@ -955,4 +1168,3 @@ class MarketServiceMixin: len(dashboard.get(key) or []) for key in ("limits", "broken", "down_limits", "yesterday_limits") ) - diff --git a/backend/features/system/http.py b/backend/features/system/http.py index 531cceb..036bf39 100644 --- a/backend/features/system/http.py +++ b/backend/features/system/http.py @@ -26,13 +26,15 @@ class SystemHttpMixin: def start_background_refresh(self) -> None: try: body = self.read_json_body(allow_empty=True) - started = self.application_service.request_background_sync( + refresh = self.application_service.request_background_sync( str(body.get("trade_date") or date.today().isoformat()) ) + started = bool(refresh.get("started")) self.send_json( { "ok": True, "started": started, + "job_key": str(refresh.get("job_key") or ""), "message": "后台刷新已开始" if started else "已有后台刷新任务正在运行", }, HTTPStatus.ACCEPTED, diff --git a/backend/features/system/routes.py b/backend/features/system/routes.py index 5956cc9..1e0d840 100644 --- a/backend/features/system/routes.py +++ b/backend/features/system/routes.py @@ -29,11 +29,17 @@ class SystemRoutesMixin: def backfill_data(self) -> None: try: body = self.read_json_body() - results = self.application_service.backfill( + lookback_raw = body.get("lookback") + lookback = int(lookback_raw) if lookback_raw not in (None, "") else None + audit = self.application_service.backfill( str(body.get("start_date") or ""), str(body.get("end_date") or ""), + lookback=lookback, + dry_run=bool(body.get("dry_run")), + force=bool(body.get("force")), + create_backup=body.get("create_backup", True) is not False, ) - self.send_json({"ok": True, "results": results}) + self.send_json({"ok": True, **audit, "results": audit.get("results") or []}) except ValueError as exc: self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) except Exception as exc: diff --git a/backend/http/dispatch.py b/backend/http/dispatch.py index 3b58526..3c31e5c 100644 --- a/backend/http/dispatch.py +++ b/backend/http/dispatch.py @@ -7,6 +7,8 @@ from urllib.parse import urlparse PUBLIC_POST_HANDLERS = { "/api/auth/register": "auth_register", "/api/auth/login": "auth_login", + "/api/auth/switch": "auth_switch", + "/api/auth/forget": "auth_forget", } AUTHENTICATED_POST_HANDLERS = { diff --git a/backend/http/handler.py b/backend/http/handler.py index c95e484..2a906fc 100644 --- a/backend/http/handler.py +++ b/backend/http/handler.py @@ -9,7 +9,13 @@ from http.cookies import SimpleCookie from typing import Any from urllib.parse import unquote -from backend.bootstrap.config import SESSION_COOKIE, SESSION_MAX_AGE, STATIC_DIR +from backend.bootstrap.config import ( + DEVICE_COOKIE, + DEVICE_MAX_AGE, + SESSION_COOKIE, + SESSION_MAX_AGE, + STATIC_DIR, +) from backend.features.accounts.security import token_hash from backend.http.context import correlation_id from backend.http.errors import normalize_error_payload @@ -21,15 +27,21 @@ class HttpTransportMixin: application_service: Any route_registry: Any - def session_token(self) -> str: + def cookie_value(self, name: str) -> str: cookie = SimpleCookie() try: cookie.load(self.headers.get("Cookie", "")) except Exception: return "" - morsel = cookie.get(SESSION_COOKIE) + morsel = cookie.get(name) return morsel.value if morsel else "" + def session_token(self) -> str: + return self.cookie_value(SESSION_COOKIE) + + def device_token(self) -> str: + return self.cookie_value(DEVICE_COOKIE) + def require_auth(self, send_error: bool = True) -> bool: raw_token = self.session_token() service = self.application_service @@ -79,15 +91,18 @@ class HttpTransportMixin: return self.require_member() return True - def session_cookie(self, value: str, clear: bool = False) -> str: - max_age = 0 if clear else SESSION_MAX_AGE - cookie = ( - f"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}" - ) + def _cookie_header(self, name: str, value: str, max_age: int) -> str: + cookie = f"{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}" if self.headers.get("X-Forwarded-Proto", "").lower() == "https": cookie += "; Secure" return cookie + def session_cookie(self, value: str, clear: bool = False) -> str: + return self._cookie_header(SESSION_COOKIE, value, 0 if clear else SESSION_MAX_AGE) + + def device_cookie(self, value: str, clear: bool = False) -> str: + return self._cookie_header(DEVICE_COOKIE, value, 0 if clear else DEVICE_MAX_AGE) + def read_json_body(self, allow_empty: bool = False) -> dict[str, Any]: length = int(self.headers.get("Content-Length", "0")) if length == 0 and allow_empty: @@ -132,7 +147,7 @@ class HttpTransportMixin: self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK, - headers: dict[str, str] | None = None, + headers: dict[str, str] | list[tuple[str, str]] | tuple[tuple[str, str], ...] | None = None, ) -> None: request_id = getattr(self, "_correlation_id", "") if not request_id: @@ -145,7 +160,8 @@ class HttpTransportMixin: self.send_header("Content-Length", str(len(content))) self.send_header("Cache-Control", "no-store") self.send_header("X-Request-ID", request_id) - for name, value in (headers or {}).items(): + header_items = headers.items() if isinstance(headers, dict) else (headers or ()) + for name, value in header_items: self.send_header(name, value) self.end_headers() self.wfile.write(content) diff --git a/backend/jobs/service.py b/backend/jobs/service.py index 35bc31e..15dccc4 100644 --- a/backend/jobs/service.py +++ b/backend/jobs/service.py @@ -7,6 +7,16 @@ from datetime import date from backend.bootstrap.config import normalize_date +def _verified_dashboard_result(dashboard: dict[str, object]) -> dict[str, object]: + meta = dashboard.get("meta") or {} + if isinstance(meta, dict) and meta.get("carried_forward"): + return { + "status": "failed", + "error": str(meta.get("notice") or "未获取到所选日期的最新行情"), + } + return dashboard + + class JobServiceMixin: def start_background_jobs(self) -> threading.Thread: return self.jobs.start_scheduler( @@ -20,15 +30,16 @@ class JobServiceMixin: workers_stopped = self.jobs.wait_for_idle(timeout_seconds) return scheduler_stopped and workers_stopped - def request_background_sync(self, trade_date: str) -> bool: + def request_background_sync(self, trade_date: str) -> dict[str, object]: normalized = normalize_date(trade_date) key = f"manual:{normalized}:{time.time_ns()}" - return self.jobs.submit( + started = self.jobs.submit( "market.refresh", key, - lambda: self.sync_dashboard(normalized), + lambda: _verified_dashboard_result(self.sync_dashboard(normalized)), {"trade_date": normalized, "trigger": "administrator"}, ) + return {"started": started, "job_key": key if started else ""} def _background_refresh_tick(self) -> None: if not ( diff --git a/config/api.config.json b/config/api.config.json index 02fd77f..dc88d77 100644 --- a/config/api.config.json +++ b/config/api.config.json @@ -128,6 +128,20 @@ "feature": "auction", "access": "authenticated" }, + { + "method": "GET", + "path": "/api/auth/accounts", + "match": "exact", + "feature": "auth", + "access": "public" + }, + { + "method": "POST", + "path": "/api/auth/forget", + "match": "exact", + "feature": "auth", + "access": "public" + }, { "method": "POST", "path": "/api/auth/login", @@ -156,6 +170,13 @@ "feature": "auth", "access": "public" }, + { + "method": "POST", + "path": "/api/auth/switch", + "match": "exact", + "feature": "auth", + "access": "public" + }, { "method": "POST", "path": "/api/backfill", diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index e725ad6..a15d82c 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -10,10 +10,10 @@ }, "counts": { "primary_pages": 16, - "api_exact_paths": 53, + "api_exact_paths": 56, "api_prefixes": 0, "api_patterns": 11, - "database_tables": 36, + "database_tables": 37, "frontend_page_fragments": 12 }, "pages": [ @@ -96,10 +96,13 @@ "/api/assistant/chat", "/api/assistant/messages", "/api/auction", + "/api/auth/accounts", + "/api/auth/forget", "/api/auth/login", "/api/auth/logout", "/api/auth/me", "/api/auth/register", + "/api/auth/switch", "/api/backfill", "/api/chart/intraday", "/api/dashboard", @@ -189,6 +192,7 @@ "assistant_messages", "heaven_readings", "job_runs", + "account_switch_grants", "schema_migrations" ], "background_job_methods": [ @@ -370,11 +374,11 @@ } ], "css_layers": [ - "/shared/tokens.css?v=20260820-3", + "/shared/tokens.css?v=20260829-hel240", "/shared/base.css?v=20260806-1", - "/shared/shell.css?v=20260820-8", - "/shared/auth.css?v=20260820-5", - "/shared/components/controls.css?v=20260820-2", + "/shared/shell.css?v=20260829-hel237", + "/shared/auth.css?v=20260829-hel240b", + "/shared/components/controls.css?v=20260829-hel237", "/shared/components/navigation.css?v=20260820-1", "/shared/components/cards.css?v=20260820-1", "/shared/components/tables.css?v=20260820-1", @@ -390,8 +394,8 @@ "/pages/popularity/foundation.css?v=20260820-1", "/pages/dragon-tiger/foundation.css?v=20260820-1", "/pages/screener/foundation.css?v=20260820-4", - "/pages/mentor/foundation.css?v=20260820-2", - "/pages/heaven/foundation.css?v=20260806-2", + "/pages/mentor/foundation.css?v=20260827-hel183", + "/pages/heaven/foundation.css?v=20260827-hel183", "/pages/review/foundation.css?v=20260820-4" ], "frontend_composition": { @@ -436,8 +440,8 @@ "code_hotspots": [ { "path": "frontend/pages/heaven/foundation.css", - "bytes": 185936, - "lines": 11734 + "bytes": 182616, + "lines": 11494 }, { "path": "frontend/pages/screener/foundation.css", @@ -446,13 +450,13 @@ }, { "path": "frontend/pages/heaven/page.js", - "bytes": 97189, - "lines": 2069 + "bytes": 97268, + "lines": 2070 }, { "path": "frontend/shared/shell.css", - "bytes": 63550, - "lines": 3757 + "bytes": 63659, + "lines": 3763 }, { "path": "backend/features/heaven/engine.py", @@ -461,8 +465,8 @@ }, { "path": "frontend/index.html", - "bytes": 47871, - "lines": 661 + "bytes": 48254, + "lines": 664 }, { "path": "backend/features/screener/catalog.py", @@ -486,8 +490,8 @@ }, { "path": "backend/data/providers/tushare_dashboard.py", - "bytes": 28051, - "lines": 644 + "bytes": 28234, + "lines": 648 }, { "path": "backend/data/providers/tushare_industries.py", @@ -501,8 +505,8 @@ }, { "path": "frontend/pages/heaven/page.html", - "bytes": 19747, - "lines": 262 + "bytes": 19885, + "lines": 269 }, { "path": "frontend/pages/screener/page.html", @@ -541,18 +545,23 @@ }, { "path": "frontend/shared/admin.js", - "bytes": 14145, - "lines": 261 + "bytes": 14410, + "lines": 268 }, { "path": "backend/features/heaven/market_context.py", "bytes": 13681, "lines": 338 }, + { + "path": "frontend/shared/dashboard.js", + "bytes": 12894, + "lines": 274 + }, { "path": "frontend/shared/session.js", - "bytes": 13176, - "lines": 293 + "bytes": 12848, + "lines": 283 }, { "path": "backend/features/market/insights_auction_data.py", @@ -574,11 +583,6 @@ "bytes": 10539, "lines": 244 }, - { - "path": "frontend/shared/dashboard.js", - "bytes": 9993, - "lines": 220 - }, { "path": "backend/data/providers/tushare_sectors.py", "bytes": 9876, @@ -716,8 +720,8 @@ }, { "path": "backend/http/dispatch.py", - "bytes": 4118, - "lines": 115 + "bytes": 4196, + "lines": 117 }, { "path": "frontend/shared/table.js", @@ -734,16 +738,16 @@ "bytes": 3369, "lines": 81 }, + { + "path": "frontend/app.js", + "bytes": 3337, + "lines": 95 + }, { "path": "frontend/pages/themes/page.html", "bytes": 3316, "lines": 55 }, - { - "path": "frontend/app.js", - "bytes": 3201, - "lines": 93 - }, { "path": "backend/features/market/insights_context.py", "bytes": 3175, @@ -761,7 +765,7 @@ }, { "path": "backend/features/accounts/application.py", - "bytes": 2442, + "bytes": 2514, "lines": 63 }, { @@ -769,6 +773,11 @@ "bytes": 2299, "lines": 57 }, + { + "path": "backend/jobs/service.py", + "bytes": 2219, + "lines": 60 + }, { "path": "backend/features/screener/regime.py", "bytes": 2202, @@ -800,9 +809,9 @@ "lines": 45 }, { - "path": "backend/jobs/service.py", - "bytes": 1746, - "lines": 49 + "path": "backend/features/system/routes.py", + "bytes": 1791, + "lines": 46 }, { "path": "backend/features/alerts/routes.py", @@ -829,11 +838,6 @@ "bytes": 1455, "lines": 48 }, - { - "path": "backend/features/system/routes.py", - "bytes": 1423, - "lines": 40 - }, { "path": "backend/features/themes/routes.py", "bytes": 1337, @@ -849,6 +853,11 @@ "bytes": 1143, "lines": 19 }, + { + "path": "backend/features/accounts/routes.py", + "bytes": 908, + "lines": 25 + }, { "path": "backend/features/popularity/routes.py", "bytes": 822, @@ -859,11 +868,6 @@ "bytes": 817, "lines": 23 }, - { - "path": "backend/features/accounts/routes.py", - "bytes": 803, - "lines": 22 - }, { "path": "backend/features/sentiment/routes.py", "bytes": 724, diff --git a/docs/maintenance/行情历史补档.md b/docs/maintenance/行情历史补档.md new file mode 100644 index 0000000..c92d4c4 --- /dev/null +++ b/docs/maintenance/行情历史补档.md @@ -0,0 +1,69 @@ +# 行情历史补档(最近 60 个交易日) + +用于修复 `dashboard_snapshots` 断档导致情绪周期 / 主题轮动 / 智能选股只剩当天的问题。 +保留 `latest_contiguous_history` 连续性规则;通过真实交易日历回补缺失交易日快照。 + +## 适用场景 + +- 库中已有稀疏历史快照,但最近一个真实交易日缺失,接口 `available_days=1`。 +- 需要可重复执行、可审计、可回退的补档,而不是迁库或放宽算法。 + +## 前置 + +1. 使用与线上一致的代码分支。 +2. 管理员账号已配置可用的公共 Tushare Token。 +3. 只操作目标环境自己的 `data/review.db`;禁止 `.36` 与 `.11` 互拷。 + +## 上线步骤(总工执行) + +在目标环境容器内执行(应用根目录;宿主机也可直接跑,脚本已自带仓库根 `sys.path` 引导): + +```bash +# 1) 只读规划:区分已有、真正缺档;不会写入 +docker compose exec xiaobai-review python tools/backfill_recent_snapshots.py --account <管理员账号> --lookback 60 --dry-run --json + +# 2) 正式补档:先走 SQLite backup API 写 data/backups/review-pre-recent-backfill-*.db +# 再对缺失交易日调用现有 sync_dashboard +docker compose exec xiaobai-review python tools/backfill_recent_snapshots.py --account <管理员账号> --lookback 60 --json + +# 3) 验证 +# GET /api/sentiment/history?trade_date=YYYY-MM-DD&limit=60 +# 期望 available_days >= 20,且不再只有 1 天 +``` + +管理端日期区间回补(`/api/backfill`)已改为只处理交易日历中的开市日,周末/节假日会进入 +`skipped_non_trading_days`,不再当成错误;单次仍限制 15 个交易日。最近 60 日请用本工具。 + +## 写入边界 + +只会通过现有同步路径写入: + +- `dashboard_snapshots` +- 同步审计表 `sync_runs` +- 必要时的 `data_snapshots`(仅当请求日被解析到其他交易日) + +不得改动用户、Token、模型绑定或系统配置表。 + +## 回滚 + +1. 优先按审计结果的 `created_dates` 精确删除新增行: + +```sql +DELETE FROM dashboard_snapshots WHERE trade_date IN ('YYYYMMDD', ...); +``` + +2. 若需整库回退,停止写入后用补档前备份覆盖: + +```bash +# 示例:把 data/backups/review-pre-recent-backfill-YYYYMMDD-HHMMSS.db +# 复制回 data/review.db 后重启容器 +``` + +3. 代码回退:对该提交执行 Git revert 后重新部署镜像。 + +## 验收要点 + +- dry-run 与正式执行可重复跑;已有交易日默认跳过。 +- 周末、节假日出现在 `skipped_non_trading_days`,不计入失败。 +- 部分交易日同步失败时,其他日期仍会继续,并在审计结果中标 `failed`。 +- 情绪周期、主题轮动 9 列、智能选股置信度随连续交易日恢复。 diff --git a/frontend/app.js b/frontend/app.js index 0fbfd37..90fbb7f 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -25,8 +25,10 @@ async function initialize() { try { const session = await apiRequest("/api/auth/me"); if (!session.authenticated) { - if (session.registration_required) selectAuthMode("register"); - showAuthGate(); + const params = new URLSearchParams(); + if (session.registration_required) params.set("mode", "register"); + const query = params.toString(); + window.location.replace("/login/" + (query ? `?${query}` : "")); return; } await applyAuthenticatedSession(session); diff --git a/frontend/index.html b/frontend/index.html index 2d85bfa..f9fcef3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -24,7 +24,9 @@ (() => { let theme = "light"; try { - theme = localStorage.getItem("xiaobaiTheme") === "dark" ? "dark" : "light"; + const stored = localStorage.getItem("xiaobaiTheme"); + if (stored === "dark" || stored === "light") theme = stored; + else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) theme = "dark"; } catch (_error) { theme = "light"; } @@ -32,11 +34,11 @@ document.documentElement.style.colorScheme = theme; })(); - + - - - + + + @@ -52,12 +54,12 @@ - - + + -
+