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 @@ - - + +
-所有用户读取同一份后台快照,页面不会随后台任务自动重绘。
+${escapeHtml(lead)}
`, + '${escapeHtml(hint)}
`, + ].join(""); + } + + function accountRow(account) { + const current = Number(account.user_id) === Number(state.currentUserId); + const confirming = Number(state.confirmingId) === Number(account.user_id); + const managing = state.view === "manage"; + const classes = [ + "login-account-row", + current ? "is-current" : "", + confirming ? "is-confirming" : "", + !managing && !current ? "is-switchable" : "", + ].filter(Boolean).join(" "); + if (managing && confirming) { + return [ + `移除「${escapeHtml(account.username)}」的本机记录?
`, + '${managing + ? "移除只删除这台电脑上的登录记录,不会注销账号" + : `这台电脑已记录 ${count} 个账号,点选即可进入,无需再次输入密码。`}
`, + managing + ? '' + : "", + `${escapeHtml(state.error)}
` : "", + managing + ? '移除后再次登录该账号需重新输入密码
' + : '账号记录仅保存在这台电脑的浏览器中
', + ].join(""); + } + + function render() { + card.classList.toggle("is-loading", state.loading); + if (state.view === "first" || state.view === "add") { + card.innerHTML = formMarkup({ + title: state.view === "add" ? "添加账号" : "欢迎回来", + lead: state.view === "add" ? "登录另一个账号,添加后可随时一键切换" : "登录后进入你的复盘空间", + hint: state.view === "add" + ? "添加后账号会保存在这台电脑,方便随时切换。" + : "密码连续输错 5 次将锁定 10 分钟。还没有账号?切换到「注册」创建。", + add: state.view === "add", + back: state.view === "add", + }); + } else { + card.innerHTML = pickerMarkup(); + } + bindCard(); + } + + function bindCard() { + card.querySelectorAll("[data-auth-mode]").forEach((button) => { + button.addEventListener("click", () => { + state.mode = button.dataset.authMode === "register" ? "register" : "login"; + setError(""); + render(); + }); + }); + card.querySelectorAll("[data-login-action]").forEach((button) => { + button.addEventListener("click", () => { + const action = button.dataset.loginAction; + if (action === "picker") { + state.view = state.accounts.length ? "picker" : "first"; + state.confirmingId = null; + } else if (action === "add") { + state.view = "add"; + state.mode = "login"; + } else if (action === "manage") { + state.view = "manage"; + } else if (action === "cancel-forget") { + state.confirmingId = null; + } + setError(""); + render(); + }); + }); + card.querySelectorAll("[data-switch-id]").forEach((button) => { + button.addEventListener("click", () => switchAccount(Number(button.dataset.switchId))); + }); + card.querySelectorAll("[data-confirm-id]").forEach((button) => { + button.addEventListener("click", (event) => { + event.stopPropagation(); + state.confirmingId = Number(button.dataset.confirmId); + render(); + }); + }); + card.querySelectorAll("[data-forget-id]").forEach((button) => { + button.addEventListener("click", () => forgetAccount(Number(button.dataset.forgetId))); + }); + const form = card.querySelector("#loginForm"); + if (form) form.addEventListener("submit", submitCredentials); + } + + async function loadAccounts() { + const payload = await api.request("/api/auth/accounts"); + state.accounts = payload.accounts || []; + state.currentUserId = payload.current_user_id ?? null; + const params = new URLSearchParams(global.location.search); + if (params.get("mode") === "register") state.mode = "register"; + if (params.get("notice")) setError(params.get("notice")); + if (state.accounts.length) state.view = "picker"; + else state.view = "first"; + } + + async function submitCredentials(event) { + event.preventDefault(); + const username = document.querySelector("#loginUsername").value.trim(); + const password = document.querySelector("#loginPassword").value; + state.username = username; + state.password = password; + if (state.mode === "register" && password !== document.querySelector("#loginPasswordConfirm").value) { + setError("两次输入的密码不一致。"); + render(); + return; + } + state.loading = true; + setError(""); + render(); + try { + await api.request(`/api/auth/${state.mode}`, "POST", { username, password }); + enterApp(); + } catch (error) { + state.loading = false; + setError(error.message || "账号操作失败"); + render(); + } + } + + async function switchAccount(userId) { + state.loading = true; + setError(""); + render(); + try { + await api.request("/api/auth/switch", "POST", { user_id: userId }); + enterApp(); + } catch (error) { + state.loading = false; + setError(error.message || "该账号需重新验证"); + render(); + } + } + + async function forgetAccount(userId) { + try { + await api.request("/api/auth/forget", "POST", { user_id: userId }); + state.accounts = state.accounts.filter((item) => Number(item.user_id) !== Number(userId)); + state.confirmingId = null; + if (!state.accounts.length) state.view = "first"; + setError(""); + render(); + } catch (error) { + setError(error.message || "移除失败"); + render(); + } + } + + themeButton.addEventListener("click", () => { + applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark", true); + }); + applyTheme(preferredTheme(), false); + + loadAccounts() + .then(render) + .catch((error) => { + setError(error.message || "无法连接本地服务"); + state.view = "first"; + render(); + }); +})(window); diff --git a/frontend/m/css/features.css b/frontend/m/css/features.css index 79addc8..baf3745 100644 --- a/frontend/m/css/features.css +++ b/frontend/m/css/features.css @@ -3174,3 +3174,458 @@ border-top: 1px solid var(--border); transform: translateY(calc(-1 * var(--m-keyboard-inset, 0px))); } + +.m-sys-lead { + margin: 8px 0 12px; + font-size: var(--font-size-label); + color: var(--text-secondary); + line-height: 1.5; +} + +.m-sys-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin: 0 0 12px; +} + +.m-sys-grid div { + padding: 12px; + border-radius: 8px; + background: var(--surface-muted); +} + +.m-sys-grid span { + display: block; + font-size: var(--font-size-label); + color: var(--text-tertiary); +} + +.m-sys-grid strong { + display: block; + margin-top: 4px; + font-size: var(--font-size-body); + color: var(--text-primary); +} + +.m-sys-home { + padding: 0 0 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.m-sys-body { + padding: 12px 12px 20px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.m-sys-home .m-card, +.m-sys-body .m-card { + margin-left: 0; + margin-right: 0; +} + +.m-sys-profile-card { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 14px; +} + +.m-sys-avatar { + flex: 0 0 auto; + width: 48px; + height: 48px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: var(--action-soft); + color: var(--action); + font-size: var(--font-size-page-title); + font-weight: 600; +} + +.m-sys-profile-card strong { + display: block; + font-size: var(--font-size-card-title); + color: var(--text-primary); +} + +.m-sys-profile-meta { + margin: 4px 0 8px; + font-size: var(--font-size-caption); + color: var(--text-secondary); +} + +.m-sys-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.m-sys-badge { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 8px; + border-radius: 999px; + font-size: var(--font-size-aux); + font-weight: 600; + background: var(--surface-muted); + color: var(--text-secondary); +} + +.m-sys-badge--admin { + background: var(--action-soft); + color: var(--action); +} + +.m-sys-badge--ok { + background: var(--market-down-soft); + color: var(--market-down); +} + +.m-sys-group-title { + margin: 4px 0 0; + font-size: var(--font-size-caption); + font-weight: 600; + color: var(--text-tertiary); +} + +.m-sys-list { + padding: 0; + overflow: hidden; +} + +.m-sys-row { + width: 100%; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.m-sys-row + .m-sys-row { + border-top: 1px solid var(--border); +} + +.m-sys-row:active { + background: var(--surface-hover); +} + +.m-sys-row-icon { + flex: 0 0 auto; + width: 36px; + height: 36px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--action-soft); + color: var(--action); +} + +.m-sys-row--danger .m-sys-row-icon { + background: var(--market-up-soft); + color: var(--market-up); +} + +.m-sys-row-body { + flex: 1; + min-width: 0; +} + +.m-sys-row-body strong { + display: block; + font-size: var(--font-size-body); + font-weight: 600; + color: var(--text-primary); +} + +.m-sys-row-body small { + display: block; + margin-top: 2px; + font-size: var(--font-size-caption); + color: var(--text-secondary); +} + +.m-sys-row-chevron { + flex: 0 0 auto; + color: var(--text-tertiary); +} + +.m-sys-foot { + margin: 8px 0 0; + text-align: center; + font-size: var(--font-size-caption); + color: var(--text-tertiary); +} + +.m-sys-notice { + padding: 12px; + border-radius: 12px; + background: var(--surface-muted); +} + +.m-sys-notice p { + margin: 0; + font-size: var(--font-size-label); + color: var(--text-secondary); + line-height: 1.55; +} + +.m-sys-notice .m-sys-badges { + margin-top: 8px; +} + +.m-sys-section { + padding: 12px; +} + +.m-sys-section > strong, +.m-sys-section-title { + display: block; + margin-bottom: 10px; + font-size: var(--font-size-card-title); + color: var(--text-primary); +} + +.m-sys-hint { + margin: 8px 0 0; + font-size: var(--font-size-caption); + color: var(--text-tertiary); + line-height: 1.45; +} + +.m-sys-status-list { + display: grid; + gap: 8px; +} + +.m-sys-status-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: var(--font-size-body); + color: var(--text-primary); +} + +.m-sys-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-tertiary); +} + +.m-sys-dot--ok { + background: var(--market-down); +} + +.m-sys-switch-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.m-sys-switch-row strong { + display: block; + font-size: var(--font-size-body); + margin-bottom: 0; +} + +.m-btn-outline, +.m-btn-outline-danger { + width: 100%; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: 8px; + background: var(--surface); + font-size: var(--font-size-body); + font-weight: 600; + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.m-btn-outline { + border: 1px solid var(--action); + color: var(--action); +} + +.m-btn-outline:active { + background: var(--action-soft); +} + +.m-btn-outline-danger { + border: 1px solid var(--market-up); + color: var(--market-up); +} + +.m-btn-outline-danger:active { + background: var(--market-up-soft); +} + +.m-btn-outline:disabled, +.m-btn-outline-danger:disabled { + opacity: 0.45; + cursor: default; + pointer-events: none; +} + +.m-sys-model-card .m-sys-badges, +.m-sys-user-row .m-sys-badges { + margin-top: 6px; +} + +.m-sys-model-card { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 12px; + border: 0; + background: transparent; + text-align: left; + cursor: pointer; +} + +.m-sys-model-card + .m-sys-model-card { + border-top: 1px solid var(--border); +} + +.m-sys-model-card:active { + background: var(--surface-hover); +} + +.m-sys-user-row { + display: flex; + align-items: center; + gap: 10px; + padding: 12px; +} + +.m-sys-user-row + .m-sys-user-row { + border-top: 1px solid var(--border); +} + +.m-sys-user-row .m-btn-outline { + width: auto; + height: 32px; + padding: 0 12px; + flex: 0 0 auto; +} + +.m-sys-pair { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.m-sheet-root.is-dialog .m-sheet { + display: none; +} + +.m-dialog { + position: absolute; + left: 50%; + top: 42%; + width: calc(100% - 48px); + max-width: 320px; + transform: translate(-50%, -46%) scale(0.96); + border-radius: 12px; + background: var(--surface); + box-shadow: var(--elevation-float); + padding: 18px 16px 14px; + z-index: 3; + opacity: 0; + transition: opacity var(--motion-enter) var(--ease-enter), + transform var(--motion-enter) var(--ease-enter); +} + +.m-sheet-root.is-open .m-dialog { + opacity: 1; + transform: translate(-50%, -50%) scale(1); +} + +.m-dialog h2 { + margin: 0 0 8px; + font-size: var(--font-size-card-title); + color: var(--text-primary); + text-align: center; +} + +.m-dialog p { + margin: 0 0 16px; + font-size: var(--font-size-label); + color: var(--text-secondary); + line-height: 1.5; + text-align: center; +} + +.m-dialog-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.m-dialog-actions .m-btn-outline, +.m-dialog-actions .m-btn-outline-danger, +.m-dialog-actions .m-btn-primary { + height: 40px; +} + +.m-sys-sheet-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 12px; +} + +.m-sys-test-row { + display: flex; + align-items: center; + gap: 10px; + margin: 8px 0 12px; +} + +.m-sys-test-row .m-btn-outline { + width: auto; + flex: 0 0 auto; + height: 36px; + padding: 0 12px; +} + +.m-sys-test-row [data-model-test-status] { + flex: 1; + font-size: var(--font-size-caption); + color: var(--text-secondary); +} + +.m-sys-hero { + padding: 14px; +} + +.m-sys-hero strong { + display: block; + font-size: var(--font-size-page-title); + margin-bottom: 6px; +} + +.m-page[data-page^="system/"] .m-card { + margin-bottom: 0; +} diff --git a/frontend/m/css/shell.css b/frontend/m/css/shell.css index 1dab4dd..6ecf0b7 100644 --- a/frontend/m/css/shell.css +++ b/frontend/m/css/shell.css @@ -246,6 +246,29 @@ body { padding-top: 8px; } +.m-auth-accounts { + display: grid; + gap: 8px; + margin-bottom: 16px; +} + +.m-auth-account { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 48px; + padding: 0 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + color: var(--text-primary); +} + +.m-auth-account span { + color: var(--action); + font-size: var(--font-size-label); +} + .m-auth-brand { text-align: center; margin: 24px 0 20px; @@ -313,7 +336,8 @@ body { color: var(--text-secondary); } -.m-form-field input { +.m-form-field input, +.m-form-field select { height: 44px; padding: 0 12px; border: 1px solid var(--border-strong); @@ -323,11 +347,20 @@ body { font-size: 14px; } -.m-form-field input:focus { +.m-form-field input:focus, +.m-form-field select:focus { outline: none; border-color: var(--action); } +.m-form-field input[type="checkbox"] { + width: 22px; + height: 22px; + padding: 0; + border: none; + background: transparent; +} + .m-auth-error { margin: 0 0 12px; padding: 10px 12px; diff --git a/frontend/m/index.html b/frontend/m/index.html index 63c6916..5ef084f 100644 --- a/frontend/m/index.html +++ b/frontend/m/index.html @@ -15,7 +15,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"; } diff --git a/frontend/m/js/boot.js b/frontend/m/js/boot.js index 9ecc085..8e015b2 100644 --- a/frontend/m/js/boot.js +++ b/frontend/m/js/boot.js @@ -5,10 +5,15 @@ function readTheme() { try { - return global.localStorage.getItem(THEME_KEY) === "dark" ? "dark" : "light"; + const stored = global.localStorage.getItem(THEME_KEY); + if (stored === "dark" || stored === "light") return stored; } catch (_error) { return "light"; } + if (global.matchMedia && global.matchMedia("(prefers-color-scheme: dark)").matches) { + return "dark"; + } + return "light"; } function applyTheme(theme) { diff --git a/frontend/m/js/pages.js b/frontend/m/js/pages.js index 6ca154f..6f353dc 100644 --- a/frontend/m/js/pages.js +++ b/frontend/m/js/pages.js @@ -90,6 +90,15 @@ "calendar-check": '" + escapeHtml(body) + "
" : "") + + '仅管理员可访问
" + + "系统设置和会员管理需要管理员权限。" + + "' + (global.MobileSession.isAdmin() ? "小白复盘 · 内网个人版" : "系统设置与会员管理仅管理员可见") + "
" + + "出生信息仅对当前账号可见并加密保存。原始信息不会在观气页回显,智能解读只使用排盘后的派生结果。
' + + '删除后智能解读将无法使用出生信息,执行前会再次确认。
' + + "仅修改当前账号的登录密码,密码不会保存在这台设备上。修改成功后下次登录需使用新密码。
建议字母与数字混合,不要与其他网站重复。
') + + formFieldHtml("确认新密码", '', true, '') + + '' + + "' + escapeHtml(detail) + "
会员每日智能分析额度 ' + number(access.daily_limit) + " 次,每日 0 点自动重置,由管理员统一设置。
全部用户可用:行情、搜索、自选股、交易日志与复盘。
' + + '会员专属:智能选股、问师、问天、复盘助手等智能功能。
开启后后台定时更新快照
所有用户读取同一份快照,刷新不影响当前页面内容。
回补用于补齐缺失的历史行情,开始前会再次确认;回补期间页面可正常使用。
主模型不可用时自动改用辅助模型
' + + '点任意模型卡片进入编辑:改名称、地址、密钥、测试连接或删除
' + + '模型池为空,请先添加模型
' + escapeHtml(expiry) + " · 今日已用 " + number(user.used_today) + " 次
暂无注册用户
对所有会员生效,每日 0 点自动重置。
' + + '点「管理」为对应账号开通、续期或停用会员。
当前状态:' + escapeHtml(expiry) + " · 今日已用 " + number(user.used_today) + " 次
" + + formFieldHtml("会员状态", '", false) + + formFieldHtml("开通 / 续期时长", '', false) + + '从当前时间开始顺延;已有会员则叠加续期。
' + + '登录后进入你的复盘空间
', "遇事不决可问春风,春风不语即随本心
diff --git a/frontend/pages/heaven/page.js b/frontend/pages/heaven/page.js index f3b41ef..ff108bb 100644 --- a/frontend/pages/heaven/page.js +++ b/frontend/pages/heaven/page.js @@ -1363,7 +1363,8 @@ async function interpretHeaven(mode) { } catch (error) { stopHeavenReadingAnimation(); state.heavenReadingLoading = false; - state.heavenReadingError = error.message || "问天解读失败"; + const detail = error?.payload?.message || error?.payload?.error || error.message; + state.heavenReadingError = detail || "问天解读失败"; renderHeavenReadingDialog(); showHeavenNotice(state.heavenReadingError); showToast(state.heavenReadingError); diff --git a/frontend/pages/mentor/foundation.css b/frontend/pages/mentor/foundation.css index e517a5e..355dcec 100644 --- a/frontend/pages/mentor/foundation.css +++ b/frontend/pages/mentor/foundation.css @@ -49,7 +49,7 @@ body[data-active-view="mentorView"] .app-page-context span { height: 100%; min-height: 0; flex-direction: column; - padding: 0; + padding: var(--page-pad-y) 0 0; color: var(--qp-text-1); font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; } diff --git a/frontend/pages/sentiment/foundation.css b/frontend/pages/sentiment/foundation.css index 6accf89..8300c5d 100644 --- a/frontend/pages/sentiment/foundation.css +++ b/frontend/pages/sentiment/foundation.css @@ -1011,9 +1011,9 @@ border-radius: 6px; - background: var(--r2-ink); + background: var(--sentiment-tooltip-bg); - color: var(--text-inverse); + color: var(--sentiment-tooltip-fg); font-size: 11px; @@ -1021,6 +1021,8 @@ } .sentiment-chart-tooltip b { + color: inherit; + font-weight: 700; } @@ -1568,10 +1570,6 @@ #sentimentCycleView .sentiment-component-item { padding: 4px 0px; } - - #sentimentCycleView .sentiment-component-item small { - display: none; - } } @media (min-width: 768px) { @@ -1764,3 +1762,72 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } } + +@media (min-width: 1024px) { + #sentimentCycleView .redesigned-emotion-grid { + align-items: stretch; + + height: var(--sentiment-analysis-height); + + max-height: var(--sentiment-analysis-height); + + overflow: hidden; + } + + .redesigned-sentiment-view .sentiment-analysis-main, + .redesigned-sentiment-view .sentiment-analysis-rail { + align-self: stretch; + + height: 100%; + + min-height: 0px; + } + + .redesigned-sentiment-view .sentiment-trend-panel { + display: flex; + + flex: 1 1 auto; + + flex-direction: column; + + min-height: 0px; + } + + .redesigned-sentiment-view .sentiment-chart-shell { + flex: 1 1 auto; + + height: auto; + + min-height: 0px; + } + + .redesigned-sentiment-view .sentiment-chart-shell canvas { + height: 100%; + } + + .redesigned-sentiment-view .sentiment-cycle-summary { + flex: 0 0 auto; + } + + .redesigned-sentiment-view .sentiment-components-panel { + display: flex; + + flex: 1 1 auto; + + flex-direction: column; + + min-height: 0px; + } + + .redesigned-sentiment-view .sentiment-component-list { + display: flex; + + flex: 1 1 auto; + + flex-direction: column; + + justify-content: space-evenly; + + min-height: 0px; + } +} diff --git a/frontend/pages/sentiment/page.js b/frontend/pages/sentiment/page.js index 135f5bb..d514b29 100644 --- a/frontend/pages/sentiment/page.js +++ b/frontend/pages/sentiment/page.js @@ -1,10 +1,39 @@ let sentimentChartAnimationFrame = null; +let sentimentChartResizeObserver = null; +let sentimentChartLastSize = ""; window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], { bind: bindSentimentEvents, enter: ["loadSentiment"], }); +function sentimentChartSizeKey(target) { + if (!target) return ""; + const rect = target.getBoundingClientRect(); + return `${Math.round(rect.width)}x${Math.round(rect.height)}x${window.devicePixelRatio || 1}`; +} + +function observeSentimentTrendChart() { + const shell = document.querySelector("#sentimentCycleView .sentiment-chart-shell"); + if (!shell) return; + if (!sentimentChartResizeObserver) { + sentimentChartResizeObserver = new ResizeObserver(() => { + if (sentimentChartAnimationFrame) return; + if (state.activeView !== "sentimentCycleView") return; + const rows = state.sentimentHistory?.rows; + if (!rows?.length) return; + const current = document.querySelector("#sentimentCycleView .sentiment-chart-shell"); + const nextKey = sentimentChartSizeKey(current); + if (!nextKey || nextKey === sentimentChartLastSize) return; + drawSentimentTrendChart(rows, 1); + }); + } else { + sentimentChartResizeObserver.disconnect(); + } + sentimentChartLastSize = sentimentChartSizeKey(shell); + sentimentChartResizeObserver.observe(shell); +} + async function loadSentimentHistory(force = false) { if (!state.dashboard || state.sentimentLoading) return; const key = `${elements.tradeDate.value}:${state.sentimentRange}`; @@ -126,6 +155,7 @@ function animateSentimentTrendChart(rows) { if (sentimentChartAnimationFrame) cancelAnimationFrame(sentimentChartAnimationFrame); if (!motionEnabled()) { drawSentimentTrendChart(rows, 1); + observeSentimentTrendChart(); return; } const startedAt = performance.now(); @@ -135,7 +165,10 @@ function animateSentimentTrendChart(rows) { const progress = 1 - (1 - rawProgress) ** 3; drawSentimentTrendChart(rows, progress); if (rawProgress < 1) sentimentChartAnimationFrame = requestAnimationFrame(frame); - else sentimentChartAnimationFrame = null; + else { + sentimentChartAnimationFrame = null; + observeSentimentTrendChart(); + } }; sentimentChartAnimationFrame = requestAnimationFrame(frame); } @@ -144,9 +177,9 @@ function drawSentimentTrendChart(rows, progress = 1) { const canvas = document.querySelector("#sentimentTrendChart"); if (!canvas || !rows.length || state.activeView !== "sentimentCycleView") return; const rect = canvas.getBoundingClientRect(); - if (!rect.width) return; - const width = Math.max(320, rect.width); - const height = Math.max(220, rect.height); + if (rect.width < 8 || rect.height < 8) return; + const width = rect.width; + const height = rect.height; const ratio = window.devicePixelRatio || 1; canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); @@ -258,6 +291,7 @@ function drawSentimentTrendChart(rows, progress = 1) { const dateText = displayCompactDate(row.trade_date).slice(5); context.fillText(dateText, x(index), height - padding.bottom + 10); }); + sentimentChartLastSize = sentimentChartSizeKey(canvas.closest(".sentiment-chart-shell")); } function bindSentimentChartTooltip(rows) { diff --git a/frontend/shared/admin.js b/frontend/shared/admin.js index 50dbbd2..cebe715 100644 --- a/frontend/shared/admin.js +++ b/frontend/shared/admin.js @@ -7,7 +7,14 @@ async function backfillData() { start_date: document.querySelector("#backfillStart").value, end_date: document.querySelector("#backfillEnd").value, }); - showToast(`历史回补完成,共处理 ${payload.results.length} 个工作日`); + const failed = (payload.failed_count || 0); + const skipped = (payload.skipped_non_trading_days || []).length; + const suffix = failed + ? `,失败 ${failed} 个` + : skipped + ? `,跳过 ${skipped} 个非交易日` + : ""; + showToast(`历史回补完成,共处理 ${payload.results.length} 个交易日${suffix}`); state.sentimentHistory = null; state.sentimentHistoryKey = ""; if (state.activeView === "sentimentCycleView") { diff --git a/frontend/shared/api.js b/frontend/shared/api.js index 262a41d..1d68232 100644 --- a/frontend/shared/api.js +++ b/frontend/shared/api.js @@ -46,12 +46,29 @@ } } + function readableRequestError(error) { + const message = String(error?.message || ""); + if ( + error instanceof TypeError + || /failed to fetch|networkerror|load failed|network request failed/i.test(message) + ) { + return "网络请求失败,服务暂时不可用,请稍后重试。"; + } + return message || "请求失败"; + } + async function request(url, method = "GET", body = null, options = {}) { - const response = await fetch(url, requestOptions(method, body, options.signal)); + let response; + try { + response = await fetch(url, requestOptions(method, body, options.signal)); + } catch (error) { + throw new ApiError(readableRequestError(error), 0, null); + } const payload = await parseJson(response); handleUnauthorized(response, url); if (!response.ok || payload.error) { - throw new ApiError(payload.error || "请求失败", response.status, payload); + const message = payload.message || payload.error || "请求失败"; + throw new ApiError(message, response.status, payload); } return payload; } diff --git a/frontend/shared/auth.css b/frontend/shared/auth.css index 424d982..a983552 100644 --- a/frontend/shared/auth.css +++ b/frontend/shared/auth.css @@ -471,6 +471,32 @@ line-height: 1.6; } +.admin-refresh-status { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 12px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-muted); + color: var(--text-muted); + font-size: 12px; + line-height: 1.55; +} + +.admin-refresh-status svg { + flex: 0 0 auto; + width: 15px; + height: 15px; + margin-top: 2px; +} + +.admin-refresh-status[data-tone="running"] { color: var(--primary); } +.admin-refresh-status[data-tone="success"] { color: var(--down); } +.admin-refresh-status[data-tone="warning"] { color: var(--warning); } +.admin-refresh-status[data-tone="failure"] { color: var(--up); } + .account-button > span { flex: 0 0 auto; @@ -1667,3 +1693,772 @@ button.account-role-badge:focus-visible { display: inline-flex; } } + + +body.login-portal { + padding-bottom: 0; +} + +.login-portal { + min-height: 100vh; + + display: flex; + + background: var(--canvas); + + color: var(--text-primary); +} + +.login-stage { + position: relative; + + flex: 1 1 auto; + + display: grid; + + place-items: center; + + padding: 48px 24px; +} + +.login-theme-toggle { + position: absolute; + + z-index: 2; + + top: 16px; + + right: 20px; + + min-height: 32px; + + padding: 0 12px; + + border: 1px solid var(--border-strong); + + border-radius: var(--size-radius-md); + + background: var(--surface); + + color: var(--text-secondary); + + font-size: var(--font-size-label); + + cursor: pointer; +} + +.login-brand { + position: relative; + + display: flex; + + flex-direction: column; + + width: var(--login-brand-share); + + min-width: var(--login-brand-min); + + max-width: var(--login-brand-cap); + + flex: 0 0 var(--login-brand-share); + + overflow: hidden; + + padding: var(--login-brand-pad); + + background: var(--login-brand-gradient); + + color: #f4f7ff; +} + +.login-brand-header { + display: flex; + + align-items: center; + + gap: 12px; +} + +.login-brand-mark { + display: flex; + + flex: 0 0 var(--login-brand-mark-size); + + align-items: center; + + justify-content: center; + + width: var(--login-brand-mark-size); + + height: var(--login-brand-mark-size); + + border-radius: 12px; + + background: rgba(255, 255, 255, 0.12); +} + +.login-brand-glyph { + font-size: 20px; + + font-weight: var(--font-weight-semibold); +} + +.login-brand-identity { + display: grid; + + gap: 2px; +} + +.login-brand-name { + margin: 0; + + font-size: var(--login-brand-name-size); + + font-weight: var(--font-weight-semibold); +} + +.login-brand-subtitle { + margin: 0; + + font-size: var(--font-size-caption); + + letter-spacing: 0.02em; + + opacity: 0.72; +} + +.login-brand-copy { + display: flex; + + flex: 1 1 auto; + + flex-direction: column; + + justify-content: flex-end; + + padding: 24px 0 20px; +} + +.login-brand-kicker { + display: inline-flex; + + align-self: flex-start; + + margin: 0 0 14px; + + padding: 4px 10px; + + border: 1px solid rgba(255, 255, 255, 0.18); + + border-radius: 999px; + + font-size: var(--font-size-caption); + + letter-spacing: 0.08em; + + opacity: 0.86; +} + +.login-brand-title { + margin: 0; + + max-width: 12.4em; + + font-size: var(--login-hero-size); + + font-weight: var(--font-weight-semibold); + + line-height: 1.35; +} + +.login-brand-lead { + margin: 12px 0 0; + + max-width: 28em; + + font-size: var(--font-size-label); + + line-height: 1.7; + + opacity: 0.78; +} + +.login-brand-market { + position: relative; + + min-height: 168px; +} + +.login-brand-chart { + display: block; + + width: 100%; + + height: 168px; +} + +.login-chart-area { + fill: url(#loginChartFade); +} + +.login-chart-line { + fill: none; + + stroke: var(--login-trend-line); + + stroke-width: 2; + + stroke-linecap: round; + + stroke-linejoin: round; +} + +.login-candles line { + stroke-width: 1.5; +} + +.login-candles .is-up line, +.login-candles .is-up rect { + fill: var(--login-candle-up); + + stroke: var(--login-candle-up); +} + +.login-candles .is-down line, +.login-candles .is-down rect { + fill: var(--login-candle-down); + + stroke: var(--login-candle-down); +} + +.login-brand-stats { + position: absolute; + + right: 0; + + bottom: 18px; + + left: 0; + + display: flex; + + flex-wrap: wrap; + + gap: 8px; + + margin: 0; +} + +.login-stat { + display: flex; + + align-items: baseline; + + gap: 6px; + + margin: 0; + + padding: 6px 10px; + + border-radius: 999px; + + background: var(--login-stat-chip-bg); +} + +.login-stat dt { + color: rgba(244, 247, 255, 0.64); + + font-size: var(--font-size-caption); +} + +.login-stat dd { + margin: 0; + + font-size: var(--font-size-body); + + font-weight: var(--font-weight-semibold); +} + +.login-stat:nth-child(1) dd { + color: #f0b45a; +} + +.login-stat:nth-child(2) dd { + color: var(--login-candle-up); +} + +.login-stat:nth-child(3) dd { + color: var(--login-candle-down); +} + +.login-stat-tag { + margin-left: 4px; + + font-size: var(--font-size-caption); + + font-weight: var(--font-weight-regular); +} + +.login-brand-disclaimer { + margin: 12px 0 0; + + font-size: var(--font-size-aux); + + letter-spacing: 0.02em; + + opacity: 0.48; +} + +.login-card { + width: var(--login-card-width); + + max-width: calc(100vw - 48px); + + padding: var(--login-card-pad); + + border: 1px solid var(--border-strong); + + border-radius: var(--size-radius-dialog); + + background: var(--surface); + + box-shadow: var(--shadow-raised); +} + +.login-card.is-loading .login-form { + pointer-events: none; + + opacity: 0.72; +} + +.login-card-title { + margin: 0; + + font-size: var(--login-card-title-size); + + font-weight: var(--font-weight-semibold); +} + +.login-card-lead { + margin: 8px 0 0; + + color: var(--text-secondary); + + font-size: var(--font-size-label); + + line-height: 1.6; +} + +.login-tabs { + display: grid; + + grid-template-columns: 1fr 1fr; + + margin-top: 24px; + + border-bottom: 1px solid var(--line); +} + +.login-tab { + min-height: 40px; + + border: 0; + + border-bottom: 2px solid transparent; + + background: transparent; + + color: var(--text-secondary); + + cursor: pointer; +} + +.login-tab.is-active { + border-bottom-color: var(--action); + + color: var(--action); + + font-weight: var(--font-weight-medium); +} + +.login-form { + display: grid; + + gap: 14px; + + margin-top: 20px; +} + +.login-portal .form-field input.is-invalid { + border-color: var(--danger); +} + +.login-submit { + width: 100%; + + min-height: var(--login-submit-height); +} + +.login-spinner { + width: 14px; + + height: 14px; + + border: 2px solid currentColor; + + border-right-color: transparent; + + border-radius: 50%; + + animation: login-spin 0.7s linear infinite; +} + +@keyframes login-spin { + to { + transform: rotate(360deg); + } +} + +.login-error { + margin: 0; + + color: var(--danger); + + font-size: var(--font-size-caption); +} + +.login-error[hidden] { + display: none; +} + +.login-hint, +.login-privacy { + margin: 16px 0 0; + + color: var(--text-tertiary); + + font-size: var(--font-size-caption); + + line-height: 1.6; +} + +.login-portal .login-privacy { + display: flex; + + align-items: center; + + gap: 6px; +} + +.login-lock { + font-size: 12px; +} + +.login-back, +.login-manage { + border: 0; + + background: transparent; + + color: var(--action); + + cursor: pointer; +} + +.login-portal .login-back { + margin-bottom: 12px; + + padding: 0; + + font-size: var(--font-size-label); +} + +.login-portal .login-add { + display: flex; + + align-items: center; + + justify-content: center; + + width: 100%; + + min-height: 44px; + + margin-top: 12px; + + border: 1px dashed var(--border-strong); + + border-radius: var(--size-radius-md); + + background: transparent; + + color: var(--action); + + cursor: pointer; +} + +.login-portal .login-manage { + display: block; + + width: 100%; + + min-height: 36px; + + margin-top: 8px; + + padding: 0; + + text-align: center; + + color: var(--text-secondary); +} + +.login-manage-toolbar { + display: flex; + + align-items: center; + + justify-content: space-between; + + gap: 12px; + + margin-top: 16px; +} + +.login-manage-hint { + margin: 0; + + color: var(--text-tertiary); + + font-size: var(--font-size-caption); +} + +.login-manage-done { + border: 0; + + background: transparent; + + color: var(--action); + + cursor: pointer; +} + +.login-account-list { + display: grid; + + gap: 10px; + + margin-top: 20px; +} + +.login-account-row { + display: grid; + + grid-template-columns: auto minmax(0, 1fr) auto; + + align-items: center; + + gap: 12px; + + min-height: var(--login-account-row-min); + + padding: 10px 14px; + + border: 1px solid var(--border); + + border-radius: var(--size-radius-md); + + background: var(--surface); +} + +.login-account-row.is-switchable { + cursor: pointer; +} + +.login-account-row.is-current { + border-color: var(--action); +} + +.login-account-row.is-confirming { + display: grid; + + grid-template-columns: minmax(0, 1fr); + + gap: 10px; + + padding: 12px 14px; + + border-color: var(--danger); +} + +.login-avatar { + display: grid; + + place-items: center; + + width: var(--login-account-avatar); + + height: var(--login-account-avatar); + + border-radius: 50%; + + color: #fff; + + font-size: var(--font-size-body); + + font-weight: var(--font-weight-semibold); +} + +.login-avatar.tone-0 { + background: var(--action); +} + +.login-avatar.tone-1 { + background: var(--market-up); +} + +.login-avatar.tone-2 { + background: var(--market-down); +} + +.login-avatar.tone-3 { + background: var(--warning); +} + +.login-account-meta { + display: grid; + + gap: 4px; + + min-width: 0; +} + +.login-account-name { + display: flex; + + flex-wrap: wrap; + + align-items: center; + + gap: 6px; +} + +.login-account-meta strong { + font-weight: var(--font-weight-medium); +} + +.login-account-used { + color: var(--text-tertiary); + + font-size: var(--font-size-caption); +} + +.login-chip { + display: inline-flex; + + align-items: center; + + min-height: 18px; + + padding: 0 6px; + + border-radius: 999px; + + background: var(--surface-muted); + + color: var(--text-secondary); + + font-size: var(--font-size-aux); +} + +.login-chip-current { + background: var(--action-soft); + + color: var(--action); +} + +.login-chip-member { + background: var(--warning-soft); + + color: var(--warning); +} + +.login-account-check { + color: var(--action); + + font-size: 16px; +} + +.login-account-remove { + display: grid; + + place-items: center; + + width: 28px; + + height: 28px; + + border: 0; + + background: transparent; + + color: var(--text-tertiary); + + cursor: pointer; +} + +.login-account-remove:hover { + color: var(--danger); +} + +.login-confirm-copy { + margin: 0; + + font-size: var(--font-size-label); +} + +.login-confirm-actions { + display: flex; + + gap: 8px; +} + +@media (min-width: 1921px) { + .login-brand { + width: var(--login-brand-wide-share); + + min-width: var(--login-brand-cap); + + max-width: none; + + flex-basis: var(--login-brand-wide-share); + } +} + +@media (max-width: 900px) { + .login-portal { + flex-direction: column; + } + + .login-brand { + width: 100%; + + min-width: 0; + + max-width: none; + + flex-basis: auto; + + padding: 20px 20px 16px; + } + + .login-brand-copy, + .login-brand-market, + .login-brand-disclaimer { + display: none; + } + + .login-stage { + padding: 28px 16px 40px; + } +} diff --git a/frontend/shared/components/controls.css b/frontend/shared/components/controls.css index 6ccc5d0..fe44fe4 100644 --- a/frontend/shared/components/controls.css +++ b/frontend/shared/components/controls.css @@ -660,7 +660,9 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide { text-align: left; - min-height: 38px; + height: var(--size-statusbar); + + min-height: var(--size-statusbar); display: flex; @@ -670,7 +672,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide { margin: auto -8px -8px; - padding: 10px 16px; + padding: 0 16px; border-right: 0px; @@ -691,6 +693,12 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide { font-size: 12px; } +.sidebar-collapse-button .lucide { + width: 14px; + + height: 14px; +} + body.sidebar-collapsed .sidebar-collapse-button span { display: none; } diff --git a/frontend/shared/dashboard.js b/frontend/shared/dashboard.js index 41e9e57..54b612f 100644 --- a/frontend/shared/dashboard.js +++ b/frontend/shared/dashboard.js @@ -40,17 +40,71 @@ async function loadDashboard(force = false, background = false, showOverlay = tr async function startAdminRefresh() { const buttons = [document.querySelector("#syncButton"), document.querySelector("#adminRefreshButton")].filter(Boolean); buttons.forEach((button) => { button.disabled = true; }); + const requestedDate = elements.tradeDate.value; + setAdminRefreshStatus("running", `正在刷新 ${requestedDate} 的行情,请稍候…`, "loader-circle"); try { - const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: elements.tradeDate.value }); - showToast(payload.message || "后台刷新已提交"); - setStatus("后台刷新运行中,当前页面保持不变"); + const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: requestedDate }); + if (!payload.started || !payload.job_key) { + setAdminRefreshStatus("warning", "已有刷新任务正在运行,请稍后再试。", "clock-3"); + showToast(payload.message || "已有后台刷新任务正在运行"); + return; + } + setStatus(`正在刷新 ${requestedDate} 的行情`); + const job = await waitForAdminRefresh(payload.job_key); + if (job.status === "failed") { + const reason = job.message || job.error_code || "数据源未返回结果"; + setAdminRefreshStatus("failure", `刷新失败:${reason}`, "circle-x"); + setStatus("后台刷新失败"); + showToast("后台刷新失败"); + return; + } + const query = new URLSearchParams({ trade_date: requestedDate }); + const dashboard = await apiRequest(`/api/dashboard?${query}`); + applyDashboard(dashboard); + const meta = dashboard.meta || {}; + const actualDate = String(meta.trade_date || "").slice(0, 10); + const requestedCompact = requestedDate.replaceAll("-", ""); + const actualCompact = actualDate.replaceAll("-", ""); + const updated = formatTimestamp(meta.updated_at); + if (actualCompact !== requestedCompact || meta.carried_forward) { + const reason = meta.notice ? `;${meta.notice}` : ""; + setAdminRefreshStatus("warning", `刷新已完成,但没有获取到 ${requestedDate} 的最新行情;当前仍是 ${actualDate || "未知日期"}${reason}`, "triangle-alert"); + showToast("刷新完成,但未获取到所选日期的最新行情"); + } else if (meta.notice) { + setAdminRefreshStatus("warning", `已刷新到 ${actualDate}(${updated}),但数据源提示:${meta.notice}`, "triangle-alert"); + showToast(`已刷新到 ${actualDate},请留意数据源提示`); + } else { + setAdminRefreshStatus("success", `刷新成功:已获取 ${actualDate} 的最新行情,更新时间 ${updated}`, "circle-check"); + showToast(`刷新成功:已获取 ${actualDate} 的最新行情`); + } } catch (error) { - showToast(error.message || "后台刷新启动失败"); + const message = error.message || "后台刷新失败"; + setAdminRefreshStatus("failure", `刷新失败:${message}`, "circle-x"); + setStatus("后台刷新失败"); + showToast(message); } finally { buttons.forEach((button) => { button.disabled = false; }); } } +function setAdminRefreshStatus(tone, message, icon = "circle-dot") { + const status = document.querySelector("#adminRefreshStatus"); + if (!status) return; + status.dataset.tone = tone; + status.innerHTML = `${escapeHtml(message)}`; + refreshIcons(); +} + +async function waitForAdminRefresh(jobKey) { + for (let attempt = 0; attempt < 120; attempt += 1) { + const payload = await apiRequest("/api/admin/settings"); + const job = (payload.data?.jobs || []).find((item) => item.idempotency_key === jobKey); + if (job && ["success", "failed"].includes(job.status)) return job; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error("刷新等待超时,请稍后重试"); +} + function applyDashboard(payload, background = false) { state.dashboard = payload; const selectedDate = payload.meta.requested_date || payload.meta.trade_date; diff --git a/frontend/shared/session.js b/frontend/shared/session.js index 1eaeafa..ac892b3 100644 --- a/frontend/shared/session.js +++ b/frontend/shared/session.js @@ -53,12 +53,10 @@ async function applyAuthenticatedSession(session) { function showAuthGate(message = "") { state.user = null; state.csrfToken = ""; - const gate = document.querySelector("#authGate"); - gate.hidden = false; - const errorElement = document.querySelector("#authError"); - errorElement.textContent = message; - errorElement.hidden = !message; - document.querySelector("#authUsername").focus(); + const params = new URLSearchParams(); + if (message) params.set("notice", message); + const query = params.toString(); + window.location.replace("/login/" + (query ? `?${query}` : "")); } async function logoutAccount() { @@ -197,16 +195,8 @@ async function changeAccountPassword(event) { } async function switchAccount() { - const button = document.querySelector("#switchAccountMenuButton"); - button.disabled = true; toggleAccountDropdown(false); - try { - await apiRequest("/api/auth/logout", "POST", {}); - window.location.reload(); - } catch (error) { - showToast(error.message || "切换账号失败"); - button.disabled = false; - } + window.location.assign("/login/"); } diff --git a/frontend/shared/shell.css b/frontend/shared/shell.css index 0de2241..d76bd79 100644 --- a/frontend/shared/shell.css +++ b/frontend/shared/shell.css @@ -1294,7 +1294,9 @@ body.sidebar-collapsed { } .sidebar-brand { - min-height: 55px; + height: var(--size-topbar); + + min-height: var(--size-topbar); display: flex; @@ -1304,7 +1306,7 @@ body.sidebar-collapsed { margin: 0px -8px 7px; - padding: 0px 16px; + padding: 0 16px; border-bottom: 1px solid var(--r2-line-soft); @@ -2146,13 +2148,17 @@ body.sidebar-collapsed .status-bar { } .module-nav .sidebar-brand { + height: var(--size-topbar); + + min-height: var(--size-topbar); + display: flex; align-items: center; gap: 8px; - padding: 14px 16px; + padding: 0 16px; border-bottom: 1px solid var(--line-soft); } @@ -3494,7 +3500,7 @@ body.sidebar-collapsed .status-bar { flex: 0 0 auto; align-items: center; gap: var(--header-action-gap); - margin-left: 0; + margin-left: auto; overflow: visible; } diff --git a/frontend/shared/tokens.css b/frontend/shared/tokens.css index 5391ed9..ba04e4e 100644 --- a/frontend/shared/tokens.css +++ b/frontend/shared/tokens.css @@ -146,6 +146,25 @@ --shadow-float: var(--elevation-float); --duration-fast: 150ms; --duration-normal: 220ms; + --login-brand-gradient: linear-gradient(165deg, #0c1e4a, #16307c, #2153cc); + --login-brand-share: 34%; + --login-brand-min: 420px; + --login-brand-cap: 560px; + --login-brand-wide-share: 29.2%; + --login-brand-pad: 36px 40px 24px; + --login-brand-mark-size: 44px; + --login-brand-name-size: 16px; + --login-hero-size: 28px; + --login-card-width: 408px; + --login-card-pad: 32px; + --login-card-title-size: 22px; + --login-account-row-min: 72px; + --login-account-avatar: 40px; + --login-submit-height: 40px; + --login-stat-chip-bg: rgba(8, 12, 24, 0.48); + --login-candle-up: #e07078; + --login-candle-down: #3db88a; + --login-trend-line: rgba(244, 247, 255, 0.88); --font-size-aux: 11.5px; --font-size-caption: 12.5px; @@ -210,6 +229,9 @@ --pool-table-max-height: calc(var(--content-height) - var(--topbar-height) - var(--page-pad-y) - var(--page-pad-y) - var(--card-gap)); --sentiment-history-max-height: 510px; --sentiment-history-min-height: 220px; + --sentiment-analysis-height: 600px; + --sentiment-tooltip-bg: var(--text-primary); + --sentiment-tooltip-fg: var(--text-inverse); --primary-share: 1.45fr; --secondary-share: .75fr; --mobile-nav-height: 64px; @@ -505,10 +527,17 @@ --chart-repair: #e2ad58; --chart-ma-10: #d39a45; --chart-ma-20: #9aa5af; + --sentiment-tooltip-bg: #26293e; + --sentiment-tooltip-fg: #e8eaed; --on-action: #101418; --warning-line: #6d5a38; --warning-line-strong: #66502d; --control-shadow: 0 1px 3px rgba(0, 0, 0, .3); + --login-brand-gradient: linear-gradient(165deg, #080c18, #0e1730, #14224a); + --login-stat-chip-bg: rgba(6, 8, 16, 0.58); + --login-candle-up: #f06d73; + --login-candle-down: #43bc8a; + --login-trend-line: rgba(232, 236, 244, 0.9); --dialog-backdrop: var(--backdrop); --ladder-level-1: #2d2426; --ladder-level-2: #2b2822; diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index 9ceaa8b..b3422a5 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -47,6 +47,72 @@ function session(role = "admin", subscribed = true) { }; } +function sentimentHistoryPayload(days = 20) { + const phases = ["冰点", "修复", "发酵", "高潮", "分化", "退潮"]; + const rows = Array.from({ length: days }, (_, index) => { + const score = 28 + ((index * 7) % 55); + return { + trade_date: `2026-08-${String(index + 1).padStart(2, "0")}`, + score, + label: "情绪观察", + phase: phases[index % phases.length], + direction: index % 2 ? "升温" : "降温", + day_change: index % 2 ? 3.2 : -2.1, + seal_rate: 71.5, + limit_up_count: 40 + index, + first_board_count: 18, + second_board_count: 8, + three_plus_count: 4, + max_height: 5, + broken_count: 12, + limit_down_count: 3, + previous_limit_count: 38, + previous_positive_count: 22, + previous_positive_rate: 57.9, + average_previous_change: 1.2, + normalization: "固定锚点", + components: { + breadth: { label: "市场宽度", score: 55.8, weight: 20, summary: "红盘家数回升" }, + limit: { label: "涨停连板", score: 79.6, weight: 25, summary: "连板生态改善" }, + profit: { label: "赚钱效应", score: 67.0, weight: 30, summary: "昨日反馈尚可" }, + ladder: { label: "涨幅结构", score: 81.5, weight: 15, summary: "高度仍在扩张" }, + amount: { label: "成交活跃度", score: 46.1, weight: 10, summary: "量能略低于均值" }, + }, + }; + }); + return { available_days: days, rows }; +} + +async function renderSentimentFixture(page, days = 20) { + await page.evaluate((payload) => { + state.sentimentHistory = payload; + renderSentimentHistory(); + }, sentimentHistoryPayload(days)); + await page.locator('[data-view="sentimentCycleView"]').first().click(); + await page.waitForTimeout(900); +} + +function readSentimentLayout() { + const analysis = document.querySelector(".redesigned-emotion-grid").getBoundingClientRect(); + const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect(); + const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect(); + const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect(); + const chart = document.querySelector(".sentiment-chart-shell").getBoundingClientRect(); + const detail = document.querySelector(".sentiment-detail-toolbar").getBoundingClientRect(); + const table = document.querySelector(".sentiment-history-frame").getBoundingClientRect(); + const small = document.querySelector(".sentiment-component-item small"); + return { + topDelta: Math.abs(trend.top - summary.top), + bottomDelta: Math.abs(trend.bottom - components.bottom), + analysisHeight: analysis.height, + chartHeight: chart.height, + detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom) - 0.5, + tableVisible: table.top < window.innerHeight && table.bottom > detail.bottom, + smallVisible: small ? getComputedStyle(small).display !== "none" : false, + overflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + }; +} + function waitForApplicationRuntime(page) { return expect(page.locator("body")).toHaveAttribute("data-runtime-ready", "true"); } @@ -721,6 +787,7 @@ test("collapsed overview and sentiment layout keep a single current reading", as await expect(page.locator(".sentiment-stage-guide, [data-sentiment-stage]")).toHaveCount(0); await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。"); const alignment = await page.evaluate(() => { + const analysis = document.querySelector(".redesigned-emotion-grid").getBoundingClientRect(); const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect(); const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect(); const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect(); @@ -732,6 +799,8 @@ test("collapsed overview and sentiment layout keep a single current reading", as const statusStyle = getComputedStyle(document.querySelector(".sentiment-block .sentiment-text")); return { columnsAligned: Math.abs(trend.top - summary.top) < 1, + bottomsAligned: Math.abs(trend.bottom - components.bottom) < 1, + analysisHeight: analysis.height, railAligned: Math.abs(summary.x - components.x) < 1 && Math.abs(summary.width - components.width) < 1 && components.top > summary.bottom, detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom), chartHeight: chart.height, @@ -743,6 +812,8 @@ test("collapsed overview and sentiment layout keep a single current reading", as }; }); expect(alignment.columnsAligned).toBe(true); + expect(alignment.bottomsAligned).toBe(true); + expect(Math.abs(alignment.analysisHeight - 600)).toBeLessThanOrEqual(1); expect(alignment.railAligned).toBe(true); expect(alignment.detailAfterAnalysis).toBe(true); expect(alignment.chartHeight).toBeGreaterThanOrEqual(340); @@ -780,6 +851,68 @@ test("collapsed overview and sentiment layout keep a single current reading", as } }); +test("sentiment cycle keeps 600px equal-height layout across zoom viewports", async ({ page }, testInfo) => { + const shotDir = testInfo.outputPath("hel-221-shots"); + await mockApplication(page, session("user", true)); + const viewports = [ + { name: "zoom-100", width: 2560, height: 1440 }, + { name: "zoom-110", width: 2327, height: 1309 }, + { name: "zoom-125", width: 2048, height: 1152 }, + ]; + + for (const viewport of viewports) { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await page.goto("/index.html"); + await renderSentimentFixture(page, 20); + const layout = await page.evaluate(readSentimentLayout); + expect(layout.topDelta, viewport.name).toBeLessThanOrEqual(1); + expect(layout.bottomDelta, viewport.name).toBeLessThanOrEqual(1); + expect(Math.abs(layout.analysisHeight - 600), viewport.name).toBeLessThanOrEqual(1); + expect(layout.chartHeight, viewport.name).toBeGreaterThanOrEqual(450); + expect(layout.detailAfterAnalysis, viewport.name).toBe(true); + expect(layout.tableVisible, viewport.name).toBe(true); + expect(layout.smallVisible, viewport.name).toBe(true); + expect(layout.overflowX, viewport.name).toBe(false); + await page.screenshot({ + path: `${shotDir}/day-${viewport.name}.png`, + fullPage: true, + }); + } + + await page.setViewportSize({ width: 2560, height: 1440 }); + await page.locator("#themeToggle").click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await page.waitForTimeout(200); + const nightLayout = await page.evaluate(readSentimentLayout); + expect(nightLayout.topDelta).toBeLessThanOrEqual(1); + expect(nightLayout.bottomDelta).toBeLessThanOrEqual(1); + expect(Math.abs(nightLayout.analysisHeight - 600)).toBeLessThanOrEqual(1); + await page.locator("#sentimentTrendChart").hover({ position: { x: 280, y: 120 } }); + const tooltip = page.locator("#sentimentChartTooltip"); + await expect(tooltip).toBeVisible(); + await expect(tooltip).toContainText("温度"); + const tooltipStyle = await tooltip.evaluate((node) => { + const style = getComputedStyle(node); + const bold = getComputedStyle(node.querySelector("b") || node); + return { background: style.backgroundColor, color: style.color, bold: bold.color }; + }); + expect(tooltipStyle.background).toBe("rgb(38, 41, 62)"); + expect(tooltipStyle.color).toBe("rgb(232, 234, 237)"); + expect(tooltipStyle.bold).toBe("rgb(232, 234, 237)"); + await page.screenshot({ path: `${shotDir}/night-zoom-100.png`, fullPage: true }); + + await page.locator("#themeToggle").click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + await page.locator("#sentimentTrendChart").hover({ position: { x: 280, y: 120 } }); + await expect(tooltip).toBeVisible(); + const lightTooltip = await tooltip.evaluate((node) => { + const style = getComputedStyle(node); + return { background: style.backgroundColor, color: style.color }; + }); + expect(lightTooltip.background).toBe("rgb(31, 35, 41)"); + expect(lightTooltip.color).toBe("rgb(255, 255, 255)"); +}); + test("limit-up pool separates stock identity and restores the reason column", async ({ page }) => { await mockApplication(page, session("user", true)); await page.goto("/index.html"); @@ -3735,3 +3868,99 @@ test("desktop header keeps commands in view and tape text unclipped across works await page.screenshot({ path: path.join(shotDir, "admin-390-night.png") }); fs.writeFileSync(path.join(shotDir, "measurements.json"), `${JSON.stringify(measurements, null, 2)}\n`); }); + +test("HEL-183 heaven tools right-align and shell heights unify", async ({ page }) => { + const fs = require("node:fs"); + const path = require("node:path"); + const shotDir = path.join(__dirname, "../../runtime/hel183-shots"); + fs.mkdirSync(shotDir, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await mockApplication(page, session("admin", true)); + await page.goto("/index.html"); + + const measure = () => page.evaluate(() => { + const box = (node) => { + if (!node) return null; + const r = node.getBoundingClientRect(); + return { x: r.x, y: r.y, right: r.right, width: r.width, height: r.height }; + }; + const brand = document.querySelector(".module-nav .sidebar-brand") || document.querySelector(".sidebar-brand"); + const header = document.querySelector(".app-header"); + const actions = document.querySelector(".header-actions"); + const collapse = document.querySelector(".sidebar-collapse-button"); + const status = document.querySelector(".status-bar"); + const overview = document.querySelector(".overview-strip"); + const brandBox = box(brand); + const headerBox = box(header); + const actionsBox = box(actions); + const collapseBox = box(collapse); + const statusBox = box(status); + return { + brandHeight: brandBox ? Math.round(brandBox.height) : null, + headerHeight: headerBox ? Math.round(headerBox.height) : null, + brandBottom: brandBox ? Math.round(brandBox.y + brandBox.height) : null, + headerBottom: headerBox ? Math.round(headerBox.y + headerBox.height) : null, + collapseHeight: collapseBox ? Math.round(collapseBox.height) : null, + statusHeight: statusBox ? Math.round(statusBox.height) : null, + actionsNearRight: actionsBox && headerBox ? (headerBox.right - actionsBox.right) < 24 : false, + actionsMarginLeft: actions ? getComputedStyle(actions).marginLeft : null, + overviewDisplay: overview ? getComputedStyle(overview).display : null, + mentorPadTop: (() => { + const mentor = document.querySelector("#mentorView"); + return mentor ? getComputedStyle(mentor).paddingTop : null; + })(), + }; + }); + + await page.locator('[data-view="sentimentCycleView"]').first().click(); + await expect(page.locator("#sentimentCycleView")).toHaveClass(/active-view/); + let geo = await measure(); + expect(geo.brandHeight, "logo height").toBe(64); + expect(geo.headerHeight, "header height").toBe(64); + expect(geo.brandBottom, "logo/header bottom align").toBe(geo.headerBottom); + expect(geo.collapseHeight, "collapse height").toBe(28); + expect(geo.statusHeight, "status height").toBe(28); + expect(geo.actionsNearRight, "sentiment tools right").toBe(true); + await page.locator(".app-header").screenshot({ path: path.join(shotDir, "sentiment-header-day.png") }); + await page.screenshot({ path: path.join(shotDir, "sentiment-page-day.png") }); + + await page.locator('[data-view="heavenView"]').first().click(); + await expect(page.locator("#heavenView")).toHaveClass(/active-view/); + await expect(page.locator("#heavenView .heaven-page-head")).toBeVisible(); + await expect(page.locator("#heavenView .heaven-page-head .segment")).toHaveCount(3); + geo = await measure(); + expect(geo.overviewDisplay, "heaven hides overview").toBe("none"); + expect(geo.actionsMarginLeft, "tools margin-left resolved").not.toBe("0px"); + expect(Number.parseFloat(geo.actionsMarginLeft), "tools left auto gap").toBeGreaterThan(40); + expect(geo.actionsNearRight, "heaven tools right").toBe(true); + expect(geo.brandHeight).toBe(64); + expect(geo.collapseHeight).toBe(28); + await page.locator(".app-header").screenshot({ path: path.join(shotDir, "heaven-header-day.png") }); + await page.screenshot({ path: path.join(shotDir, "heaven-page-day.png") }); + + await page.locator('[data-heaven-panel="fortune"]').click(); + await expect(page.locator('[data-heaven-panel="fortune"]')).toHaveClass(/active/); + await expect(page.locator("#heavenFortunePanel")).toHaveClass(/active-heaven-panel/); + + await page.locator('[data-view="mentorView"]').first().click(); + await expect(page.locator("#mentorView")).toHaveClass(/active-view/); + geo = await measure(); + expect(geo.mentorPadTop, "mentor top padding").toBe("14px"); + await page.screenshot({ path: path.join(shotDir, "mentor-page-day.png") }); + + await page.locator("#themeToggle").click(); + await page.locator('[data-view="heavenView"]').first().click(); + await expect(page.locator("#heavenView")).toHaveClass(/active-view/); + geo = await measure(); + expect(geo.actionsNearRight, "heaven night tools right").toBe(true); + expect(geo.brandHeight).toBe(64); + await page.locator(".app-header").screenshot({ path: path.join(shotDir, "heaven-header-night.png") }); + await page.screenshot({ path: path.join(shotDir, "heaven-page-night.png") }); + + await page.locator('[data-view="sentimentCycleView"]').first().click(); + await page.locator(".app-header").screenshot({ path: path.join(shotDir, "sentiment-header-night.png") }); + await page.screenshot({ path: path.join(shotDir, "sentiment-page-night.png") }); + + await page.locator('[data-view="mentorView"]').first().click(); + await page.screenshot({ path: path.join(shotDir, "mentor-page-night.png") }); +}); diff --git a/tests/e2e/login-portal.spec.js b/tests/e2e/login-portal.spec.js new file mode 100644 index 0000000..95c61f8 --- /dev/null +++ b/tests/e2e/login-portal.spec.js @@ -0,0 +1,291 @@ +const { test, expect } = require("@playwright/test"); +const fs = require("fs"); +const path = require("path"); + +const SHOT_DIR = path.resolve(__dirname, "../../../verify-shots"); +fs.mkdirSync(SHOT_DIR, { recursive: true }); + +function loginPayload(user) { + return { + ok: true, + authenticated: true, + csrf_token: "portal-csrf", + user, + }; +} + +async function mockLoginPortal(page, options = {}) { + const accounts = options.accounts || []; + let currentUserId = options.currentUserId ?? null; + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()); + const method = route.request().method(); + if (url.pathname === "/api/auth/accounts") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ok: true, accounts, current_user_id: currentUserId }), + }); + return; + } + if (url.pathname === "/api/auth/switch" && method === "POST") { + const body = route.request().postDataJSON() || {}; + const account = accounts.find((item) => Number(item.user_id) === Number(body.user_id)); + if (!account || options.switchFails) { + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ error: "该账号需重新验证" }), + }); + return; + } + currentUserId = account.user_id; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(loginPayload(account)), + }); + return; + } + if (url.pathname === "/api/auth/forget" && method === "POST") { + const body = route.request().postDataJSON() || {}; + const index = accounts.findIndex((item) => Number(item.user_id) === Number(body.user_id)); + if (index >= 0) accounts.splice(index, 1); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ok: true }), + }); + return; + } + if ((url.pathname === "/api/auth/login" || url.pathname === "/api/auth/register") && method === "POST") { + if (options.loginDelay) await new Promise((resolve) => setTimeout(resolve, options.loginDelay)); + if (options.loginFails) { + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ error: "账号名或密码不正确,请重新输入。" }), + }); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(loginPayload({ + id: 9, + username: "new_user", + role: "user", + membership: { active: false, subscribed: false, is_admin: false }, + })), + }); + return; + } + if (url.pathname === "/api/auth/me") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ok: true, + authenticated: Boolean(currentUserId), + csrf_token: "portal-csrf", + user: accounts.find((item) => Number(item.user_id) === Number(currentUserId)) || null, + }), + }); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ok: true }), + }); + }); +} + +const SAVED_ACCOUNTS = [ + { + user_id: 1, + username: "alpha_user", + role: "admin", + membership: { active: true, subscribed: true, is_admin: true }, + last_used_at: "2026-08-29T01:00:00+00:00", + }, + { + user_id: 2, + username: "beta_user", + role: "user", + membership: { active: false, subscribed: false, is_admin: false }, + last_used_at: "2026-08-28T01:00:00+00:00", + }, +]; + +test("first-time login portal asks for a password and hides environment copy", async ({ page }) => { + await mockLoginPortal(page, { accounts: [] }); + await page.goto("/login/"); + await expect(page.locator(".login-card-title")).toHaveText("欢迎回来"); + await expect(page.locator("#loginUsername")).toBeVisible(); + await expect(page.locator(".login-submit")).toHaveText("登录"); + await expect(page.locator("body")).not.toContainText("内网个人版"); + await expect(page.locator("body")).not.toContainText("192.168.200.11"); +}); + +test("saved accounts can switch directly and show a re-auth message on failure", async ({ page }) => { + await mockLoginPortal(page, { accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })) }); + await page.goto("/login/"); + await expect(page.locator(".login-card-title")).toHaveText("选择账号"); + await expect(page.locator(".login-account-row")).toHaveCount(2); + const switched = page.waitForRequest((request) => ( + request.url().includes("/api/auth/switch") && request.method() === "POST" + )); + await page.locator('[data-switch-id="2"]').click(); + const request = await switched; + expect(JSON.parse(request.postData() || "{}")).toEqual({ user_id: 2 }); +}); + +test("failed account switch stays on the portal with the original copy", async ({ page }) => { + await mockLoginPortal(page, { + accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })), + switchFails: true, + }); + await page.goto("/login/"); + await page.locator('[data-switch-id="2"]').click(); + await expect(page.locator(".login-error")).toHaveText("该账号需重新验证"); + await expect(page).toHaveURL(/\/login\/?/); +}); + +test("managing accounts removes a local record after inline confirmation", async ({ page }) => { + await mockLoginPortal(page, { accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })) }); + await page.goto("/login/"); + await page.locator('[data-login-action="manage"]').click(); + await expect(page.locator(".login-card-title")).toHaveText("管理账号记录"); + await page.locator('[data-confirm-id="2"]').click(); + await expect(page.locator(".login-confirm-copy")).toContainText("beta_user"); + await page.locator('[data-forget-id="2"]').click(); + await expect(page.locator(".login-account-row")).toHaveCount(1); + await expect(page.locator(".login-account-row")).toContainText("alpha_user"); +}); + +async function assertConfirmedSkeleton(page, { width, height }) { + await expect(page.locator(".login-brand-title")).toHaveText("看懂情绪周期,把复盘变成下一次的先手。"); + await expect(page.locator(".login-brand-header")).toBeVisible(); + await expect(page.locator(".login-brand-chart")).toBeVisible(); + await expect(page.locator(".login-brand-stats")).toBeVisible(); + await expect(page.locator(".login-brand-kicker")).toHaveText("收盘之后 · 复盘开始"); + const brand = await page.locator(".login-brand").boundingBox(); + const header = await page.locator(".login-brand-header").boundingBox(); + const mark = await page.locator(".login-brand-mark").boundingBox(); + const name = await page.locator(".login-brand-name").boundingBox(); + const title = await page.locator(".login-brand-title").boundingBox(); + const stats = await page.locator(".login-brand-stats").boundingBox(); + const chart = await page.locator(".login-brand-chart").boundingBox(); + const card = await page.locator(".login-card").boundingBox(); + expect(brand).toBeTruthy(); + expect(header.y - brand.y).toBeLessThan(48); + expect(Math.abs(mark.y - name.y)).toBeLessThan(16); + expect(title.y).toBeGreaterThan(height * 0.28); + expect(title.y).toBeLessThan(height * 0.72); + expect(stats.y).toBeGreaterThan(height * 0.55); + expect(chart.height).toBeGreaterThan(80); + expect(card.width).toBeGreaterThan(380); + expect(card.width).toBeLessThan(450); + if (width === 1440) { + expect(brand.width).toBeGreaterThan(470); + expect(brand.width).toBeLessThan(520); + expect(brand.height).toBe(height); + } else if (width === 1920) { + expect(brand.width).toBeGreaterThan(540); + expect(brand.width).toBeLessThan(580); + } else { + expect(brand.width).toBeGreaterThan(560); + } +} + +async function openPortal(page, { theme, width, height, accounts, currentUserId, loginFails, loginDelay }) { + await page.addInitScript((nextTheme) => { + localStorage.setItem("xiaobaiTheme", nextTheme); + }, theme); + await page.setViewportSize({ width, height }); + await mockLoginPortal(page, { accounts, currentUserId, loginFails, loginDelay }); + await page.goto("/login/"); +} + +for (const theme of ["light", "dark"]) { + for (const [width, height] of [[1440, 900], [1920, 1080]]) { + test(`confirmed skeleton ${theme} ${width}x${height}`, async ({ page }) => { + await openPortal(page, { theme, width, height, accounts: [] }); + await assertConfirmedSkeleton(page, { width, height }); + await expect(page.locator("#loginThemeToggle")).toHaveText(theme === "dark" ? "☀ 日间" : "🌙 夜间"); + await page.screenshot({ path: path.join(SHOT_DIR, `first-${theme}-${width}.png`), fullPage: true }); + }); + } +} + +test("ultrawide keeps the left brand from collapsing into a strip", async ({ page }) => { + await openPortal(page, { theme: "dark", width: 2560, height: 1080, accounts: [] }); + await assertConfirmedSkeleton(page, { width: 2560, height: 1080 }); +}); + +test("picker add remove error and loading share the same desktop skeleton", async ({ page }) => { + const accounts = SAVED_ACCOUNTS.map((item) => ({ ...item })); + await openPortal(page, { + theme: "dark", + width: 1440, + height: 900, + accounts, + currentUserId: 1, + }); + await assertConfirmedSkeleton(page, { width: 1440, height: 900 }); + await expect(page.locator(".login-card-title")).toHaveText("选择账号"); + await expect(page.locator(".login-avatar")).toHaveCount(2); + await expect(page.locator(".login-add")).toBeVisible(); + await page.screenshot({ path: path.join(SHOT_DIR, "picker-dark-1440.png"), fullPage: true }); + + await page.locator('[data-login-action="add"]').click(); + await expect(page.locator(".login-card-title")).toHaveText("添加账号"); + await assertConfirmedSkeleton(page, { width: 1440, height: 900 }); + await page.screenshot({ path: path.join(SHOT_DIR, "add-dark-1440.png"), fullPage: true }); + + await page.locator('[data-login-action="picker"]').click(); + await page.locator('[data-login-action="manage"]').click(); + await expect(page.locator(".login-card-title")).toHaveText("管理账号记录"); + await page.locator('[data-confirm-id="2"]').click(); + await expect(page.locator(".login-confirm-copy")).toContainText("beta_user"); + await page.screenshot({ path: path.join(SHOT_DIR, "remove-dark-1440.png"), fullPage: true }); +}); + +test("login failure and loading keep the confirmed first-login skeleton", async ({ page }) => { + await openPortal(page, { + theme: "light", + width: 1440, + height: 900, + accounts: [], + loginFails: true, + }); + await page.locator("#loginUsername").fill("baiqizhi"); + await page.locator("#loginPassword").fill("wrong-password"); + await page.locator(".login-submit").click(); + await expect(page.locator(".login-error")).toContainText("账号名或密码不正确"); + await expect(page.locator("#loginPassword")).toHaveClass(/is-invalid/); + await assertConfirmedSkeleton(page, { width: 1440, height: 900 }); + await page.screenshot({ path: path.join(SHOT_DIR, "error-light-1440.png"), fullPage: true }); +}); + +test("loading button appears on the confirmed first-login skeleton", async ({ page }) => { + await openPortal(page, { + theme: "dark", + width: 1440, + height: 900, + accounts: [], + loginDelay: 2500, + }); + await page.evaluate(() => { + window.location.replace = () => {}; + }); + await page.locator("#loginUsername").fill("baiqizhi"); + await page.locator("#loginPassword").fill("password12"); + const submit = page.locator(".login-submit").click(); + await expect(page.locator(".login-submit")).toContainText("正在登录..."); + await expect(page.locator(".login-spinner")).toBeVisible(); + await assertConfirmedSkeleton(page, { width: 1440, height: 900 }); + await page.screenshot({ path: path.join(SHOT_DIR, "loading-dark-1440.png"), fullPage: true }); + await submit; +}); diff --git a/tests/e2e/mobile-app.spec.js b/tests/e2e/mobile-app.spec.js index 2a5869d..373c268 100644 --- a/tests/e2e/mobile-app.spec.js +++ b/tests/e2e/mobile-app.spec.js @@ -1,7 +1,7 @@ const { test, expect } = require("@playwright/test"); -// 手机端(/m/)全页面回归:P5 收官打磨。 -// 覆盖:登录、四个入口图标页、行情 12 页、工具 3 页、复盘 5 页、复盘助手, +// 手机端(/m/)全页面回归:P5 收官打磨 + HEL-233 系统管理恢复。 +// 覆盖:登录、四个入口图标页、行情 12 页、工具 3 页、复盘 5 页、复盘助手、系统管理 5 页, // 以及日夜两套渲染、空态、错误态、横屏健壮性、深底深字对比度抽查。 const EMPTY_DASHBOARD = { @@ -138,6 +138,36 @@ async function mockMobileApi(page, options = {}) { payload = { items: [] }; } else if (path === "/api/search") { payload = { groups: { stocks: [{ id: "002141", code: "002141", name: "贤丰控股", type: "stock", industry: "电子元件" }], sectors: [], themes: [], indices: [] } }; + } else if (path === "/api/auth/accounts") { + payload = { + accounts: [{ user_id: auth.user.id, username: auth.user.username, role: auth.user.role, last_used_at: "2026-07-22T09:12:00+08:00" }], + current_user_id: auth.user.id, + }; + } else if (path === "/api/account/status") { + payload = { + birth_profile_configured: true, + birth_profile: { birth_datetime: "1990-01-15T08:30", gender: "male" }, + llm_access: { + daily_limit: 50, + used_today: 3, + remaining_calls: 47, + membership: { + active: true, + subscribed: true, + is_admin: auth.user.role === "admin", + remaining_days: 20, + expires_at: "2026-09-18", + plan: "会员", + }, + }, + }; + } else if (path === "/api/admin/settings") { + payload = { + data: { configured: true, snapshot_dates: 12, background_refresh_enabled: true, ifind: { configured: false } }, + llm: { models: [{ id: "model-1", name: "主模型", base_url: "https://api.openai.com/v1", model: "gpt-4.1", configured: true }], primary_model_id: "model-1", fallback_model_id: "" }, + membership: { member_daily_limit: 50 }, + users: [{ id: 2, username: "normal_user", role: "user", membership_subscribed: true, membership_status: "active", membership_expires_at: "2026-09-18", used_today: 3 }], + }; } else if (/^\/api\/stock\/\d+\/preview$/.test(path)) { payload = { meta: { trade_date: "2026-07-22", intraday_status: "available" }, @@ -230,6 +260,16 @@ const REVIEW_PAGES = [ ["review/alerts", "提醒中心"], ]; +const SYSTEM_PAGES = [ + ["system/profile", "账号资料"], + ["system/password", "修改密码"], + ["system/membership", "会员状态"], + ["system/admin", "系统设置"], + ["system/members", "会员管理"], +]; + +const PLACEHOLDER_COPY = "该功能页将在后续批次实现"; + test("mobile login renders before authentication", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.route("**/api/**", async (route) => { @@ -249,12 +289,16 @@ test("mobile login renders before authentication", async ({ page }) => { test("four hub pages render their icon grids", async ({ page }) => { await mockMobileApi(page); await openMobile(page); - for (const hub of ["market", "tools", "review", "system"]) { + for (const hub of ["market", "tools", "review"]) { await page.evaluate((h) => { window.MobileRouter.navigate("#/hub/" + h); }, hub); await expect(page.locator(".m-hub-grid")).toBeVisible(); await expect(page.locator(".m-hub-grid .m-grid-item").first()).toBeVisible(); expect(await measureOverflow(page)).toBeLessThanOrEqual(1); } + await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); }); + await expect(page.locator("[data-system-page='home']")).toBeVisible(); + await expect(page.locator(".m-sys-row").first()).toBeVisible(); + expect(await measureOverflow(page)).toBeLessThanOrEqual(1); }); for (const theme of ["day", "night"]) { @@ -296,6 +340,112 @@ test("review five pages render watchlist, trades, daily, notes and alerts", asyn } }); +test("system management pages render real content instead of placeholders", async ({ page }) => { + await mockMobileApi(page); + await openMobile(page); + for (const [key, label] of SYSTEM_PAGES) { + await navigateToFeature(page, key); + await expect(page.locator("#m-title")).toHaveText(label); + await expect(page.locator(".m-placeholder")).toHaveCount(0); + await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY); + await expect(page.locator("[data-system-page], [data-system-admin-panel]").first()).toBeVisible(); + expect(await measureOverflow(page)).toBeLessThanOrEqual(1); + } + await navigateToFeature(page, "system/profile"); + await expect(page.locator("#m-sys-birth-date")).toBeVisible(); + await expect(page.locator("[data-system-save-birth]")).toBeVisible(); + await navigateToFeature(page, "system/password"); + await expect(page.locator("#m-sys-password-current")).toBeVisible(); + await navigateToFeature(page, "system/membership"); + await expect(page.locator(".m-sys-grid")).toBeVisible(); + await navigateToFeature(page, "system/admin"); + await expect(page.locator("#m-sys-token")).toBeVisible(); + await navigateToFeature(page, "system/members"); + await expect(page.locator("#m-sys-member-limit")).toBeVisible(); +}); + +test("system home groups entries and keeps admin-only items gated", async ({ page }) => { + await mockMobileApi(page); + await openMobile(page); + await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); }); + await expect(page.locator("[data-system-page='home']")).toBeVisible(); + await expect(page.locator("#m-view")).toContainText("账号"); + await expect(page.locator("#m-view")).toContainText("偏好"); + await expect(page.locator("#m-view")).toContainText("管理员专区"); + await expect(page.locator("[data-theme-toggle]")).toBeVisible(); + await expect(page.locator("[data-system-switch]")).toBeVisible(); + expect(await measureOverflow(page)).toBeLessThanOrEqual(1); +}); + +test("system settings tabs, model editor, delete confirm and theme toggle work", async ({ page }) => { + await mockMobileApi(page); + await openMobile(page); + await navigateToFeature(page, "system/admin"); + await expect(page.locator("[data-system-admin-panel='market']")).toBeVisible(); + await page.locator("[data-system-admin-tab='models']").click(); + await expect(page.locator("[data-system-admin-panel='models']")).toBeVisible(); + await page.locator("[data-system-edit-model]").first().click(); + await expect(page.locator(".m-sheet-root.is-open")).toBeVisible(); + await expect(page.locator(".m-sheet-head h2")).toHaveText("编辑模型"); + await page.locator("[data-sheet-close]").click(); + await page.locator("[data-system-admin-tab='market']").click(); + await expect(page.locator("#m-sys-token")).toBeVisible(); + + await navigateToFeature(page, "system/profile"); + await page.locator("[data-system-delete-birth]").click(); + await expect(page.locator(".m-dialog")).toBeVisible(); + await expect(page.locator(".m-dialog")).toContainText("删除命理资料"); + await page.locator("[data-sheet-close]").click(); + + await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); }); + await expect(page.locator("[data-theme-toggle]")).toBeVisible(); + const before = await page.locator("#m-app").getAttribute("data-theme"); + await page.locator("[data-theme-toggle]").click(); + await expect.poll(async () => page.locator("#m-app").getAttribute("data-theme")).not.toBe(before); + + await navigateToFeature(page, "system/members"); + await page.locator("[data-system-open-member]").click(); + await expect(page.locator(".m-sheet-root.is-open")).toBeVisible(); + await expect(page.locator(".m-sheet-head h2")).toContainText("管理会员"); +}); + +test("password mismatch shows inline error instead of a silent submit", async ({ page }) => { + await mockMobileApi(page); + await openMobile(page); + await navigateToFeature(page, "system/password"); + await page.locator("#m-sys-password-current").fill("OldPass12"); + await page.locator("#m-sys-password-new").fill("NewPass123"); + await page.locator("#m-sys-password-confirm").fill("OtherPass123"); + await page.locator("[data-system-save-password]").click(); + await expect(page.locator("[data-field-error='confirm']")).toBeVisible(); + await expect(page.locator("[data-field-error='confirm']")).toContainText("两次输入的密码不一致"); +}); + +test("empty birth profile save shows a validation toast", async ({ page }) => { + await mockMobileApi(page); + await openMobile(page); + await navigateToFeature(page, "system/profile"); + await expect(page.locator("[data-system-save-birth]")).toBeVisible(); + await page.locator("#m-sys-birth-date").fill(""); + await page.locator("#m-sys-birth-time").fill(""); + await page.locator("[data-system-save-birth]").click(); + await expect(page.locator("#m-toast.is-visible")).toBeVisible(); + await expect(page.locator("#m-toast")).toContainText("请填写完整出生日期和时间"); +}); + +test("non-admin cannot open system admin pages as placeholders", async ({ page }) => { + await mockMobileApi(page, { auth: authSession("user", true) }); + await openMobile(page); + await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); }); + await expect(page.locator("[data-system-page='home']")).toBeVisible(); + await expect(page.locator('[data-route="#/feature/system/admin"]')).toHaveCount(0); + await expect(page.locator('[data-route="#/feature/system/members"]')).toHaveCount(0); + await expect(page.locator("[data-system-switch]")).toBeVisible(); + await navigateToFeature(page, "system/admin"); + await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY); + await expect(page.locator("[data-system-page='forbidden']")).toBeVisible(); +}); + test("assistant chat renders with presets and input", async ({ page }) => { await mockMobileApi(page); await openMobile(page); diff --git a/tests/test_account_switch_grants.py b/tests/test_account_switch_grants.py new file mode 100644 index 0000000..b282cdb --- /dev/null +++ b/tests/test_account_switch_grants.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import threading +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from tempfile import TemporaryDirectory + +from backend.features.accounts.security import SecretVault, token_hash +from backend.features.accounts.service import AccountService +from database import ReviewDatabase + + +class AccountSwitchGrantTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = TemporaryDirectory() + self.database = ReviewDatabase(Path(self.temp.name) / "review.db") + self.bound_user_id = 0 + self.service = AccountService( + database=self.database, + vault=SecretVault(SecretVault.generate_key()), + current_user_supplier=lambda: self.bound_user_id, + access_supplier=lambda: self.database.user_access(self.bound_user_id) or {}, + bind_user=self._bind, + personal_field_builder=lambda *args, **kwargs: {}, + auth_lock=threading.Lock(), + ) + self.device_a = token_hash("device-a-token") + self.device_b = token_hash("device-b-token") + + def tearDown(self) -> None: + self.temp.cleanup() + + def _bind(self, user_id: int) -> None: + self.bound_user_id = int(user_id) + + def _register(self, username: str, device_hash: str = "") -> dict: + return self.service.register(username, "Password123", device_hash or self.device_a) + + def test_login_records_accounts_for_the_current_device_only(self) -> None: + first = self._register("alpha_user") + second = self._register("beta_user") + self.service.login("alpha_user", "Password123", self.device_b) + + listed = self.service.list_device_accounts(self.device_a) + names = [item["username"] for item in listed["accounts"]] + self.assertEqual(names, ["beta_user", "alpha_user"]) + self.assertEqual( + self.service.list_device_accounts(self.device_b)["accounts"][0]["username"], + "alpha_user", + ) + self.assertEqual(self.service.list_device_accounts("")["accounts"], []) + self.assertEqual(first["user"]["username"], "alpha_user") + self.assertEqual(second["user"]["username"], "beta_user") + + def test_switch_uses_device_grant_and_keeps_the_original_authorization(self) -> None: + first = self._register("alpha_user") + self._register("beta_user") + switched = self.service.switch_account(self.device_a, int(first["user"]["id"])) + self.assertEqual(switched["user"]["username"], "alpha_user") + remaining = { + item["username"] + for item in self.service.list_device_accounts(self.device_a)["accounts"] + } + self.assertEqual(remaining, {"alpha_user", "beta_user"}) + + def test_switch_without_a_valid_grant_requires_reauthentication(self) -> None: + user = self._register("alpha_user") + with self.assertRaisesRegex(PermissionError, "该账号需重新验证"): + self.service.switch_account(self.device_b, int(user["user"]["id"])) + with self.assertRaisesRegex(PermissionError, "该账号需重新验证"): + self.service.switch_account("", int(user["user"]["id"])) + + def test_forget_only_removes_the_current_device_grant(self) -> None: + user = self._register("alpha_user") + self.service.login("alpha_user", "Password123", self.device_b) + self.service.forget_account(self.device_a, int(user["user"]["id"])) + self.service.forget_account(self.device_a, int(user["user"]["id"])) + self.assertEqual(self.service.list_device_accounts(self.device_a)["accounts"], []) + self.assertEqual( + self.service.list_device_accounts(self.device_b)["accounts"][0]["username"], + "alpha_user", + ) + + def test_logout_revokes_only_the_current_account_on_this_device(self) -> None: + first = self._register("alpha_user") + second = self._register("beta_user") + self.service.revoke_current_device_grant(self.device_a, int(second["user"]["id"])) + names = { + item["username"] + for item in self.service.list_device_accounts(self.device_a)["accounts"] + } + self.assertEqual(names, {"alpha_user"}) + switched = self.service.switch_account(self.device_a, int(first["user"]["id"])) + self.assertEqual(switched["user"]["id"], first["user"]["id"]) + + def test_password_change_revokes_grants_on_every_device(self) -> None: + user = self._register("alpha_user") + self.service.login("alpha_user", "Password123", self.device_b) + self._bind(int(user["user"]["id"])) + self.service.change_password("Password123", "Password456") + self.assertEqual(self.service.list_device_accounts(self.device_a)["accounts"], []) + self.assertEqual(self.service.list_device_accounts(self.device_b)["accounts"], []) + with self.assertRaisesRegex(PermissionError, "该账号需重新验证"): + self.service.switch_account(self.device_a, int(user["user"]["id"])) + + def test_device_keeps_at_most_five_accounts(self) -> None: + usernames = [f"user_{index}" for index in range(6)] + ids = [self._register(name)["user"]["id"] for name in usernames] + listed = self.service.list_device_accounts(self.device_a)["accounts"] + self.assertEqual(len(listed), 5) + kept = {item["user_id"] for item in listed} + self.assertNotIn(ids[0], kept) + self.assertTrue(set(ids[1:]).issubset(kept)) + + def test_expired_grants_are_removed_lazily(self) -> None: + user = self._register("alpha_user") + past = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat(timespec="seconds") + self.database.upsert_switch_grant( + self.device_a, + int(user["user"]["id"]), + past, + past, + past, + ) + self.assertEqual(self.service.list_device_accounts(self.device_a)["accounts"], []) + with self.assertRaisesRegex(PermissionError, "该账号需重新验证"): + self.service.switch_account(self.device_a, int(user["user"]["id"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_admin_refresh_status.py b/tests/test_admin_refresh_status.py new file mode 100644 index 0000000..634f89e --- /dev/null +++ b/tests/test_admin_refresh_status.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import unittest + +from backend.jobs.service import _verified_dashboard_result + + +class AdminRefreshStatusTests(unittest.TestCase): + def test_carried_snapshot_is_reported_as_failed_job(self): + result = _verified_dashboard_result( + {"meta": {"carried_forward": True, "notice": "官方涨跌停数据尚未返回"}} + ) + + self.assertEqual(result["status"], "failed") + self.assertEqual(result["error"], "官方涨跌停数据尚未返回") + + def test_current_snapshot_is_reported_as_successful_job(self): + dashboard = {"meta": {"trade_date": "2026-08-28", "carried_forward": False}} + + self.assertIs(_verified_dashboard_result(dashboard), dashboard) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_database_migrations.py b/tests/test_database_migrations.py index 4c72bf3..6ee6379 100644 --- a/tests/test_database_migrations.py +++ b/tests/test_database_migrations.py @@ -25,6 +25,7 @@ class DatabaseMigrationTests(unittest.TestCase): ("0002", "create_job_runs"), ("0003", "extend_llm_audit"), ("0004", "add_mentor_note"), + ("0005", "create_account_switch_grants"), ], ) columns = { @@ -39,7 +40,7 @@ class DatabaseMigrationTests(unittest.TestCase): count = connection.execute( "SELECT COUNT(*) AS count FROM schema_migrations" ).fetchone()["count"] - self.assertEqual(count, 4) + self.assertEqual(count, 5) def test_database_with_recorded_0004_and_note_column_starts_without_reapply( self, @@ -60,7 +61,7 @@ class DatabaseMigrationTests(unittest.TestCase): count = connection.execute( "SELECT COUNT(*) AS count FROM schema_migrations" ).fetchone()["count"] - self.assertEqual(count, 4) + self.assertEqual(count, 5) def test_old_database_without_0004_upgrades_and_adds_note_column(self) -> None: with tempfile.TemporaryDirectory() as root: @@ -87,7 +88,7 @@ class DatabaseMigrationTests(unittest.TestCase): "PRAGMA table_info(mentor_preferences)" ) ] - self.assertEqual(versions, {"0001", "0002", "0003", "0004"}) + self.assertEqual(versions, {"0001", "0002", "0003", "0004", "0005"}) self.assertIn("note", note_rows) def test_database_with_unknown_migration_is_rejected(self) -> None: diff --git a/tests/test_deploy_baseline.py b/tests/test_deploy_baseline.py new file mode 100644 index 0000000..e7cf6eb --- /dev/null +++ b/tests/test_deploy_baseline.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import os +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CHECK = ROOT / "tools" / "check_deploy_baseline.sh" +BUILD = ROOT / "tools" / "build_image.sh" + + +def run_check(repo: Path, candidate: str, live: str) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["GIT_DIR"] = str(repo / ".git") + env["GIT_WORK_TREE"] = str(repo) + return subprocess.run( + ["bash", str(CHECK), candidate, "--live-revision", live], + cwd=repo, + capture_output=True, + text=True, + env=env, + check=False, + ) + + +def git(repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", *args], + cwd=repo, + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + + +class DeployBaselineGateTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.tmpdir = tempfile.TemporaryDirectory() + cls.repo = Path(cls.tmpdir.name) / "repo" + cls.repo.mkdir() + git(cls.repo, "init") + git(cls.repo, "config", "user.email", "gate@example.com") + git(cls.repo, "config", "user.name", "Gate") + (cls.repo / "README").write_text("base\n", encoding="utf-8") + git(cls.repo, "add", "README") + git(cls.repo, "commit", "-m", "base") + cls.base = git(cls.repo, "rev-parse", "HEAD") + + (cls.repo / "online.txt").write_text("live\n", encoding="utf-8") + git(cls.repo, "add", "online.txt") + git(cls.repo, "commit", "-m", "online") + cls.live = git(cls.repo, "rev-parse", "HEAD") + + git(cls.repo, "checkout", "-b", "successor") + (cls.repo / "next.txt").write_text("next\n", encoding="utf-8") + git(cls.repo, "add", "next.txt") + git(cls.repo, "commit", "-m", "successor of live") + cls.successor = git(cls.repo, "rev-parse", "HEAD") + + git(cls.repo, "checkout", "-B", "lagging-main", cls.base) + (cls.repo / "stale.txt").write_text("stale main\n", encoding="utf-8") + git(cls.repo, "add", "stale.txt") + git(cls.repo, "commit", "-m", "lagging main") + cls.lagging = git(cls.repo, "rev-parse", "HEAD") + + git(cls.repo, "checkout", "-B", "side", cls.base) + (cls.repo / "side.txt").write_text("side branch\n", encoding="utf-8") + git(cls.repo, "add", "side.txt") + git(cls.repo, "commit", "-m", "unrelated side branch") + cls.side = git(cls.repo, "rev-parse", "HEAD") + + git(cls.repo, "checkout", "-B", "successor", cls.successor) + + @classmethod + def tearDownClass(cls) -> None: + cls.tmpdir.cleanup() + + def test_check_script_is_executable(self) -> None: + self.assertTrue(CHECK.exists()) + self.assertTrue(stat.S_IXUSR & CHECK.stat().st_mode) + + def test_successor_of_live_passes(self) -> None: + result = run_check(self.repo, self.successor, self.live) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(self.live, result.stdout) + self.assertIn(self.successor, result.stdout) + self.assertIn("next.txt", result.stdout) + self.assertIn("祖先关系通过", result.stdout) + + def test_lagging_main_is_blocked(self) -> None: + result = run_check(self.repo, self.lagging, self.live) + self.assertNotEqual(result.returncode, 0) + self.assertIn("拒绝", result.stderr) + + def test_side_branch_is_blocked(self) -> None: + result = run_check(self.repo, self.side, self.live) + self.assertNotEqual(result.returncode, 0) + self.assertIn("拒绝", result.stderr) + + def test_unknown_commit_is_blocked(self) -> None: + result = run_check(self.repo, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", self.live) + self.assertNotEqual(result.returncode, 0) + self.assertIn("无法解析", result.stderr) + + def test_build_image_calls_the_gate_and_rejects_latest(self) -> None: + source = BUILD.read_text(encoding="utf-8") + self.assertIn("check_deploy_baseline.sh", source) + self.assertIn("禁止构建 latest", source) + self.assertIn("org.opencontainers.image.revision", source) + gate = CHECK.read_text(encoding="utf-8") + self.assertIn("org.opencontainers.image.revision", gate) + self.assertIn("merge-base --is-ancestor", gate) + self.assertIn("候选将丢失的提交", gate) + self.assertIn("禁止人工填写", gate) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_frontend_contract.py b/tests/test_frontend_contract.py index ab8f0fd..e2657b7 100644 --- a/tests/test_frontend_contract.py +++ b/tests/test_frontend_contract.py @@ -344,6 +344,22 @@ class FrontendContractTests(unittest.TestCase): self.assertIn("max-height: var(--sentiment-history-max-height);", self.sentiment_styles) self.assertIn("overflow: auto;", self.sentiment_styles) + def test_sentiment_equal_height_and_tooltip_tokens(self): + self.assertIn("--sentiment-analysis-height: 600px;", self.tokens) + self.assertIn("--sentiment-tooltip-bg: var(--text-primary);", self.tokens) + self.assertIn("--sentiment-tooltip-fg: var(--text-inverse);", self.tokens) + self.assertIn("--sentiment-tooltip-bg: #26293e;", self.tokens) + self.assertIn("--sentiment-tooltip-fg: #e8eaed;", self.tokens) + self.assertIn("height: var(--sentiment-analysis-height);", self.sentiment_styles) + self.assertIn("max-height: var(--sentiment-analysis-height);", self.sentiment_styles) + self.assertIn("justify-content: space-evenly;", self.sentiment_styles) + self.assertIn("background: var(--sentiment-tooltip-bg);", self.sentiment_styles) + self.assertIn("color: var(--sentiment-tooltip-fg);", self.sentiment_styles) + self.assertIn("new ResizeObserver", self.script) + self.assertIn("#sentimentCycleView .redesigned-emotion-grid {", self.sentiment_styles) + self.assertNotIn("height: 100vh", self.sentiment_styles) + self.assertNotIn("min-height: 100%", self.sentiment_styles) + def test_mentor_final_visual_fix_contract(self): shell_styles = (STATIC_DIR / "shared" / "shell.css").read_text(encoding="utf-8") mentor_html = (STATIC_DIR / "pages" / "mentor" / "page.html").read_text(encoding="utf-8") diff --git a/tests/test_governance_registries.py b/tests/test_governance_registries.py index f01c9cb..f8acd51 100644 --- a/tests/test_governance_registries.py +++ b/tests/test_governance_registries.py @@ -65,8 +65,11 @@ class GovernanceRegistryTests(unittest.TestCase): public, { ("GET", "/api/health"), + ("GET", "/api/auth/accounts"), ("POST", "/api/auth/login"), ("POST", "/api/auth/register"), + ("POST", "/api/auth/switch"), + ("POST", "/api/auth/forget"), }, ) diff --git a/tests/test_heaven_knowledge.py b/tests/test_heaven_knowledge.py index 4264086..abf35ae 100644 --- a/tests/test_heaven_knowledge.py +++ b/tests/test_heaven_knowledge.py @@ -1,13 +1,162 @@ from __future__ import annotations +import json +import tempfile import unittest +from http import HTTPStatus +from pathlib import Path +from unittest.mock import patch from backend.features.heaven.engine import build_five_phase_field, hexagram_from_lines -from backend.features.heaven.knowledge import prepare_heaven_context +from backend.features.heaven.http import HeavenHttpMixin +from backend.features.heaven.knowledge import ( + HeavenKnowledgeError, + clear_heaven_knowledge_cache, + prepare_heaven_context, + resolve_heaven_knowledge_path, + _knowledge_catalog, +) from backend.features.heaven.six_yao import build_six_yao_chart class HeavenKnowledgeTests(unittest.TestCase): + def tearDown(self) -> None: + clear_heaven_knowledge_cache() + + def test_catalog_loads_from_trusted_repo_file(self): + clear_heaven_knowledge_cache() + path = resolve_heaven_knowledge_path() + catalog = _knowledge_catalog() + self.assertTrue(path.is_file()) + self.assertEqual(path.name, "heaven_knowledge.json") + self.assertTrue(str(catalog.get("version") or "").startswith("2026.")) + self.assertIn("zhouyi", catalog["sources"]) + self.assertIn("neijing", catalog["sources"]) + self.assertEqual(len(catalog["fortune"]["qi"]), 6) + self.assertEqual(len(catalog["fortune"]["personal_relations"]), 10) + + def test_missing_knowledge_file_raises_chinese_structured_error(self): + clear_heaven_knowledge_cache() + missing = Path(tempfile.mkdtemp()) / "missing-heaven_knowledge.json" + with patch( + "backend.features.heaven.knowledge.KNOWLEDGE_FILE", missing + ), patch( + "backend.features.heaven.knowledge.KNOWLEDGE_SEED_FILE", + missing.with_name("missing-seed.json"), + ): + with self.assertRaises(HeavenKnowledgeError) as raised: + _knowledge_catalog() + self.assertEqual(raised.exception.error_code, "heaven_knowledge_missing") + self.assertIn("缺失", str(raised.exception)) + + def test_corrupt_knowledge_json_raises_chinese_structured_error(self): + clear_heaven_knowledge_cache() + with tempfile.TemporaryDirectory() as temp_dir: + broken = Path(temp_dir) / "heaven_knowledge.json" + broken.write_text("{not-json", encoding="utf-8") + with patch( + "backend.features.heaven.knowledge.KNOWLEDGE_FILE", broken + ), patch( + "backend.features.heaven.knowledge.KNOWLEDGE_SEED_FILE", + Path(temp_dir) / "unused-seed.json", + ): + with self.assertRaises(HeavenKnowledgeError) as raised: + _knowledge_catalog() + self.assertEqual(raised.exception.error_code, "heaven_knowledge_invalid") + self.assertIn("损坏", str(raised.exception)) + + def test_interpret_http_returns_structured_chinese_error_for_missing_file(self): + class FakeHandler(HeavenHttpMixin): + def __init__(self) -> None: + self.payload = None + self.status = None + self.application_service = type( + "Svc", + (), + { + "heaven_interpret": staticmethod( + lambda _body: (_ for _ in ()).throw( + HeavenKnowledgeError( + "问天知识文件缺失:未找到 heaven_knowledge.json。", + code="heaven_knowledge_missing", + ) + ) + ) + }, + )() + + def read_json_body(self): + return {"mode": "trend", "trade_date": "2026-08-04"} + + def send_json(self, payload, status=HTTPStatus.OK, headers=None): + self.payload = payload + self.status = status + + handler = FakeHandler() + handler.heaven_interpret() + self.assertEqual(handler.status, HTTPStatus.BAD_REQUEST) + self.assertIn("缺失", handler.payload["error"]) + self.assertEqual(handler.payload["code"], "heaven_knowledge_missing") + + def test_interpret_http_returns_structured_chinese_error_for_corrupt_json(self): + class FakeHandler(HeavenHttpMixin): + def __init__(self) -> None: + self.payload = None + self.status = None + self.application_service = type( + "Svc", + (), + { + "heaven_interpret": staticmethod( + lambda _body: (_ for _ in ()).throw( + HeavenKnowledgeError( + "问天知识文件 JSON 损坏(heaven_knowledge.json),无法解析:第 1 行附近。", + code="heaven_knowledge_invalid", + ) + ) + ) + }, + )() + + def read_json_body(self): + return {"mode": "trend", "trade_date": "2026-08-04"} + + def send_json(self, payload, status=HTTPStatus.OK, headers=None): + self.payload = payload + self.status = status + + handler = FakeHandler() + handler.heaven_interpret() + self.assertEqual(handler.status, HTTPStatus.BAD_REQUEST) + self.assertIn("损坏", handler.payload["error"]) + self.assertEqual(handler.payload["code"], "heaven_knowledge_invalid") + + def test_seed_fallback_when_data_file_missing(self): + clear_heaven_knowledge_cache() + with tempfile.TemporaryDirectory() as temp_dir: + seed = Path(temp_dir) / "seed.json" + seed.write_text( + json.dumps( + { + "version": "test-seed", + "sources": {"zhouyi": {"title": "周易"}}, + "trend": {"method": "m", "rules": {"stable": "s", "single": "a", "multiple": "b"}}, + "fortune": {}, + "heart": {}, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + missing_data = Path(temp_dir) / "data-heaven_knowledge.json" + with patch( + "backend.features.heaven.knowledge.KNOWLEDGE_FILE", missing_data + ), patch( + "backend.features.heaven.knowledge.KNOWLEDGE_SEED_FILE", seed + ): + catalog = _knowledge_catalog() + self.assertEqual(catalog["version"], "test-seed") + def test_fortune_context_excludes_weighted_summary_and_adds_bounded_industry_symbols(self): field = build_five_phase_field("2026-08-04") prepared = prepare_heaven_context( diff --git a/tests/test_login_portal.py b/tests/test_login_portal.py new file mode 100644 index 0000000..c77af1f --- /dev/null +++ b/tests/test_login_portal.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +LOGIN = (ROOT / "frontend" / "login" / "index.html").read_text(encoding="utf-8") +LOGIN_JS = (ROOT / "frontend" / "login" / "page.js").read_text(encoding="utf-8") +AUTH = (ROOT / "frontend" / "shared" / "auth.css").read_text(encoding="utf-8") +TOKENS = (ROOT / "frontend" / "shared" / "tokens.css").read_text(encoding="utf-8") + + +class LoginPortalContractTests(unittest.TestCase): + def test_confirmed_brand_skeleton_is_in_markup(self) -> None: + for needle in ( + 'class="login-brand-header"', + 'class="login-brand-name"', + "A股个人复盘工作台", + 'class="login-brand-kicker"', + "看懂情绪周期,把复盘变成下一次的先手。", + 'class="login-brand-chart"', + 'class="login-brand-stats"', + "股市有风险,投资需谨慎", + 'id="loginThemeToggle"', + 'id="loginCard"', + ): + self.assertIn(needle, LOGIN) + + def test_login_tokens_own_the_confirmed_layout_metrics(self) -> None: + for needle in ( + "--login-brand-share: 34%;", + "--login-brand-cap: 560px;", + "--login-brand-wide-share: 29.2%;", + "--login-card-width: 408px;", + "--login-hero-size: 28px;", + "--login-account-row-min: 72px;", + ): + self.assertIn(needle, TOKENS) + self.assertIn("width: var(--login-brand-share);", AUTH) + self.assertIn("width: var(--login-card-width);", AUTH) + self.assertIn("justify-content: flex-end;", AUTH) + self.assertIn("body.login-portal {", AUTH) + self.assertIn("padding-bottom: 0;", AUTH) + self.assertNotIn("padding: 0 0 var(--statusbar-height);", AUTH) + + def test_portal_keeps_account_switch_and_theme_hooks(self) -> None: + self.assertIn("data-switch-id", LOGIN_JS) + self.assertIn('data-login-action="manage"', LOGIN_JS) + self.assertIn('data-login-action="add"', LOGIN_JS) + self.assertIn("xiaobaiTheme", LOGIN_JS) + self.assertNotIn("内网个人版", LOGIN) + self.assertNotIn("192.168.200.11", LOGIN) + self.assertNotIn("/api/heaven", LOGIN_JS) diff --git a/tests/test_mobile_system_pages.py b/tests/test_mobile_system_pages.py new file mode 100644 index 0000000..c634254 --- /dev/null +++ b/tests/test_mobile_system_pages.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +NAV = ROOT / "frontend/m/config/nav.config.js" +PAGES = ROOT / "frontend/m/js/pages.js" +ROUTER = ROOT / "frontend/m/js/router.js" + + +class MobileSystemPagesRegressionTests(unittest.TestCase): + """Prevent mobile system-management entries from falling back to placeholders.""" + + def test_nav_system_entries_are_registered_as_real_pages(self) -> None: + nav = NAV.read_text(encoding="utf-8") + pages = PAGES.read_text(encoding="utf-8") + keys = re.findall(r'key:\s*"(system/[^"]+)"', nav) + self.assertEqual( + keys, + [ + "system/profile", + "system/password", + "system/membership", + "system/admin", + "system/members", + ], + ) + for key in keys: + self.assertIn(f'"{key}": setupSystemPage', pages) + self.assertIn(f'"{key}": loadSystem', pages) + + def test_placeholder_copy_is_only_a_router_fallback(self) -> None: + router = ROUTER.read_text(encoding="utf-8") + pages = PAGES.read_text(encoding="utf-8") + self.assertIn("该功能页将在后续批次实现", router) + self.assertNotIn("该功能页将在后续批次实现", pages) + self.assertIn("function setupSystemPage", pages) + self.assertIn("function loadSystem", pages) + + def test_system_pages_render_real_controls_not_stubs(self) -> None: + pages = PAGES.read_text(encoding="utf-8") + for marker in ( + 'data-system-page="home"', + 'data-system-page="profile"', + 'data-system-page="password"', + 'data-system-page="membership"', + 'data-system-page="members"', + 'data-system-page="forbidden"', + 'data-system-admin-panel="market"', + "m-sys-birth-date", + "m-sys-password-current", + "m-sys-token", + "m-sys-member-limit", + "data-system-switch", + "data-system-edit-model", + "data-system-open-member", + "管理员专区", + "保存密钥", + "保存分工", + 'location.assign("/login/")', + ): + self.assertIn(marker, pages) + + def test_system_boolean_attrs_do_not_have_stray_quotes(self) -> None: + pages = PAGES.read_text(encoding="utf-8") + stray = re.findall(r'data-system-[a-z-]*"(?=[>\s])', pages) + self.assertEqual( + stray, + [], + "boolean data-system attributes must not have a trailing quote before > or space", + ) + for name in ( + "data-system-save-birth", + "data-system-save-password", + "data-system-add-model", + "data-system-save-models", + "data-system-save-market", + "data-system-refresh", + "data-system-toggle-refresh", + "data-system-save-model", + ): + self.assertIn(name, pages) + self.assertNotIn(name + '">', pages) diff --git a/tests/test_realtime_dashboard.py b/tests/test_realtime_dashboard.py index 1114dc9..6977a4f 100644 --- a/tests/test_realtime_dashboard.py +++ b/tests/test_realtime_dashboard.py @@ -111,6 +111,25 @@ class RealtimeDashboardTests(unittest.TestCase): self.assertEqual(quote["amount_billion"], 3.0) self.assertAlmostEqual(quote["turnover_rate"], 0.01) + def test_close_dashboard_marks_official_limit_data(self): + dashboard = self.client.dashboard("20260720") + + self.assertEqual(dashboard["meta"]["limit_data_source"], "official") + + def test_close_dashboard_marks_derived_limit_data_as_incomplete(self): + original_query = self.client.query + + def query(api_name, params=None, fields=""): + if api_name == "limit_list_d": + return [] + return original_query(api_name, params, fields) + + self.client.query = query + dashboard = self.client.dashboard("20260720") + + self.assertEqual(dashboard["meta"]["limit_data_source"], "derived") + self.assertIn("日线数据推算", dashboard["meta"]["notice"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_snapshot_backfill.py b/tests/test_snapshot_backfill.py new file mode 100644 index 0000000..a0b0b0e --- /dev/null +++ b/tests/test_snapshot_backfill.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import json +import tempfile +import threading +import unittest +from datetime import datetime +from pathlib import Path +from typing import Any +from unittest.mock import patch + +from backend.features.market.backfill_history import ( + build_backfill_audit, + classify_snapshot_coverage, + create_sqlite_backup, + select_open_trade_dates, + select_open_trade_dates_in_range, +) +from backend.features.market.service import MarketServiceMixin +from backend.features.sentiment.engine import ( + build_sentiment_history, + latest_contiguous_history, +) +from backend.features.sentiment.service import SentimentServiceMixin +from database import ReviewDatabase + + +def _snapshot(trade_date: str, previous_trade_date: str) -> dict[str, Any]: + display = f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}" + previous_display = ( + f"{previous_trade_date[:4]}-{previous_trade_date[4:6]}-{previous_trade_date[6:8]}" + if previous_trade_date + else "" + ) + return { + "meta": { + "trade_date": display, + "previous_trade_date": previous_display, + "source": "tushare", + }, + "overview": { + "up_count": 2500, + "down_count": 2000, + "flat_count": 100, + "amount_billion": 12000, + "limit_up_count": 40, + "limit_down_count": 5, + "broken_count": 10, + "seal_rate": 70, + "max_height": 3, + "second_board_count": 8, + "three_plus_count": 4, + "previous_limit_count": 35, + "previous_positive_rate": 55, + "average_previous_change": 1.2, + "median_previous_change": 0.8, + "advance_rate": 20, + "severe_loss_rate": 5, + "previous_down_count": 3, + "ladder_completeness": 60, + "limit_amount_billion": 300, + }, + "limits": [{"code": "000001"}], + "broken": [], + "down_limits": [], + "yesterday_limits": [], + } + + +class _BackfillHarness(MarketServiceMixin, SentimentServiceMixin): + def __init__(self, database: ReviewDatabase) -> None: + self.database = database + self.sync_lock = threading.Lock() + self.configured = True + self.token = "test-token" + self.current_user_id = 1 + self._calendar_rows: list[dict[str, Any]] = [] + self._fail_dates: set[str] = set() + self.sync_calls: list[str] = [] + + def _tushare_client(self): # type: ignore[override] + harness = self + + class _Client: + def query(self, api_name, params, fields=""): + assert api_name == "trade_cal" + start = str(params["start_date"]) + end = str(params["end_date"]) + return [ + row + for row in harness._calendar_rows + if start <= str(row["cal_date"]) <= end + ] + + return _Client() + + def sync_dashboard(self, trade_date: str) -> dict[str, Any]: # type: ignore[override] + compact = trade_date.replace("-", "") + self.sync_calls.append(compact) + if compact in self._fail_dates: + raise ValueError(f"simulated failure for {compact}") + previous = "" + for row in self._calendar_rows: + if str(row["cal_date"]) == compact: + previous = str(row.get("pretrade_date") or "") + break + payload = _snapshot(compact, previous) + self.database.save_snapshot(compact, "tushare", payload) + return payload + + def _apply_reason_overrides(self, dashboard: dict[str, Any]) -> dict[str, Any]: + return dashboard + + def _with_storage(self, dashboard: dict[str, Any], cached: bool) -> dict[str, Any]: + return dashboard + + +class BackfillHistoryHelperTests(unittest.TestCase): + def test_select_open_trade_dates_skips_weekends_and_holidays(self) -> None: + rows = [ + {"cal_date": "20260821", "is_open": 1, "pretrade_date": "20260820"}, + {"cal_date": "20260822", "is_open": 0, "pretrade_date": "20260821"}, # Sat + {"cal_date": "20260823", "is_open": 0, "pretrade_date": "20260821"}, # Sun + {"cal_date": "20260824", "is_open": 1, "pretrade_date": "20260821"}, + {"cal_date": "20260825", "is_open": 1, "pretrade_date": "20260824"}, + {"cal_date": "20260826", "is_open": 1, "pretrade_date": "20260825"}, + {"cal_date": "20260827", "is_open": 1, "pretrade_date": "20260826"}, + ] + selected = select_open_trade_dates(rows, "20260827", 4) + self.assertEqual(selected, ["20260824", "20260825", "20260826", "20260827"]) + + def test_range_mode_reports_non_trading_days_separately(self) -> None: + rows = [ + {"cal_date": "20260821", "is_open": 1}, + {"cal_date": "20260824", "is_open": 1}, + ] + open_dates, skipped = select_open_trade_dates_in_range( + rows, "20260821", "20260824" + ) + self.assertEqual(open_dates, ["20260821", "20260824"]) + self.assertEqual(skipped, ["20260822", "20260823"]) + + def test_classify_snapshot_coverage_finds_real_gaps(self) -> None: + coverage = classify_snapshot_coverage( + ["20260824", "20260825", "20260826", "20260827"], + ["20260824", "20260827"], + ) + self.assertEqual(coverage["missing"], ["20260825", "20260826"]) + self.assertEqual(coverage["present"], ["20260824", "20260827"]) + + +class ContiguousHistoryGapTests(unittest.TestCase): + def test_missing_previous_trade_day_collapses_to_today(self) -> None: + payloads = [ + _snapshot("20260824", "20260821"), + _snapshot("20260827", "20260826"), # gap: 20260826 missing + ] + series = latest_contiguous_history(build_sentiment_history(payloads)) + self.assertEqual([row["trade_date"] for row in series], ["20260827"]) + + def test_continuous_history_keeps_full_tail(self) -> None: + payloads = [ + _snapshot("20260825", "20260824"), + _snapshot("20260826", "20260825"), + _snapshot("20260827", "20260826"), + ] + series = latest_contiguous_history(build_sentiment_history(payloads)) + self.assertEqual( + [row["trade_date"] for row in series], + ["20260825", "20260826", "20260827"], + ) + + +class SnapshotBackfillServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.db_path = Path(self.temporary.name) / "review.db" + self.database = ReviewDatabase(self.db_path) + self.service = _BackfillHarness(self.database) + self.service._calendar_rows = [ + {"cal_date": "20260820", "is_open": 1, "pretrade_date": "20260819"}, + {"cal_date": "20260821", "is_open": 1, "pretrade_date": "20260820"}, + {"cal_date": "20260822", "is_open": 0, "pretrade_date": "20260821"}, + {"cal_date": "20260823", "is_open": 0, "pretrade_date": "20260821"}, + {"cal_date": "20260824", "is_open": 1, "pretrade_date": "20260821"}, + {"cal_date": "20260825", "is_open": 1, "pretrade_date": "20260824"}, + {"cal_date": "20260826", "is_open": 1, "pretrade_date": "20260825"}, + {"cal_date": "20260827", "is_open": 1, "pretrade_date": "20260826"}, + ] + # Sparse history mimicking .11: keep 0824 and today, miss 0825/0826. + self.database.save_snapshot("20260824", "tushare", _snapshot("20260824", "20260821")) + self.database.save_snapshot("20260827", "tushare", _snapshot("20260827", "20260826")) + + def tearDown(self) -> None: + self.temporary.cleanup() + + def test_recent_backfill_fills_gap_and_restores_history(self) -> None: + before = self.service.sentiment_history("20260827", 20) + self.assertEqual(before["available_days"], 1) + + with patch( + "backend.features.market.service.create_sqlite_backup", + return_value=Path(self.temporary.name) / "fake-backup.db", + ) as backup: + audit = self.service.backfill_recent_trading_days( + end_date="20260827", + lookback=4, + dry_run=False, + create_backup=True, + ) + + backup.assert_called_once() + self.assertEqual(sorted(self.service.sync_calls), ["20260825", "20260826"]) + self.assertEqual(audit["missing"], ["2026-08-25", "2026-08-26"]) + self.assertEqual(sorted(audit["created_dates"]), ["2026-08-25", "2026-08-26"]) + after = self.service.sentiment_history("20260827", 20) + self.assertGreaterEqual(after["available_days"], 4) + self.assertEqual( + [row["trade_date"] for row in after["rows"]], + ["20260824", "20260825", "20260826", "20260827"], + ) + + def test_dry_run_does_not_write_snapshots(self) -> None: + audit = self.service.backfill_recent_trading_days( + end_date="20260827", + lookback=4, + dry_run=True, + create_backup=True, + ) + self.assertTrue(audit["dry_run"]) + self.assertEqual(self.service.sync_calls, []) + self.assertIsNone(audit["backup_path"]) + self.assertEqual( + self.database.list_snapshot_trade_dates("20260824", "20260827"), + ["20260824", "20260827"], + ) + + def test_repeat_execution_skips_existing_days(self) -> None: + with patch( + "backend.features.market.service.create_sqlite_backup", + return_value=Path(self.temporary.name) / "fake-backup.db", + ): + first = self.service.backfill_recent_trading_days( + end_date="20260827", lookback=4 + ) + self.service.sync_calls.clear() + second = self.service.backfill_recent_trading_days( + end_date="20260827", lookback=4 + ) + self.assertEqual(first["succeeded_count"], 2) + self.assertEqual(self.service.sync_calls, []) + self.assertEqual(second["missing_count"], 0) + self.assertEqual(second["skipped_count"], 4) + self.assertIsNone(second["backup_path"]) + + def test_partial_failure_continues_remaining_days(self) -> None: + self.service._fail_dates.add("20260825") + with patch( + "backend.features.market.service.create_sqlite_backup", + return_value=Path(self.temporary.name) / "fake-backup.db", + ): + audit = self.service.backfill_recent_trading_days( + end_date="20260827", lookback=4 + ) + self.assertFalse(audit["ok"]) + self.assertEqual(audit["failed_count"], 1) + self.assertEqual(audit["succeeded_count"], 1) + self.assertIn("20260826", self.database.list_snapshot_trade_dates()) + self.assertNotIn("20260825", self.database.list_snapshot_trade_dates()) + + def test_range_backfill_skips_weekend_without_treating_as_error(self) -> None: + with patch( + "backend.features.market.service.create_sqlite_backup", + return_value=Path(self.temporary.name) / "fake-backup.db", + ): + audit = self.service.backfill( + start_date="2026-08-21", + end_date="2026-08-24", + ) + self.assertEqual(audit["mode"], "range") + self.assertEqual(audit["skipped_non_trading_days"], ["2026-08-22", "2026-08-23"]) + self.assertEqual(sorted(self.service.sync_calls), ["20260821"]) + self.assertTrue(audit["ok"]) + + def test_sqlite_backup_api_creates_restorable_copy(self) -> None: + backup_dir = Path(self.temporary.name) / "backups" + backup = create_sqlite_backup( + self.db_path, + backup_dir, + label="pre-recent-backfill", + stamped_at=datetime(2026, 8, 27, 15, 30, 0), + ) + self.assertTrue(backup.exists()) + self.assertIn("pre-recent-backfill-20260827-153000", backup.name) + restored = ReviewDatabase(backup) + self.assertEqual( + restored.list_snapshot_trade_dates(), + ["20260824", "20260827"], + ) + + def test_audit_lists_only_snapshot_related_write_tables(self) -> None: + audit = build_backfill_audit( + mode="recent", + end_date="20260827", + lookback=60, + coverage={"trade_dates": [], "present": [], "missing": [], "present_count": 0, "missing_count": 0}, + ) + self.assertEqual( + audit["write_tables"], + ["dashboard_snapshots", "data_snapshots", "sync_runs"], + ) + self.assertNotIn("users", audit["write_tables"]) + self.assertNotIn("system_settings", audit["write_tables"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/README.md b/tools/README.md index 417e3e4..cc5c632 100644 --- a/tools/README.md +++ b/tools/README.md @@ -17,6 +17,22 @@ registry, and verification tools. `backend/features/*/routes.py` owners. - `python tools/build_architecture_inventory.py [--check]`: generate or verify `config/architecture-inventory.json` from the current source tree. +- `python tools/backfill_recent_snapshots.py --account