Compare commits

..
Author SHA1 Message Date
总工 f7cf9a6454 部署集成(HEL-227): 合并登录门户/免密切换(HEL-226)到含 HEL-221 修复的部署线 2026-08-29 11:34:03 +08:00
f0a1adf52f 施工(HEL-226): 实现登录门户与本机免密切换账号
用设备 Cookie 和授权表记住本机已验证账号,登录页按确认样图做成门户,不再把密码写进浏览器。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-29 11:24:39 +08:00
213f735f6a fix(HEL-221): 按确认样图实现情绪周期左右等高与夜间提示
桌面分析区固定 600px 并 stretch 对齐,评分构成在剩余高度内分配;夜间 tooltip 使用指定深色对比度,图表按容器尺寸重绘且不随视口拉满整页。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-28 15:01:22 +00:00
62 changed files with 2181 additions and 1767 deletions
-1
View File
@@ -9,7 +9,6 @@ __pycache__/
*.log *.log
runtime/ runtime/
data/cache/ data/cache/
data/backups/
data/private-mentor-skills/ data/private-mentor-skills/
data/*.db data/*.db
data/*.db-shm data/*.db-shm
-16
View File
@@ -14,29 +14,13 @@
v v
xiaobai-review 容器 :8765 xiaobai-review 容器 :8765
|-- /app 只读应用代码 |-- /app 只读应用代码
| `-- backend/features/heaven/assets/heaven_knowledge.json
| 镜像内 seed(不受 data 挂载遮盖)
`-- /app/data 宿主机 ./data 持久化挂载 `-- /app/data 宿主机 ./data 持久化挂载
|-- review.db
|-- iching_zh.json
`-- heaven_knowledge.json 优先读取;缺失时回退到上方 seed
``` ```
账号、加密后的公共数据 Token、平台模型 API Key、生辰资料、行情快照和复盘数据均在 账号、加密后的公共数据 Token、平台模型 API Key、生辰资料、行情快照和复盘数据均在
`data/review.db`。解密密钥来自 `.env` 中的 `APP_ENCRYPTION_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` 管理员私有的问师 Skill 保存在宿主机 `data/private-mentor-skills/`。该目录随 `data`
挂载进入容器,但被 Git 与 Docker 构建上下文排除,不会进入 Gitea 或镜像。私有 Skill 挂载进入容器,但被 Git 与 Docker 构建上下文排除,不会进入 Gitea 或镜像。私有 Skill
只对管理员账号返回和开放调用,也会随本指南的 `data` 备份一起保存。 只对管理员账号返回和开放调用,也会随本指南的 `data` 备份一起保存。
+1 -4
View File
@@ -23,10 +23,7 @@ COPY requirements.txt ./
RUN python -m pip install --no-cache-dir -r requirements.txt RUN python -m pip install --no-cache-dir -r requirements.txt
COPY --chown=xiaobai:xiaobai . . 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 USER xiaobai
+2
View File
@@ -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}$") USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_\-\u4e00-\u9fff]{3,30}$")
SESSION_COOKIE = "xiaobai_session" SESSION_COOKIE = "xiaobai_session"
SESSION_MAX_AGE = 30 * 24 * 60 * 60 SESSION_MAX_AGE = 30 * 24 * 60 * 60
DEVICE_COOKIE = "xiaobai_device"
DEVICE_MAX_AGE = 180 * 24 * 60 * 60
def load_local_env() -> None: def load_local_env() -> None:
@@ -41,16 +41,13 @@ class DashboardMixin:
raise TushareError(f"No daily data returned for {trade_date}") raise TushareError(f"No daily data returned for {trade_date}")
notices: list[str] = [] notices: list[str] = []
limit_data_source = "official"
try: try:
limit_rows = self._load_limit_lists(trade_date) limit_rows = self._load_limit_lists(trade_date)
previous_limit_rows = self._load_limit_type(previous_trade_date, "U") previous_limit_rows = self._load_limit_type(previous_trade_date, "U")
if not limit_rows: if not limit_rows:
limit_data_source = "derived"
notices.append("涨跌停高级接口当日数据尚未更新,已使用日线数据推算。") notices.append("涨跌停高级接口当日数据尚未更新,已使用日线数据推算。")
limit_rows = self._derive_limits(trade_date, daily) limit_rows = self._derive_limits(trade_date, daily)
except TushareError as exc: except TushareError as exc:
limit_data_source = "derived"
notices.append(f"涨跌停高级接口不可用,已使用日线数据推算:{exc}") notices.append(f"涨跌停高级接口不可用,已使用日线数据推算:{exc}")
limit_rows = self._derive_limits(trade_date, daily) limit_rows = self._derive_limits(trade_date, daily)
previous_daily = self._load_daily(previous_trade_date) previous_daily = self._load_daily(previous_trade_date)
@@ -82,7 +79,6 @@ class DashboardMixin:
"trade_date": _display_date(trade_date), "trade_date": _display_date(trade_date),
"previous_trade_date": _display_date(previous_trade_date), "previous_trade_date": _display_date(previous_trade_date),
"source": "tushare", "source": "tushare",
"limit_data_source": limit_data_source,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "".join(notices), "notice": "".join(notices),
}, },
+2
View File
@@ -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 .m0002_job_runs import MIGRATION as M0002_JOB_RUNS
from .m0003_llm_audit import MIGRATION as M0003_LLM_AUDIT from .m0003_llm_audit import MIGRATION as M0003_LLM_AUDIT
from .m0004_mentor_notes import MIGRATION as M0004_MENTOR_NOTES 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 from .runner import Migration, MigrationError, MigrationRunner
MIGRATIONS = ( MIGRATIONS = (
@@ -9,6 +10,7 @@ MIGRATIONS = (
M0002_JOB_RUNS, M0002_JOB_RUNS,
M0003_LLM_AUDIT, M0003_LLM_AUDIT,
M0004_MENTOR_NOTES, M0004_MENTOR_NOTES,
M0005_ACCOUNT_SWITCH_GRANTS,
) )
__all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"] __all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"]
@@ -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",
)
+4 -4
View File
@@ -28,11 +28,11 @@ class AccountApplicationMixin:
def update_membership(self, payload: dict[str, Any]) -> None: def update_membership(self, payload: dict[str, Any]) -> None:
self.accounts.update_membership(payload) self.accounts.update_membership(payload)
def register_account(self, username: str, password: str) -> dict[str, Any]: def register_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
return self.accounts.register(username, password) return self.accounts.register(username, password, device_hash)
def login_account(self, username: str, password: str) -> dict[str, Any]: def login_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
return self.accounts.login(username, password) return self.accounts.login(username, password, device_hash)
def change_password(self, current_password: str, new_password: str) -> None: def change_password(self, current_password: str, new_password: str) -> None:
self.accounts.change_password(current_password, new_password) self.accounts.change_password(current_password, new_password)
+93 -21
View File
@@ -1,17 +1,27 @@
from __future__ import annotations from __future__ import annotations
import json import json
import secrets
from http import HTTPStatus from http import HTTPStatus
from backend.features.accounts.security import token_hash
class AccountHttpMixin: class AccountHttpMixin:
def auth_register(self) -> None: def _device_hash(self) -> str:
try: raw = self.device_token()
body = self.read_json_body() return token_hash(raw) if raw else ""
result = self.application_service.register_account(
str(body.get("username") or ""), def _ensure_device_token(self) -> str:
str(body.get("password") or ""), 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( self.send_json(
{ {
"ok": True, "ok": True,
@@ -19,31 +29,80 @@ class AccountHttpMixin:
"user": result["user"], "user": result["user"],
"csrf_token": result["csrf_token"], "csrf_token": result["csrf_token"],
}, },
HTTPStatus.CREATED, status,
{"Set-Cookie": self.session_cookie(result["session_token"])}, 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_authenticated_session(result, HTTPStatus.CREATED, device_raw)
except (ValueError, json.JSONDecodeError) as exc: except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def auth_login(self) -> None: def auth_login(self) -> None:
try: try:
body = self.read_json_body() body = self.read_json_body()
device_raw = self._ensure_device_token()
result = self.application_service.login_account( result = self.application_service.login_account(
str(body.get("username") or ""), str(body.get("username") or ""),
str(body.get("password") or ""), str(body.get("password") or ""),
token_hash(device_raw),
) )
self.send_json( self._send_authenticated_session(result, HTTPStatus.OK, device_raw)
{
"ok": True,
"authenticated": True,
"user": result["user"],
"csrf_token": result["csrf_token"],
},
headers={"Set-Cookie": self.session_cookie(result["session_token"])},
)
except (ValueError, json.JSONDecodeError) as exc: except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.UNAUTHORIZED) 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: def auth_me(self) -> None:
service = self.application_service service = self.application_service
if not self.require_auth(send_error=False): if not self.require_auth(send_error=False):
@@ -55,6 +114,15 @@ class AccountHttpMixin:
} }
) )
return 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( self.send_json(
{ {
"ok": True, "ok": True,
@@ -66,15 +134,19 @@ class AccountHttpMixin:
"membership": service.membership(), "membership": service.membership(),
}, },
"csrf_token": str(self.auth_user["csrf_token"]), "csrf_token": str(self.auth_user["csrf_token"]),
} },
headers=headers,
) )
def auth_logout(self) -> None: def auth_logout(self) -> None:
raw_token = self.session_token() raw_token = self.session_token()
user_id = int(getattr(self, "auth_user", {}).get("id") or 0)
if raw_token: if raw_token:
from backend.features.accounts.security import token_hash
self.application_service.database.delete_session(token_hash(raw_token)) 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( self.send_json(
{"ok": True}, {"ok": True},
headers={"Set-Cookie": self.session_cookie("", clear=True)}, headers={"Set-Cookie": self.session_cookie("", clear=True)},
+93
View File
@@ -234,3 +234,96 @@ class AccountRepositoryMixin:
(user_id,), (user_id,),
) )
return cursor.rowcount > 0 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)
+3
View File
@@ -6,6 +6,9 @@ class AccountRoutesMixin:
if parsed.path == "/api/auth/me": if parsed.path == "/api/auth/me":
self.auth_me() self.auth_me()
return True return True
if parsed.path == "/api/auth/accounts":
self.auth_accounts()
return True
return False return False
def _handle_accounts_get(self, parsed) -> bool: def _handle_accounts_get(self, parsed) -> bool:
+93 -4
View File
@@ -77,15 +77,22 @@ class AccountService:
access = self.access_supplier() or self.database.user_access(self.current_user_id) or {} access = self.access_supplier() or self.database.user_access(self.current_user_id) or {}
return self.membership_for_access(access) 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() username = username.strip()
self.validate_input(username, password) self.validate_input(username, password)
with self.auth_lock: with self.auth_lock:
salt, password_digest = hash_password(password) salt, password_digest = hash_password(password)
user = self.database.create_user(username, salt, password_digest) 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() username = username.strip()
if not username or not password: if not username or not password:
raise ValueError("账号名和密码不能为空。") raise ValueError("账号名和密码不能为空。")
@@ -96,7 +103,9 @@ class AccountService:
str(user.get("password_hash") or ""), str(user.get("password_hash") or ""),
): ):
raise ValueError("账号名或密码不正确。") 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: def change_password(self, current_password: str, new_password: str) -> None:
current_password = str(current_password or "") current_password = str(current_password or "")
@@ -112,6 +121,86 @@ class AccountService:
salt, digest = hash_password(new_password) salt, digest = hash_password(new_password)
if not self.database.update_user_password(self.current_user_id, salt, digest): if not self.database.update_user_password(self.current_user_id, salt, digest):
raise ValueError("账号不存在。") 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]: def create_session(self, user: dict[str, Any]) -> dict[str, Any]:
session_token = secrets.token_urlsafe(32) session_token = secrets.token_urlsafe(32)
@@ -1,117 +0,0 @@
{
"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": {
"兄弟": "兄弟是与卦宫五行同类的关系。在股票交易问题中可作为竞争、同类力量或资源分流的候选象义,但不直接等同合作方、亏损或他人拿走资金。",
"子孙": "子孙是卦宫所生的关系,可作为舒缓、产出、执行后的释放或对压力的制衡候选象义,但不直接等同收益、资金提供方或确定的利好。",
"妻财": "妻财是卦宫所克的关系,在股票交易问题中可作为价值、收益预期、持仓利益或可支配资源的候选象义,但不直接等同现金、融资、自有资金或必得之财。",
"官鬼": "官鬼是克制卦宫的关系,可作为压力、风险、规则约束或担忧的候选象义,但不直接等同借贷、坏消息、疾病或必然损失。",
"父母": "父母是生助卦宫的关系,可作为信息、依据、计划、规则、凭据或保护条件的候选象义,但不直接等同政策、合同或某一条消息。"
}
}
}
}
+4 -13
View File
@@ -3,24 +3,15 @@ from __future__ import annotations
import json import json
from http import HTTPStatus from http import HTTPStatus
from backend.features.heaven.knowledge import HeavenKnowledgeError
class HeavenHttpMixin: 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: def heaven_hexagram(self) -> None:
try: try:
body = self.read_json_body() body = self.read_json_body()
result = self.application_service.heaven_hexagram(body.get("lines")) result = self.application_service.heaven_hexagram(body.get("lines"))
self.send_json({"ok": True, "hexagram": result}) self.send_json({"ok": True, "hexagram": result})
except (ValueError, json.JSONDecodeError) as exc: except (ValueError, json.JSONDecodeError) as exc:
self._send_heaven_client_error(exc) self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def heaven_personal(self) -> None: def heaven_personal(self) -> None:
try: try:
@@ -28,12 +19,12 @@ class HeavenHttpMixin:
result = self.application_service.heaven_personal(body) result = self.application_service.heaven_personal(body)
self.send_json({"ok": True, "personal": result}) self.send_json({"ok": True, "personal": result})
except (ValueError, json.JSONDecodeError) as exc: except (ValueError, json.JSONDecodeError) as exc:
self._send_heaven_client_error(exc) self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def heaven_interpret(self) -> None: def heaven_interpret(self) -> None:
try: try:
body = self.read_json_body() body = self.read_json_body()
result = self.application_service.heaven_interpret(body) result = self.application_service.heaven_interpret(body)
self.send_json({"ok": True, **result}) self.send_json({"ok": True, **result})
except (HeavenKnowledgeError, ValueError, json.JSONDecodeError) as exc: except (ValueError, json.JSONDecodeError) as exc:
self._send_heaven_client_error(exc) self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
+2 -53
View File
@@ -2,23 +2,12 @@ from __future__ import annotations
import json import json
from functools import lru_cache from functools import lru_cache
from pathlib import Path
from typing import Any from typing import Any
from backend.bootstrap.config import APP_DIR from backend.bootstrap.config import APP_DIR
KNOWLEDGE_FILE = APP_DIR / "data" / "heaven_knowledge.json" 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]: def prepare_heaven_context(mode: str, calculation: dict[str, Any]) -> dict[str, Any]:
@@ -390,49 +379,9 @@ 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) @lru_cache(maxsize=1)
def _knowledge_catalog() -> dict[str, Any]: def _knowledge_catalog() -> dict[str, Any]:
path = resolve_heaven_knowledge_path() payload = json.loads(KNOWLEDGE_FILE.read_text(encoding="utf-8"))
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): if not payload.get("version") or not isinstance(payload.get("sources"), dict):
raise HeavenKnowledgeError( raise ValueError("问天知识库格式不完整。")
f"问天知识库格式不完整({path.name}):缺少 version 或 sources。",
code="heaven_knowledge_invalid",
)
return payload return payload
def clear_heaven_knowledge_cache() -> None:
_knowledge_catalog.cache_clear()
-202
View File
@@ -1,202 +0,0 @@
"""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"
],
}
-25
View File
@@ -227,31 +227,6 @@ class MarketRepositoryMixin:
result.append(payload) result.append(payload)
return result 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: def start_sync(self, trade_date: str, source: str) -> int:
started_at = datetime.now().astimezone().isoformat(timespec="seconds") started_at = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection: with self.connect() as connection:
+21 -233
View File
@@ -3,11 +3,9 @@ from __future__ import annotations
import copy import copy
import re import re
from datetime import date, datetime, time as dt_time, timedelta from datetime import date, datetime, time as dt_time, timedelta
from pathlib import Path
from typing import Any from typing import Any
from backend.bootstrap.config import ( from backend.bootstrap.config import (
DATA_DIR,
normalize_date, normalize_date,
tushare_code, tushare_code,
validate_stock_code, validate_stock_code,
@@ -15,17 +13,6 @@ from backend.bootstrap.config import (
) )
from backend.data.providers.ifind_client import IfindError from backend.data.providers.ifind_client import IfindError
from backend.data.providers.tushare_client import TushareClient, TushareError 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.charts import ChartDataError
from backend.features.market.insights import MarketInsightsService from backend.features.market.insights import MarketInsightsService
from backend.features.sentiment.engine import SENTIMENT_ENGINE_VERSION from backend.features.sentiment.engine import SENTIMENT_ENGINE_VERSION
@@ -198,11 +185,6 @@ class MarketServiceMixin:
raise TushareError("公共行情尚未配置") raise TushareError("公共行情尚未配置")
dashboard = self._tushare_client().dashboard(normalized_date) 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"]["source"] = source
dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date) dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date)
dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date) dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date)
@@ -908,226 +890,31 @@ class MarketServiceMixin:
"intraday": intraday_points, "intraday": intraday_points,
} }
def backfill( def backfill(self, start_date: str, end_date: str) -> list[dict[str, Any]]:
self, start = datetime.strptime(normalize_date(start_date), "%Y%m%d").date()
start_date: str = "", end = datetime.strptime(normalize_date(end_date), "%Y%m%d").date()
end_date: str = "", if start > end:
*, raise ValueError("开始日期不能晚于结束日期。")
lookback: int | None = None, weekdays = []
dry_run: bool = False, current = start
force: bool = False, while current <= end:
create_backup: bool = True, if current.weekday() < 5:
) -> dict[str, Any]: weekdays.append(current)
"""Backfill dashboard snapshots for real trading days only. current += timedelta(days=1)
if len(weekdays) > 15:
- Date-range mode keeps the admin UI contract (max 15 open sessions). raise ValueError("单次最多回补 15 个工作日。")
- Recent mode fills the last N open sessions (default/max 60). results = []
Weekends and holidays are reported as skipped non-trading days, not errors. for day in weekdays:
""" dashboard = self.sync_dashboard(day.strftime("%Y%m%d"))
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( results.append(
{ {
"requested_date": display_date(trade_date), "requested_date": day.isoformat(),
"trade_date": display_date(trade_date), "trade_date": dashboard["meta"]["trade_date"],
"status": status, "source": dashboard["meta"]["source"],
"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), "records": self._record_count(dashboard),
} }
) )
except Exception as exc: return results
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": display_date(trade_date),
"trade_date": display_date(trade_date),
"status": "skipped",
"action": "exists",
}
)
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]: def _stock_identity(self, code: str, trade_date: str) -> tuple[str, str]:
snapshot = self.database.get_snapshot(trade_date) or {} snapshot = self.database.get_snapshot(trade_date) or {}
@@ -1168,3 +955,4 @@ class MarketServiceMixin:
len(dashboard.get(key) or []) len(dashboard.get(key) or [])
for key in ("limits", "broken", "down_limits", "yesterday_limits") for key in ("limits", "broken", "down_limits", "yesterday_limits")
) )
+1 -3
View File
@@ -26,15 +26,13 @@ class SystemHttpMixin:
def start_background_refresh(self) -> None: def start_background_refresh(self) -> None:
try: try:
body = self.read_json_body(allow_empty=True) body = self.read_json_body(allow_empty=True)
refresh = self.application_service.request_background_sync( started = self.application_service.request_background_sync(
str(body.get("trade_date") or date.today().isoformat()) str(body.get("trade_date") or date.today().isoformat())
) )
started = bool(refresh.get("started"))
self.send_json( self.send_json(
{ {
"ok": True, "ok": True,
"started": started, "started": started,
"job_key": str(refresh.get("job_key") or ""),
"message": "后台刷新已开始" if started else "已有后台刷新任务正在运行", "message": "后台刷新已开始" if started else "已有后台刷新任务正在运行",
}, },
HTTPStatus.ACCEPTED, HTTPStatus.ACCEPTED,
+2 -8
View File
@@ -29,17 +29,11 @@ class SystemRoutesMixin:
def backfill_data(self) -> None: def backfill_data(self) -> None:
try: try:
body = self.read_json_body() body = self.read_json_body()
lookback_raw = body.get("lookback") results = self.application_service.backfill(
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("start_date") or ""),
str(body.get("end_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, **audit, "results": audit.get("results") or []}) self.send_json({"ok": True, "results": results})
except ValueError as exc: except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
except Exception as exc: except Exception as exc:
+2
View File
@@ -7,6 +7,8 @@ from urllib.parse import urlparse
PUBLIC_POST_HANDLERS = { PUBLIC_POST_HANDLERS = {
"/api/auth/register": "auth_register", "/api/auth/register": "auth_register",
"/api/auth/login": "auth_login", "/api/auth/login": "auth_login",
"/api/auth/switch": "auth_switch",
"/api/auth/forget": "auth_forget",
} }
AUTHENTICATED_POST_HANDLERS = { AUTHENTICATED_POST_HANDLERS = {
+26 -10
View File
@@ -9,7 +9,13 @@ from http.cookies import SimpleCookie
from typing import Any from typing import Any
from urllib.parse import unquote 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.features.accounts.security import token_hash
from backend.http.context import correlation_id from backend.http.context import correlation_id
from backend.http.errors import normalize_error_payload from backend.http.errors import normalize_error_payload
@@ -21,15 +27,21 @@ class HttpTransportMixin:
application_service: Any application_service: Any
route_registry: Any route_registry: Any
def session_token(self) -> str: def cookie_value(self, name: str) -> str:
cookie = SimpleCookie() cookie = SimpleCookie()
try: try:
cookie.load(self.headers.get("Cookie", "")) cookie.load(self.headers.get("Cookie", ""))
except Exception: except Exception:
return "" return ""
morsel = cookie.get(SESSION_COOKIE) morsel = cookie.get(name)
return morsel.value if morsel else "" 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: def require_auth(self, send_error: bool = True) -> bool:
raw_token = self.session_token() raw_token = self.session_token()
service = self.application_service service = self.application_service
@@ -79,15 +91,18 @@ class HttpTransportMixin:
return self.require_member() return self.require_member()
return True return True
def session_cookie(self, value: str, clear: bool = False) -> str: def _cookie_header(self, name: str, value: str, max_age: int) -> str:
max_age = 0 if clear else SESSION_MAX_AGE cookie = f"{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}"
cookie = (
f"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}"
)
if self.headers.get("X-Forwarded-Proto", "").lower() == "https": if self.headers.get("X-Forwarded-Proto", "").lower() == "https":
cookie += "; Secure" cookie += "; Secure"
return cookie 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]: def read_json_body(self, allow_empty: bool = False) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))
if length == 0 and allow_empty: if length == 0 and allow_empty:
@@ -132,7 +147,7 @@ class HttpTransportMixin:
self, self,
payload: dict[str, Any], payload: dict[str, Any],
status: HTTPStatus = HTTPStatus.OK, 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: ) -> None:
request_id = getattr(self, "_correlation_id", "") request_id = getattr(self, "_correlation_id", "")
if not request_id: if not request_id:
@@ -145,7 +160,8 @@ class HttpTransportMixin:
self.send_header("Content-Length", str(len(content))) self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("X-Request-ID", request_id) 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.send_header(name, value)
self.end_headers() self.end_headers()
self.wfile.write(content) self.wfile.write(content)
+3 -14
View File
@@ -7,16 +7,6 @@ from datetime import date
from backend.bootstrap.config import normalize_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: class JobServiceMixin:
def start_background_jobs(self) -> threading.Thread: def start_background_jobs(self) -> threading.Thread:
return self.jobs.start_scheduler( return self.jobs.start_scheduler(
@@ -30,16 +20,15 @@ class JobServiceMixin:
workers_stopped = self.jobs.wait_for_idle(timeout_seconds) workers_stopped = self.jobs.wait_for_idle(timeout_seconds)
return scheduler_stopped and workers_stopped return scheduler_stopped and workers_stopped
def request_background_sync(self, trade_date: str) -> dict[str, object]: def request_background_sync(self, trade_date: str) -> bool:
normalized = normalize_date(trade_date) normalized = normalize_date(trade_date)
key = f"manual:{normalized}:{time.time_ns()}" key = f"manual:{normalized}:{time.time_ns()}"
started = self.jobs.submit( return self.jobs.submit(
"market.refresh", "market.refresh",
key, key,
lambda: _verified_dashboard_result(self.sync_dashboard(normalized)), lambda: self.sync_dashboard(normalized),
{"trade_date": normalized, "trigger": "administrator"}, {"trade_date": normalized, "trigger": "administrator"},
) )
return {"started": started, "job_key": key if started else ""}
def _background_refresh_tick(self) -> None: def _background_refresh_tick(self) -> None:
if not ( if not (
+21
View File
@@ -128,6 +128,20 @@
"feature": "auction", "feature": "auction",
"access": "authenticated" "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", "method": "POST",
"path": "/api/auth/login", "path": "/api/auth/login",
@@ -156,6 +170,13 @@
"feature": "auth", "feature": "auth",
"access": "public" "access": "public"
}, },
{
"method": "POST",
"path": "/api/auth/switch",
"match": "exact",
"feature": "auth",
"access": "public"
},
{ {
"method": "POST", "method": "POST",
"path": "/api/backfill", "path": "/api/backfill",
+54 -50
View File
@@ -10,10 +10,10 @@
}, },
"counts": { "counts": {
"primary_pages": 16, "primary_pages": 16,
"api_exact_paths": 53, "api_exact_paths": 56,
"api_prefixes": 0, "api_prefixes": 0,
"api_patterns": 11, "api_patterns": 11,
"database_tables": 36, "database_tables": 37,
"frontend_page_fragments": 12 "frontend_page_fragments": 12
}, },
"pages": [ "pages": [
@@ -96,10 +96,13 @@
"/api/assistant/chat", "/api/assistant/chat",
"/api/assistant/messages", "/api/assistant/messages",
"/api/auction", "/api/auction",
"/api/auth/accounts",
"/api/auth/forget",
"/api/auth/login", "/api/auth/login",
"/api/auth/logout", "/api/auth/logout",
"/api/auth/me", "/api/auth/me",
"/api/auth/register", "/api/auth/register",
"/api/auth/switch",
"/api/backfill", "/api/backfill",
"/api/chart/intraday", "/api/chart/intraday",
"/api/dashboard", "/api/dashboard",
@@ -189,6 +192,7 @@
"assistant_messages", "assistant_messages",
"heaven_readings", "heaven_readings",
"job_runs", "job_runs",
"account_switch_grants",
"schema_migrations" "schema_migrations"
], ],
"background_job_methods": [ "background_job_methods": [
@@ -370,11 +374,11 @@
} }
], ],
"css_layers": [ "css_layers": [
"/shared/tokens.css?v=20260820-3", "/shared/tokens.css?v=20260829-1",
"/shared/base.css?v=20260806-1", "/shared/base.css?v=20260806-1",
"/shared/shell.css?v=20260827-hel183", "/shared/shell.css?v=20260820-8",
"/shared/auth.css?v=20260820-5", "/shared/auth.css?v=20260829-1",
"/shared/components/controls.css?v=20260827-hel183", "/shared/components/controls.css?v=20260820-2",
"/shared/components/navigation.css?v=20260820-1", "/shared/components/navigation.css?v=20260820-1",
"/shared/components/cards.css?v=20260820-1", "/shared/components/cards.css?v=20260820-1",
"/shared/components/tables.css?v=20260820-1", "/shared/components/tables.css?v=20260820-1",
@@ -390,8 +394,8 @@
"/pages/popularity/foundation.css?v=20260820-1", "/pages/popularity/foundation.css?v=20260820-1",
"/pages/dragon-tiger/foundation.css?v=20260820-1", "/pages/dragon-tiger/foundation.css?v=20260820-1",
"/pages/screener/foundation.css?v=20260820-4", "/pages/screener/foundation.css?v=20260820-4",
"/pages/mentor/foundation.css?v=20260827-hel183", "/pages/mentor/foundation.css?v=20260820-2",
"/pages/heaven/foundation.css?v=20260827-hel183", "/pages/heaven/foundation.css?v=20260806-2",
"/pages/review/foundation.css?v=20260820-4" "/pages/review/foundation.css?v=20260820-4"
], ],
"frontend_composition": { "frontend_composition": {
@@ -436,8 +440,8 @@
"code_hotspots": [ "code_hotspots": [
{ {
"path": "frontend/pages/heaven/foundation.css", "path": "frontend/pages/heaven/foundation.css",
"bytes": 182616, "bytes": 185936,
"lines": 11494 "lines": 11734
}, },
{ {
"path": "frontend/pages/screener/foundation.css", "path": "frontend/pages/screener/foundation.css",
@@ -446,13 +450,13 @@
}, },
{ {
"path": "frontend/pages/heaven/page.js", "path": "frontend/pages/heaven/page.js",
"bytes": 97268, "bytes": 97189,
"lines": 2070 "lines": 2069
}, },
{ {
"path": "frontend/shared/shell.css", "path": "frontend/shared/shell.css",
"bytes": 63659, "bytes": 63550,
"lines": 3763 "lines": 3757
}, },
{ {
"path": "backend/features/heaven/engine.py", "path": "backend/features/heaven/engine.py",
@@ -461,8 +465,8 @@
}, },
{ {
"path": "frontend/index.html", "path": "frontend/index.html",
"bytes": 48077, "bytes": 48037,
"lines": 662 "lines": 663
}, },
{ {
"path": "backend/features/screener/catalog.py", "path": "backend/features/screener/catalog.py",
@@ -486,8 +490,8 @@
}, },
{ {
"path": "backend/data/providers/tushare_dashboard.py", "path": "backend/data/providers/tushare_dashboard.py",
"bytes": 28234, "bytes": 28051,
"lines": 648 "lines": 644
}, },
{ {
"path": "backend/data/providers/tushare_industries.py", "path": "backend/data/providers/tushare_industries.py",
@@ -501,8 +505,8 @@
}, },
{ {
"path": "frontend/pages/heaven/page.html", "path": "frontend/pages/heaven/page.html",
"bytes": 19885, "bytes": 19747,
"lines": 269 "lines": 262
}, },
{ {
"path": "frontend/pages/screener/page.html", "path": "frontend/pages/screener/page.html",
@@ -541,8 +545,8 @@
}, },
{ {
"path": "frontend/shared/admin.js", "path": "frontend/shared/admin.js",
"bytes": 14410, "bytes": 14145,
"lines": 268 "lines": 261
}, },
{ {
"path": "backend/features/heaven/market_context.py", "path": "backend/features/heaven/market_context.py",
@@ -551,13 +555,8 @@
}, },
{ {
"path": "frontend/shared/session.js", "path": "frontend/shared/session.js",
"bytes": 13176, "bytes": 12848,
"lines": 293 "lines": 283
},
{
"path": "frontend/shared/dashboard.js",
"bytes": 12894,
"lines": 274
}, },
{ {
"path": "backend/features/market/insights_auction_data.py", "path": "backend/features/market/insights_auction_data.py",
@@ -579,6 +578,11 @@
"bytes": 10539, "bytes": 10539,
"lines": 244 "lines": 244
}, },
{
"path": "frontend/shared/dashboard.js",
"bytes": 9993,
"lines": 220
},
{ {
"path": "backend/data/providers/tushare_sectors.py", "path": "backend/data/providers/tushare_sectors.py",
"bytes": 9876, "bytes": 9876,
@@ -716,8 +720,8 @@
}, },
{ {
"path": "backend/http/dispatch.py", "path": "backend/http/dispatch.py",
"bytes": 4118, "bytes": 4196,
"lines": 115 "lines": 117
}, },
{ {
"path": "frontend/shared/table.js", "path": "frontend/shared/table.js",
@@ -734,16 +738,16 @@
"bytes": 3369, "bytes": 3369,
"lines": 81 "lines": 81
}, },
{
"path": "frontend/app.js",
"bytes": 3337,
"lines": 95
},
{ {
"path": "frontend/pages/themes/page.html", "path": "frontend/pages/themes/page.html",
"bytes": 3316, "bytes": 3316,
"lines": 55 "lines": 55
}, },
{
"path": "frontend/app.js",
"bytes": 3201,
"lines": 93
},
{ {
"path": "backend/features/market/insights_context.py", "path": "backend/features/market/insights_context.py",
"bytes": 3175, "bytes": 3175,
@@ -761,7 +765,7 @@
}, },
{ {
"path": "backend/features/accounts/application.py", "path": "backend/features/accounts/application.py",
"bytes": 2442, "bytes": 2514,
"lines": 63 "lines": 63
}, },
{ {
@@ -769,11 +773,6 @@
"bytes": 2299, "bytes": 2299,
"lines": 57 "lines": 57
}, },
{
"path": "backend/jobs/service.py",
"bytes": 2219,
"lines": 60
},
{ {
"path": "backend/features/screener/regime.py", "path": "backend/features/screener/regime.py",
"bytes": 2202, "bytes": 2202,
@@ -805,9 +804,9 @@
"lines": 45 "lines": 45
}, },
{ {
"path": "backend/features/system/routes.py", "path": "backend/jobs/service.py",
"bytes": 1791, "bytes": 1746,
"lines": 46 "lines": 49
}, },
{ {
"path": "backend/features/alerts/routes.py", "path": "backend/features/alerts/routes.py",
@@ -834,6 +833,11 @@
"bytes": 1455, "bytes": 1455,
"lines": 48 "lines": 48
}, },
{
"path": "backend/features/system/routes.py",
"bytes": 1423,
"lines": 40
},
{ {
"path": "backend/features/themes/routes.py", "path": "backend/features/themes/routes.py",
"bytes": 1337, "bytes": 1337,
@@ -849,6 +853,11 @@
"bytes": 1143, "bytes": 1143,
"lines": 19 "lines": 19
}, },
{
"path": "backend/features/accounts/routes.py",
"bytes": 908,
"lines": 25
},
{ {
"path": "backend/features/popularity/routes.py", "path": "backend/features/popularity/routes.py",
"bytes": 822, "bytes": 822,
@@ -859,11 +868,6 @@
"bytes": 817, "bytes": 817,
"lines": 23 "lines": 23
}, },
{
"path": "backend/features/accounts/routes.py",
"bytes": 803,
"lines": 22
},
{ {
"path": "backend/features/sentiment/routes.py", "path": "backend/features/sentiment/routes.py",
"bytes": 724, "bytes": 724,
-69
View File
@@ -1,69 +0,0 @@
# 行情历史补档(最近 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 列、智能选股置信度随连续交易日恢复。
+4 -2
View File
@@ -25,8 +25,10 @@ async function initialize() {
try { try {
const session = await apiRequest("/api/auth/me"); const session = await apiRequest("/api/auth/me");
if (!session.authenticated) { if (!session.authenticated) {
if (session.registration_required) selectAuthMode("register"); const params = new URLSearchParams();
showAuthGate(); if (session.registration_required) params.set("mode", "register");
const query = params.toString();
window.location.replace("/login/" + (query ? `?${query}` : ""));
return; return;
} }
await applyAuthenticatedSession(session); await applyAuthenticatedSession(session);
+10 -9
View File
@@ -24,7 +24,9 @@
(() => { (() => {
let theme = "light"; let theme = "light";
try { 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) { } catch (_error) {
theme = "light"; theme = "light";
} }
@@ -32,11 +34,11 @@
document.documentElement.style.colorScheme = theme; document.documentElement.style.colorScheme = theme;
})(); })();
</script> </script>
<link rel="stylesheet" href="/shared/tokens.css?v=20260820-3"> <link rel="stylesheet" href="/shared/tokens.css?v=20260829-1">
<link rel="stylesheet" href="/shared/base.css?v=20260806-1"> <link rel="stylesheet" href="/shared/base.css?v=20260806-1">
<link rel="stylesheet" href="/shared/shell.css?v=20260827-hel183"> <link rel="stylesheet" href="/shared/shell.css?v=20260820-8">
<link rel="stylesheet" href="/shared/auth.css?v=20260820-5"> <link rel="stylesheet" href="/shared/auth.css?v=20260829-1">
<link rel="stylesheet" href="/shared/components/controls.css?v=20260827-hel183"> <link rel="stylesheet" href="/shared/components/controls.css?v=20260820-2">
<link rel="stylesheet" href="/shared/components/navigation.css?v=20260820-1"> <link rel="stylesheet" href="/shared/components/navigation.css?v=20260820-1">
<link rel="stylesheet" href="/shared/components/cards.css?v=20260820-1"> <link rel="stylesheet" href="/shared/components/cards.css?v=20260820-1">
<link rel="stylesheet" href="/shared/components/tables.css?v=20260820-1"> <link rel="stylesheet" href="/shared/components/tables.css?v=20260820-1">
@@ -52,12 +54,12 @@
<link rel="stylesheet" href="/pages/popularity/foundation.css?v=20260820-1"> <link rel="stylesheet" href="/pages/popularity/foundation.css?v=20260820-1">
<link rel="stylesheet" href="/pages/dragon-tiger/foundation.css?v=20260820-1"> <link rel="stylesheet" href="/pages/dragon-tiger/foundation.css?v=20260820-1">
<link rel="stylesheet" href="/pages/screener/foundation.css?v=20260820-4"> <link rel="stylesheet" href="/pages/screener/foundation.css?v=20260820-4">
<link rel="stylesheet" href="/pages/mentor/foundation.css?v=20260827-hel183"> <link rel="stylesheet" href="/pages/mentor/foundation.css?v=20260820-2">
<link rel="stylesheet" href="/pages/heaven/foundation.css?v=20260827-hel183"> <link rel="stylesheet" href="/pages/heaven/foundation.css?v=20260806-2">
<link rel="stylesheet" href="/pages/review/foundation.css?v=20260820-4"> <link rel="stylesheet" href="/pages/review/foundation.css?v=20260820-4">
</head> </head>
<body> <body>
<section id="authGate" class="auth-gate" aria-label="账号登录"> <section id="authGate" class="auth-gate" aria-label="账号登录" hidden>
<div class="auth-shell"> <div class="auth-shell">
<div class="auth-brand"> <div class="auth-brand">
<div class="brand-mark" aria-hidden="true"><span class="brand-glyph"></span></div> <div class="brand-mark" aria-hidden="true"><span class="brand-glyph"></span></div>
@@ -609,7 +611,6 @@
<label class="form-field"><span>iFinD Refresh Token</span><input id="systemIfindTokenInput" type="password" autocomplete="off" maxlength="2048" placeholder="留空保留现有 Token"></label> <label class="form-field"><span>iFinD Refresh Token</span><input id="systemIfindTokenInput" type="password" autocomplete="off" maxlength="2048" placeholder="留空保留现有 Token"></label>
<label class="switch-control"><input id="systemBackgroundRefresh" type="checkbox"><span>启用交易时段后台刷新</span></label> <label class="switch-control"><input id="systemBackgroundRefresh" type="checkbox"><span>启用交易时段后台刷新</span></label>
<p class="form-hint">所有用户读取同一份后台快照,页面不会随后台任务自动重绘。</p> <p class="form-hint">所有用户读取同一份后台快照,页面不会随后台任务自动重绘。</p>
<div id="adminRefreshStatus" class="admin-refresh-status" data-tone="idle" role="status" aria-live="polite"><i data-lucide="circle-dot"></i><span>尚未手动刷新</span></div>
<div class="dialog-actions admin-inline-actions"><button id="adminRefreshButton" class="button" type="button"><i data-lucide="refresh-cw"></i>立即后台刷新</button><button class="button primary" type="submit">保存行情配置</button></div> <div class="dialog-actions admin-inline-actions"><button id="adminRefreshButton" class="button" type="button"><i data-lucide="refresh-cw"></i>立即后台刷新</button><button class="button primary" type="submit">保存行情配置</button></div>
</form> </form>
<section class="settings-section"> <section class="settings-section">
+59
View File
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>登录 · 小白复盘</title>
<script>
(() => {
let theme = "light";
try {
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";
}
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
})();
</script>
<link rel="stylesheet" href="/shared/tokens.css?v=20260829-1">
<link rel="stylesheet" href="/shared/base.css?v=20260806-1">
<link rel="stylesheet" href="/shared/auth.css?v=20260829-1">
<link rel="stylesheet" href="/shared/components/controls.css?v=20260820-2">
</head>
<body class="login-portal">
<button id="loginThemeToggle" class="login-theme-toggle" type="button">🌙 夜间</button>
<aside class="login-brand" aria-hidden="true">
<div class="login-brand-mark"><span class="login-brand-glyph"></span></div>
<p class="login-brand-kicker">收盘之后 · 复盘开始</p>
<h1 class="login-brand-title">小白复盘</h1>
<p class="login-brand-lead">看懂情绪周期,把复盘变成下一次的先手。</p>
<dl class="login-brand-stats">
<div class="login-stat">
<dt>市场情绪</dt>
<dd>72 <span class="login-stat-tag">高热</span></dd>
</div>
<div class="login-stat">
<dt>涨停</dt>
<dd>63</dd>
</div>
<div class="login-stat">
<dt>跌停</dt>
<dd>4</dd>
</div>
<div class="login-stat">
<dt>两市成交</dt>
<dd>1.02万亿</dd>
</div>
</dl>
</aside>
<main class="login-stage">
<section class="login-card" id="loginCard" aria-live="polite"></section>
</main>
<script src="/shared/api.js?v=20260803-2"></script>
<script src="/login/page.js?v=20260829-1"></script>
</body>
</html>
+273
View File
@@ -0,0 +1,273 @@
(function bootLoginPortal(global) {
"use strict";
const THEME_KEY = "xiaobaiTheme";
const api = global.XiaobaiAPI;
const card = document.querySelector("#loginCard");
const themeButton = document.querySelector("#loginThemeToggle");
const state = {
view: "first",
mode: "login",
accounts: [],
currentUserId: null,
loading: false,
confirmingId: null,
error: "",
};
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (ch) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch]
));
}
function preferredTheme() {
try {
const stored = global.localStorage.getItem(THEME_KEY);
if (stored === "dark" || stored === "light") return stored;
} catch (_error) {
// Fall through to the system preference.
}
return global.matchMedia && global.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function applyTheme(theme, persist) {
const normalized = theme === "dark" ? "dark" : "light";
document.documentElement.dataset.theme = normalized;
document.documentElement.style.colorScheme = normalized;
themeButton.textContent = normalized === "dark" ? "☀ 日间" : "🌙 夜间";
themeButton.setAttribute("aria-label", normalized === "dark" ? "切换到日间模式" : "切换到夜间模式");
if (persist) {
try {
global.localStorage.setItem(THEME_KEY, normalized);
} catch (_error) {
// Theme still applies for the current page when storage is unavailable.
}
}
}
function setError(message) {
state.error = message || "";
}
function membershipLabel(account) {
if (account.role === "admin") return account.membership?.subscribed ? "管理员 · 会员" : "管理员";
return account.membership?.subscribed ? "会员" : "普通用户";
}
function enterApp() {
const next = new URLSearchParams(global.location.search).get("next");
global.location.replace(next && next.startsWith("/") ? next : "/");
}
function formMarkup(options) {
const registering = state.mode === "register";
const submitLabel = options.submitLabel
|| (state.loading ? "正在登录..." : registering ? "注册并进入" : options.add ? "添加并进入" : "登录");
return [
options.back
? '<button class="login-back" type="button" data-login-action="picker">返回账号列表</button>'
: "",
`<h2 class="login-card-title">${escapeHtml(options.title)}</h2>`,
`<p class="login-card-lead">${escapeHtml(options.lead)}</p>`,
'<div class="login-tabs" role="tablist">',
`<button class="login-tab${state.mode === "login" ? " is-active" : ""}" type="button" data-auth-mode="login">登录</button>`,
`<button class="login-tab${state.mode === "register" ? " is-active" : ""}" type="button" data-auth-mode="register">注册</button>`,
"</div>",
'<form class="login-form" id="loginForm">',
'<label class="form-field"><span>账号名</span><input id="loginUsername" type="text" minlength="3" maxlength="30" autocomplete="username" required></label>',
`<label class="form-field"><span>密码</span><input id="loginPassword" type="password" minlength="8" maxlength="128" autocomplete="${registering ? "new-password" : "current-password"}" required></label>`,
`<label class="form-field" id="loginConfirmField"${registering ? "" : " hidden"}><span>确认密码</span><input id="loginPasswordConfirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"${registering ? " required" : ""}></label>`,
state.error ? `<p class="login-error">${escapeHtml(state.error)}</p>` : '<p class="login-error" hidden></p>',
`<button class="button primary login-submit" type="submit"${state.loading ? " disabled" : ""}>`,
state.loading ? '<span class="login-spinner" aria-hidden="true"></span>' : "",
`<span>${escapeHtml(submitLabel)}</span></button>`,
"</form>",
'<p class="login-hint">密码连续输错 5 次将锁定 10 分钟。还没有账号?切换到「注册」创建。</p>',
].join("");
}
function accountRow(account) {
const current = Number(account.user_id) === Number(state.currentUserId);
const confirming = Number(state.confirmingId) === Number(account.user_id);
const classes = `login-account-row${current ? " is-current" : ""}${confirming ? " is-confirming" : ""}`;
if (state.view === "manage" && confirming) {
return [
`<div class="${classes}" data-user-id="${account.user_id}">`,
`<p class="login-confirm-copy">移除「${escapeHtml(account.username)}」的本机记录?</p>`,
'<div class="login-confirm-actions">',
`<button class="button danger-button" type="button" data-forget-id="${account.user_id}">移除</button>`,
'<button class="button" type="button" data-login-action="cancel-forget">取消</button>',
"</div></div>",
].join("");
}
const action = state.view === "manage"
? `<button class="login-account-remove" type="button" data-confirm-id="${account.user_id}">移除</button>`
: current
? '<span class="login-account-check" aria-hidden="true">✓</span>'
: `<button class="login-account-enter" type="button" data-switch-id="${account.user_id}">进入</button>`;
return [
`<div class="${classes}" data-user-id="${account.user_id}">`,
'<div class="login-account-meta">',
`<strong>${escapeHtml(account.username)}</strong>`,
`<span>${escapeHtml(membershipLabel(account))}${current ? " · 当前" : ""}</span>`,
"</div>",
action,
"</div>",
].join("");
}
function pickerMarkup() {
const count = state.accounts.length;
const managing = state.view === "manage";
return [
`<h2 class="login-card-title">${managing ? "管理账号记录" : "选择账号"}</h2>`,
`<p class="login-card-lead">这台电脑已记录 ${count} 个账号,可直接进入,无需再次输入密码。</p>`,
managing
? '<button class="login-manage" type="button" data-login-action="picker">完成</button>'
: "",
`<div class="login-account-list">${state.accounts.map(accountRow).join("")}</div>`,
managing
? ""
: '<button class="login-add" type="button" data-login-action="add"> 添加账号</button>',
managing
? ""
: '<button class="login-manage" type="button" data-login-action="manage">管理已记录的账号</button>',
state.error ? `<p class="login-error">${escapeHtml(state.error)}</p>` : "",
'<p class="login-privacy"><span class="login-lock" aria-hidden="true">🔒</span>账号记录仅保存在这台电脑的浏览器中</p>',
].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: "登录后进入你的复盘空间",
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", () => {
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;
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);
+23
View File
@@ -246,6 +246,29 @@ body {
padding-top: 8px; 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 { .m-auth-brand {
text-align: center; text-align: center;
margin: 24px 0 20px; margin: 24px 0 20px;
+3 -1
View File
@@ -15,7 +15,9 @@
(() => { (() => {
let theme = "light"; let theme = "light";
try { 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) { } catch (_error) {
theme = "light"; theme = "light";
} }
+6 -1
View File
@@ -5,10 +5,15 @@
function readTheme() { function readTheme() {
try { 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) { } catch (_error) {
return "light"; return "light";
} }
if (global.matchMedia && global.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
} }
function applyTheme(theme) { function applyTheme(theme) {
+30
View File
@@ -250,6 +250,7 @@
'<h2>小白复盘</h2>', '<h2>小白复盘</h2>',
'<p>登录后进入你的复盘空间</p>', '<p>登录后进入你的复盘空间</p>',
"</div>", "</div>",
'<div class="m-auth-accounts" id="m-auth-accounts" hidden></div>',
'<div class="m-auth-tabs">', '<div class="m-auth-tabs">',
'<button class="m-auth-tab active" type="button" data-auth-mode="login">登录</button>', '<button class="m-auth-tab active" type="button" data-auth-mode="login">登录</button>',
'<button class="m-auth-tab" type="button" data-auth-mode="register">注册</button>', '<button class="m-auth-tab" type="button" data-auth-mode="register">注册</button>',
@@ -265,6 +266,7 @@
].join(""); ].join("");
authMode = "login"; authMode = "login";
bindAuth(); bindAuth();
loadMobileAccounts();
} }
function setAuthMode(mode) { function setAuthMode(mode) {
@@ -287,6 +289,34 @@
document.getElementById("m-auth-form").addEventListener("submit", submitAuth); document.getElementById("m-auth-form").addEventListener("submit", submitAuth);
} }
async function loadMobileAccounts() {
const host = document.getElementById("m-auth-accounts");
if (!host || !global.MobileSession.listAccounts) return;
try {
const payload = await global.MobileSession.listAccounts();
const accounts = payload.accounts || [];
if (!accounts.length) return;
host.hidden = false;
host.innerHTML = accounts.map(function (account) {
return '<button class="m-auth-account" type="button" data-switch-id="' + account.user_id + '">' +
'<strong>' + escapeHtml(account.username) + '</strong>' +
'<span>直接进入</span></button>';
}).join("");
host.querySelectorAll("[data-switch-id]").forEach(function (button) {
button.addEventListener("click", async function () {
try {
await global.MobileSession.switchAccount(Number(button.dataset.switchId));
replace(DEFAULT_HASH);
} catch (error) {
showAuthError(document.getElementById("m-auth-error"), error.message || "该账号需重新验证");
}
});
});
} catch (_error) {
host.hidden = true;
}
}
function showAuthError(element, message) { function showAuthError(element, message) {
element.textContent = message; element.textContent = message;
element.classList.remove("m-motion-fade-in"); element.classList.remove("m-motion-fade-in");
+26 -1
View File
@@ -47,5 +47,30 @@
return Boolean(state.user && state.user.role === "admin"); return Boolean(state.user && state.user.role === "admin");
} }
global.MobileSession = { state: state, me: me, login: login, register: register, logout: logout, isAdmin: isAdmin }; async function listAccounts() {
return global.MobileAPI.request("/api/auth/accounts");
}
async function switchAccount(userId) {
const payload = await global.MobileAPI.request("/api/auth/switch", "POST", {
user_id: userId
});
return applySession(payload);
}
async function forgetAccount(userId) {
await global.MobileAPI.request("/api/auth/forget", "POST", { user_id: userId });
}
global.MobileSession = {
state: state,
me: me,
login: login,
register: register,
logout: logout,
listAccounts: listAccounts,
switchAccount: switchAccount,
forgetAccount: forgetAccount,
isAdmin: isAdmin
};
})(window); })(window);
+243 -3
View File
@@ -93,6 +93,28 @@ body[data-active-view="heavenView"] .workspace-view {
display: none; display: none;
} }
:where(#heavenView) .heaven-tabs {
display: flex;
border-bottom: 1px solid var(--line);
}
:where(#heavenView) .heaven-tab {
border-bottom: 3px solid transparent;
background: transparent;
cursor: pointer;
}
:where(#heavenView) .heaven-tab.active {
border-bottom-color: var(--coral);
}
:where(#heavenView) .heaven-tab:hover {
border-bottom-color: var(--coral);
}
:where(#heavenView) .heaven-panel { :where(#heavenView) .heaven-panel {
display: none; display: none;
} }
@@ -1853,6 +1875,60 @@ body[data-active-view="heavenView"] .workspace-view {
color: var(--heaven-ink-faint); color: var(--heaven-ink-faint);
} }
#heavenView .heaven-tab {
font-family: var(--heaven-serif);
height: 52px;
position: relative;
padding: 0px 2px;
border: 0px;
color: var(--heaven-ink-soft);
font-size: 14px;
font-weight: 600;
}
#heavenView .heaven-tab::after {
content: "";
position: absolute;
right: 0px;
bottom: 0px;
left: 0px;
height: 2px;
background: var(--heaven-cinnabar);
opacity: 0;
transform: scaleX(0.3);
transition: opacity 220ms ease, transform 260ms var(--ease-out);
}
#heavenView .heaven-tab.active {
color: var(--heaven-ink);
}
#heavenView .heaven-tab:hover {
color: var(--heaven-ink);
}
#heavenView .heaven-tab.active::after {
opacity: 1;
transform: scaleX(1);
}
:where(#heavenView) .heaven-proverb { :where(#heavenView) .heaven-proverb {
margin: 0px; margin: 0px;
@@ -1884,7 +1960,7 @@ body[data-active-view="heavenView"] .workspace-view {
} }
#heavenView .button:focus-visible, #heavenView .button:focus-visible,
#heavenView .segment:focus-visible, #heavenView .heaven-tab:focus-visible,
#heavenView summary:focus-visible { #heavenView summary:focus-visible {
outline: 2px solid var(--heaven-cinnabar); outline: 2px solid var(--heaven-cinnabar);
@@ -2547,6 +2623,12 @@ body[data-active-view="heavenView"] .workspace-view {
padding: 0px 14px; padding: 0px 14px;
} }
#heavenView .heaven-tabs {
gap: 22px;
padding: 0px 14px;
}
.heaven-proverb { .heaven-proverb {
padding: 8px 14px; padding: 8px 14px;
@@ -3011,7 +3093,7 @@ body[data-active-view="heavenView"] .workspace-view {
#heavenView.heaven-data-loading .heaven-panel, #heavenView.heaven-data-loading .heaven-panel,
#heavenView.heaven-data-loading .heaven-proverb, #heavenView.heaven-data-loading .heaven-proverb,
#heavenView.heaven-data-loading .heaven-page-head { #heavenView.heaven-data-loading .heaven-tabs {
opacity: 0.42; opacity: 0.42;
pointer-events: none; pointer-events: none;
@@ -3613,7 +3695,7 @@ body[data-active-view="heavenView"] .workspace-view {
@media (max-width: 900px) { @media (max-width: 900px) {
#heavenView .heaven-panel > , #heavenView .heaven-panel > ,
#heavenView .heaven-proverb, #heavenView .heaven-proverb,
#heavenView .heaven-page-head, #heavenView .heaven-tabs,
#heavenView .heaven-toolbar { #heavenView .heaven-toolbar {
width: min(100% - 28px, 1280px); width: min(100% - 28px, 1280px);
} }
@@ -3664,6 +3746,22 @@ body[data-active-view="heavenView"] .workspace-view {
display: none; display: none;
} }
#heavenView .heaven-tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0px, 1fr));
gap: 0px;
padding: 0px;
}
#heavenView .heaven-tab {
width: 100%;
min-width: 0px;
}
#heavenFortunePanel .fortune-heading { #heavenFortunePanel .fortune-heading {
display: flex; display: flex;
@@ -3769,6 +3867,18 @@ body[data-active-view="heavenView"] .workspace-view {
} }
@media (max-width: 520px) { @media (max-width: 520px) {
.heaven-tabs {
gap: 0px;
padding: 0px 8px;
}
.heaven-tab {
min-width: 0px;
flex: 1 1 0%;
}
.heaven-controls { .heaven-controls {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -6122,6 +6232,26 @@ body[data-active-view="heavenView"] .workspace-view {
padding: 12px 20px; padding: 12px 20px;
} }
#heavenView .heaven-tabs {
align-items: stretch;
gap: 30px;
border-color: var(--heaven-rule);
background: rgba(253, 252, 248, 0.96);
width: min(100% - 40px, 1280px);
margin-right: auto;
margin-left: auto;
min-height: 54px;
padding: 0px 20px;
}
#heavenView .heaven-proverb { #heavenView .heaven-proverb {
margin-right: auto; margin-right: auto;
@@ -6167,6 +6297,12 @@ body[data-active-view="heavenView"] .workspace-view {
padding: 10px 14px; padding: 10px 14px;
} }
#heavenView .heaven-tabs {
min-height: 52px;
padding: 0px 14px;
}
#heavenView .heaven-proverb { #heavenView .heaven-proverb {
padding: 9px 14px; padding: 9px 14px;
} }
@@ -6532,6 +6668,18 @@ body[data-active-view="heavenView"] .workspace-view {
color: var(--wt-faint); color: var(--wt-faint);
} }
:root[data-theme="light"] #heavenView .wt-tabs .wt-tab {
color: var(--wt-muted);
}
:root[data-theme="light"] #heavenView .wt-tabs .wt-tab small {
color: var(--wt-faint);
}
:root[data-theme="light"] #heavenView .wt-tabs .wt-tab.on {
color: var(--wt-gold-bright);
}
:root[data-theme="light"] #heavenView .wt-empty { :root[data-theme="light"] #heavenView .wt-empty {
color: var(--wt-muted); color: var(--wt-muted);
} }
@@ -6980,6 +7128,88 @@ body[data-active-view="heavenView"] .workspace-view {
letter-spacing: 4px; letter-spacing: 4px;
} }
.wt-tabs {
display: flex;
justify-content: center;
gap: 34px;
margin-top: 20px;
}
.wt-tabs .wt-tab {
position: relative;
padding: 8px 4px;
border: 0px;
background: transparent;
color: rgba(216, 210, 189, 0.5);
font-size: 15px;
letter-spacing: 3px;
transition: color 0.2s;
}
.wt-tabs .wt-tab small {
display: block;
margin-top: 3px;
color: rgba(216, 210, 189, 0.3);
font-family: inherit;
font-size: 10px;
letter-spacing: 1px;
}
.wt-tabs .wt-tab::after {
content: "";
position: absolute;
bottom: -2px;
left: 50%;
width: 0px;
height: 1.5px;
background: var(--wt-gold);
transform: translateX(-50%);
transition: 0.25s;
}
.wt-tabs .wt-tab.on {
color: var(--wt-gold-bright);
}
.wt-tabs .wt-tab.on::after {
width: 100%;
}
.wt-tabs .wt-tab:disabled {
cursor: default;
}
.wt-tabs .wt-tab:focus {
outline: none;
}
.wt-tabs .wt-tab:focus-visible {
box-shadow: rgba(201, 165, 92, 0.45) 0px 2px 0px;
}
.heaven-proverb { .heaven-proverb {
margin: 8px 0px 0px; margin: 8px 0px 0px;
@@ -8842,6 +9072,16 @@ body[data-active-view="heavenView"] .workspace-view {
gap: 5px; gap: 5px;
} }
.wt-tabs {
gap: 16px;
}
.wt-tabs .wt-tab {
font-size: 13px;
letter-spacing: 2px;
}
.heaven-proverb { .heaven-proverb {
text-align: center; text-align: center;
} }
+6 -13
View File
@@ -1,23 +1,16 @@
<section id="heavenView" class="workspace-view member-feature-view heaven-shell wt"> <section id="heavenView" class="workspace-view member-feature-view heaven-shell wt">
<div class="member-gate" hidden><div class="member-gate-icon"><i data-lucide="lock-keyhole"></i></div><div><strong>问天仅对会员开放</strong><span>开通会员后可使用观势、观气、观心及平台解读。会员状态可从顶部账号标识进入。</span></div></div> <div class="member-gate" hidden><div class="member-gate-icon"><i data-lucide="lock-keyhole"></i></div><div><strong>问天仅对会员开放</strong><span>开通会员后可使用观势、观气、观心及平台解读。会员状态可从顶部账号标识进入。</span></div></div>
<div class="section-toolbar redesigned-page-head heaven-page-head">
<div class="section-title-group">
<h2>问天</h2>
<span class="section-subtitle"><b id="heavenDataDate">--</b></span>
</div>
<div class="toolbar-controls">
<div class="segmented" role="group" aria-label="问天模块">
<button class="segment active on" type="button" data-heaven-panel="trend" aria-current="page">观势</button>
<button class="segment" type="button" data-heaven-panel="fortune">观气</button>
<button class="segment" type="button" data-heaven-panel="heart">观心</button>
</div>
</div>
</div>
<header class="wt-head"> <header class="wt-head">
<div class="wt-title-line"> <div class="wt-title-line">
<h1 class="wt-serif">问 天</h1> <h1 class="wt-serif">问 天</h1>
<span id="heavenDataDate">--</span>
</div> </div>
<div class="verse wt-serif">观天之道 · 执天之行</div> <div class="verse wt-serif">观天之道 · 执天之行</div>
<nav class="wt-tabs" aria-label="问天模块">
<button class="wt-tab wt-serif on" type="button" data-heaven-panel="trend" aria-current="page">观势<small>三才六爻 · 量化成卦</small></button>
<button class="wt-tab wt-serif" type="button" data-heaven-panel="fortune">观气<small>五运六气 · 日辰生克</small></button>
<button class="wt-tab wt-serif" type="button" data-heaven-panel="heart">观心<small>静心占卜 · 第一念</small></button>
</nav>
</header> </header>
<p class="heaven-proverb wt-serif">遇事不决可问春风,春风不语即随本心</p> <p class="heaven-proverb wt-serif">遇事不决可问春风,春风不语即随本心</p>
<div id="heavenNotice" class="inline-notice" role="status" hidden></div> <div id="heavenNotice" class="inline-notice" role="status" hidden></div>
+1 -2
View File
@@ -1363,8 +1363,7 @@ async function interpretHeaven(mode) {
} catch (error) { } catch (error) {
stopHeavenReadingAnimation(); stopHeavenReadingAnimation();
state.heavenReadingLoading = false; state.heavenReadingLoading = false;
const detail = error?.payload?.message || error?.payload?.error || error.message; state.heavenReadingError = error.message || "问天解读失败";
state.heavenReadingError = detail || "问天解读失败";
renderHeavenReadingDialog(); renderHeavenReadingDialog();
showHeavenNotice(state.heavenReadingError); showHeavenNotice(state.heavenReadingError);
showToast(state.heavenReadingError); showToast(state.heavenReadingError);
+1 -1
View File
@@ -49,7 +49,7 @@ body[data-active-view="mentorView"] .app-page-context span {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
flex-direction: column; flex-direction: column;
padding: var(--page-pad-y) 0 0; padding: 0;
color: var(--qp-text-1); color: var(--qp-text-1);
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
} }
+73 -6
View File
@@ -1011,9 +1011,9 @@
border-radius: 6px; border-radius: 6px;
background: var(--r2-ink); background: var(--sentiment-tooltip-bg);
color: var(--text-inverse); color: var(--sentiment-tooltip-fg);
font-size: 11px; font-size: 11px;
@@ -1021,6 +1021,8 @@
} }
.sentiment-chart-tooltip b { .sentiment-chart-tooltip b {
color: inherit;
font-weight: 700; font-weight: 700;
} }
@@ -1568,10 +1570,6 @@
#sentimentCycleView .sentiment-component-item { #sentimentCycleView .sentiment-component-item {
padding: 4px 0px; padding: 4px 0px;
} }
#sentimentCycleView .sentiment-component-item small {
display: none;
}
} }
@media (min-width: 768px) { @media (min-width: 768px) {
@@ -1764,3 +1762,72 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); 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;
}
}
+38 -4
View File
@@ -1,10 +1,39 @@
let sentimentChartAnimationFrame = null; let sentimentChartAnimationFrame = null;
let sentimentChartResizeObserver = null;
let sentimentChartLastSize = "";
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], { window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
bind: bindSentimentEvents, bind: bindSentimentEvents,
enter: ["loadSentiment"], 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) { async function loadSentimentHistory(force = false) {
if (!state.dashboard || state.sentimentLoading) return; if (!state.dashboard || state.sentimentLoading) return;
const key = `${elements.tradeDate.value}:${state.sentimentRange}`; const key = `${elements.tradeDate.value}:${state.sentimentRange}`;
@@ -126,6 +155,7 @@ function animateSentimentTrendChart(rows) {
if (sentimentChartAnimationFrame) cancelAnimationFrame(sentimentChartAnimationFrame); if (sentimentChartAnimationFrame) cancelAnimationFrame(sentimentChartAnimationFrame);
if (!motionEnabled()) { if (!motionEnabled()) {
drawSentimentTrendChart(rows, 1); drawSentimentTrendChart(rows, 1);
observeSentimentTrendChart();
return; return;
} }
const startedAt = performance.now(); const startedAt = performance.now();
@@ -135,7 +165,10 @@ function animateSentimentTrendChart(rows) {
const progress = 1 - (1 - rawProgress) ** 3; const progress = 1 - (1 - rawProgress) ** 3;
drawSentimentTrendChart(rows, progress); drawSentimentTrendChart(rows, progress);
if (rawProgress < 1) sentimentChartAnimationFrame = requestAnimationFrame(frame); if (rawProgress < 1) sentimentChartAnimationFrame = requestAnimationFrame(frame);
else sentimentChartAnimationFrame = null; else {
sentimentChartAnimationFrame = null;
observeSentimentTrendChart();
}
}; };
sentimentChartAnimationFrame = requestAnimationFrame(frame); sentimentChartAnimationFrame = requestAnimationFrame(frame);
} }
@@ -144,9 +177,9 @@ function drawSentimentTrendChart(rows, progress = 1) {
const canvas = document.querySelector("#sentimentTrendChart"); const canvas = document.querySelector("#sentimentTrendChart");
if (!canvas || !rows.length || state.activeView !== "sentimentCycleView") return; if (!canvas || !rows.length || state.activeView !== "sentimentCycleView") return;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
if (!rect.width) return; if (rect.width < 8 || rect.height < 8) return;
const width = Math.max(320, rect.width); const width = rect.width;
const height = Math.max(220, rect.height); const height = rect.height;
const ratio = window.devicePixelRatio || 1; const ratio = window.devicePixelRatio || 1;
canvas.width = Math.round(width * ratio); canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio); canvas.height = Math.round(height * ratio);
@@ -258,6 +291,7 @@ function drawSentimentTrendChart(rows, progress = 1) {
const dateText = displayCompactDate(row.trade_date).slice(5); const dateText = displayCompactDate(row.trade_date).slice(5);
context.fillText(dateText, x(index), height - padding.bottom + 10); context.fillText(dateText, x(index), height - padding.bottom + 10);
}); });
sentimentChartLastSize = sentimentChartSizeKey(canvas.closest(".sentiment-chart-shell"));
} }
function bindSentimentChartTooltip(rows) { function bindSentimentChartTooltip(rows) {
+1 -8
View File
@@ -7,14 +7,7 @@ async function backfillData() {
start_date: document.querySelector("#backfillStart").value, start_date: document.querySelector("#backfillStart").value,
end_date: document.querySelector("#backfillEnd").value, end_date: document.querySelector("#backfillEnd").value,
}); });
const failed = (payload.failed_count || 0); showToast(`历史回补完成,共处理 ${payload.results.length} 个工作日`);
const skipped = (payload.skipped_non_trading_days || []).length;
const suffix = failed
? `,失败 ${failed}`
: skipped
? `,跳过 ${skipped} 个非交易日`
: "";
showToast(`历史回补完成,共处理 ${payload.results.length} 个交易日${suffix}`);
state.sentimentHistory = null; state.sentimentHistory = null;
state.sentimentHistoryKey = ""; state.sentimentHistoryKey = "";
if (state.activeView === "sentimentCycleView") { if (state.activeView === "sentimentCycleView") {
+2 -19
View File
@@ -46,29 +46,12 @@
} }
} }
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 = {}) { async function request(url, method = "GET", body = null, options = {}) {
let response; const response = await fetch(url, requestOptions(method, body, options.signal));
try {
response = await fetch(url, requestOptions(method, body, options.signal));
} catch (error) {
throw new ApiError(readableRequestError(error), 0, null);
}
const payload = await parseJson(response); const payload = await parseJson(response);
handleUnauthorized(response, url); handleUnauthorized(response, url);
if (!response.ok || payload.error) { if (!response.ok || payload.error) {
const message = payload.message || payload.error || "请求失败"; throw new ApiError(payload.error || "请求失败", response.status, payload);
throw new ApiError(message, response.status, payload);
} }
return payload; return payload;
} }
+442 -26
View File
@@ -471,32 +471,6 @@
line-height: 1.6; 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 { .account-button > span {
flex: 0 0 auto; flex: 0 0 auto;
@@ -1693,3 +1667,445 @@ button.account-role-badge:focus-visible {
display: inline-flex; display: inline-flex;
} }
} }
.login-portal {
min-height: 100vh;
display: flex;
background: var(--canvas);
color: var(--text-primary);
}
.login-theme-toggle {
position: fixed;
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 {
width: clamp(420px, 34vw, 560px);
flex: 0 0 auto;
padding: 44px 48px 36px;
background: var(--login-brand-gradient);
color: #f4f7ff;
}
.login-brand-mark {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.12);
}
.login-brand-glyph {
font-size: 24px;
font-weight: var(--font-weight-semibold);
}
.login-brand-kicker {
margin: 28px 0 8px;
font-size: var(--font-size-caption);
letter-spacing: 0.08em;
opacity: 0.78;
}
.login-brand-title {
margin: 0;
font-size: 36px;
font-weight: var(--font-weight-semibold);
}
.login-brand-lead {
margin: 12px 0 0;
max-width: 18em;
font-size: var(--font-size-body);
line-height: 1.7;
opacity: 0.86;
}
.login-brand-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px 20px;
margin: 40px 0 0;
}
.login-stat {
margin: 0;
}
.login-stat dt {
color: rgba(244, 247, 255, 0.64);
font-size: var(--font-size-caption);
}
.login-stat dd {
margin: 4px 0 0;
font-size: 20px;
font-weight: var(--font-weight-semibold);
}
.login-stat-tag {
margin-left: 6px;
font-size: var(--font-size-caption);
font-weight: var(--font-weight-regular);
}
.login-stage {
flex: 1 1 auto;
display: grid;
place-items: center;
padding: 48px 24px;
}
.login-card {
width: 400px;
max-width: calc(100vw - 48px);
padding: 32px;
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: 22px;
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 {
height: 38px;
}
.login-submit {
width: 100%;
height: 40px;
min-height: 40px;
}
.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-add,
.login-manage,
.login-account-enter,
.login-account-remove {
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: block;
width: 100%;
min-height: 40px;
margin-top: 8px;
text-align: left;
}
.login-portal .login-manage {
display: block;
width: 100%;
min-height: 40px;
margin-top: 8px;
text-align: left;
color: var(--text-secondary);
}
.login-account-list {
display: grid;
gap: 8px;
margin-top: 20px;
}
.login-account-row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 58px;
padding: 0 14px;
border: 1px solid var(--border);
border-radius: var(--size-radius-md);
background: var(--surface);
}
.login-account-row.is-current {
border-color: var(--action);
}
.login-account-row.is-confirming {
display: grid;
gap: 10px;
padding: 12px 14px;
}
.login-account-meta {
display: grid;
gap: 2px;
}
.login-account-meta strong {
font-weight: var(--font-weight-medium);
}
.login-account-meta span {
color: var(--text-tertiary);
font-size: var(--font-size-caption);
}
.login-account-check {
color: var(--action);
font-size: 16px;
}
.login-confirm-copy {
margin: 0;
font-size: var(--font-size-label);
}
.login-confirm-actions {
display: flex;
gap: 8px;
}
@media (max-width: 900px) {
.login-portal {
flex-direction: column;
}
.login-brand {
width: 100%;
padding: 20px 20px 16px;
}
.login-brand-lead,
.login-brand-stats {
display: none;
}
.login-brand-title {
font-size: 22px;
}
.login-brand-kicker {
margin-top: 12px;
}
.login-stage {
padding: 28px 16px 40px;
}
}
+2 -10
View File
@@ -660,9 +660,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
text-align: left; text-align: left;
height: var(--size-statusbar); min-height: 38px;
min-height: var(--size-statusbar);
display: flex; display: flex;
@@ -672,7 +670,7 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
margin: auto -8px -8px; margin: auto -8px -8px;
padding: 0 16px; padding: 10px 16px;
border-right: 0px; border-right: 0px;
@@ -693,12 +691,6 @@ body.sidebar-collapsed .sidebar-collapse-button .lucide {
font-size: 12px; font-size: 12px;
} }
.sidebar-collapse-button .lucide {
width: 14px;
height: 14px;
}
body.sidebar-collapsed .sidebar-collapse-button span { body.sidebar-collapsed .sidebar-collapse-button span {
display: none; display: none;
} }
+4 -58
View File
@@ -40,71 +40,17 @@ async function loadDashboard(force = false, background = false, showOverlay = tr
async function startAdminRefresh() { async function startAdminRefresh() {
const buttons = [document.querySelector("#syncButton"), document.querySelector("#adminRefreshButton")].filter(Boolean); const buttons = [document.querySelector("#syncButton"), document.querySelector("#adminRefreshButton")].filter(Boolean);
buttons.forEach((button) => { button.disabled = true; }); buttons.forEach((button) => { button.disabled = true; });
const requestedDate = elements.tradeDate.value;
setAdminRefreshStatus("running", `正在刷新 ${requestedDate} 的行情,请稍候…`, "loader-circle");
try { try {
const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: requestedDate }); const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: elements.tradeDate.value });
if (!payload.started || !payload.job_key) { showToast(payload.message || "后台刷新已提交");
setAdminRefreshStatus("warning", "已有刷新任务正在运行,请稍后再试。", "clock-3"); setStatus("后台刷新运行中,当前页面保持不变");
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) { } catch (error) {
const message = error.message || "后台刷新失败"; showToast(error.message || "后台刷新启动失败");
setAdminRefreshStatus("failure", `刷新失败:${message}`, "circle-x");
setStatus("后台刷新失败");
showToast(message);
} finally { } finally {
buttons.forEach((button) => { button.disabled = false; }); 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 = `<i data-lucide="${icon}"></i><span>${escapeHtml(message)}</span>`;
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) { function applyDashboard(payload, background = false) {
state.dashboard = payload; state.dashboard = payload;
const selectedDate = payload.meta.requested_date || payload.meta.trade_date; const selectedDate = payload.meta.requested_date || payload.meta.trade_date;
+5 -15
View File
@@ -53,12 +53,10 @@ async function applyAuthenticatedSession(session) {
function showAuthGate(message = "") { function showAuthGate(message = "") {
state.user = null; state.user = null;
state.csrfToken = ""; state.csrfToken = "";
const gate = document.querySelector("#authGate"); const params = new URLSearchParams();
gate.hidden = false; if (message) params.set("notice", message);
const errorElement = document.querySelector("#authError"); const query = params.toString();
errorElement.textContent = message; window.location.replace("/login/" + (query ? `?${query}` : ""));
errorElement.hidden = !message;
document.querySelector("#authUsername").focus();
} }
async function logoutAccount() { async function logoutAccount() {
@@ -197,16 +195,8 @@ async function changeAccountPassword(event) {
} }
async function switchAccount() { async function switchAccount() {
const button = document.querySelector("#switchAccountMenuButton");
button.disabled = true;
toggleAccountDropdown(false); toggleAccountDropdown(false);
try { window.location.assign("/login/");
await apiRequest("/api/auth/logout", "POST", {});
window.location.reload();
} catch (error) {
showToast(error.message || "切换账号失败");
button.disabled = false;
}
} }
+4 -10
View File
@@ -1294,9 +1294,7 @@ body.sidebar-collapsed {
} }
.sidebar-brand { .sidebar-brand {
height: var(--size-topbar); min-height: 55px;
min-height: var(--size-topbar);
display: flex; display: flex;
@@ -1306,7 +1304,7 @@ body.sidebar-collapsed {
margin: 0px -8px 7px; margin: 0px -8px 7px;
padding: 0 16px; padding: 0px 16px;
border-bottom: 1px solid var(--r2-line-soft); border-bottom: 1px solid var(--r2-line-soft);
@@ -2148,17 +2146,13 @@ body.sidebar-collapsed .status-bar {
} }
.module-nav .sidebar-brand { .module-nav .sidebar-brand {
height: var(--size-topbar);
min-height: var(--size-topbar);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 0 16px; padding: 14px 16px;
border-bottom: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft);
} }
@@ -3500,7 +3494,7 @@ body.sidebar-collapsed .status-bar {
flex: 0 0 auto; flex: 0 0 auto;
align-items: center; align-items: center;
gap: var(--header-action-gap); gap: var(--header-action-gap);
margin-left: auto; margin-left: 0;
overflow: visible; overflow: visible;
} }
+7
View File
@@ -146,6 +146,7 @@
--shadow-float: var(--elevation-float); --shadow-float: var(--elevation-float);
--duration-fast: 150ms; --duration-fast: 150ms;
--duration-normal: 220ms; --duration-normal: 220ms;
--login-brand-gradient: linear-gradient(165deg, #0c1e4a, #16307c, #2153cc);
--font-size-aux: 11.5px; --font-size-aux: 11.5px;
--font-size-caption: 12.5px; --font-size-caption: 12.5px;
@@ -210,6 +211,9 @@
--pool-table-max-height: calc(var(--content-height) - var(--topbar-height) - var(--page-pad-y) - var(--page-pad-y) - var(--card-gap)); --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-max-height: 510px;
--sentiment-history-min-height: 220px; --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; --primary-share: 1.45fr;
--secondary-share: .75fr; --secondary-share: .75fr;
--mobile-nav-height: 64px; --mobile-nav-height: 64px;
@@ -505,10 +509,13 @@
--chart-repair: #e2ad58; --chart-repair: #e2ad58;
--chart-ma-10: #d39a45; --chart-ma-10: #d39a45;
--chart-ma-20: #9aa5af; --chart-ma-20: #9aa5af;
--sentiment-tooltip-bg: #26293e;
--sentiment-tooltip-fg: #e8eaed;
--on-action: #101418; --on-action: #101418;
--warning-line: #6d5a38; --warning-line: #6d5a38;
--warning-line-strong: #66502d; --warning-line-strong: #66502d;
--control-shadow: 0 1px 3px rgba(0, 0, 0, .3); --control-shadow: 0 1px 3px rgba(0, 0, 0, .3);
--login-brand-gradient: linear-gradient(165deg, #080c18, #0e1730, #14224a);
--dialog-backdrop: var(--backdrop); --dialog-backdrop: var(--backdrop);
--ladder-level-1: #2d2426; --ladder-level-1: #2d2426;
--ladder-level-2: #2b2822; --ladder-level-2: #2b2822;
+133 -96
View File
@@ -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) { function waitForApplicationRuntime(page) {
return expect(page.locator("body")).toHaveAttribute("data-runtime-ready", "true"); 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(".sentiment-stage-guide, [data-sentiment-stage]")).toHaveCount(0);
await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。"); await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。");
const alignment = await page.evaluate(() => { const alignment = await page.evaluate(() => {
const analysis = document.querySelector(".redesigned-emotion-grid").getBoundingClientRect();
const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect(); const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect();
const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect(); const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect();
const summary = document.querySelector(".sentiment-cycle-summary").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")); const statusStyle = getComputedStyle(document.querySelector(".sentiment-block .sentiment-text"));
return { return {
columnsAligned: Math.abs(trend.top - summary.top) < 1, 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, 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), detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom),
chartHeight: chart.height, 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.columnsAligned).toBe(true);
expect(alignment.bottomsAligned).toBe(true);
expect(Math.abs(alignment.analysisHeight - 600)).toBeLessThanOrEqual(1);
expect(alignment.railAligned).toBe(true); expect(alignment.railAligned).toBe(true);
expect(alignment.detailAfterAnalysis).toBe(true); expect(alignment.detailAfterAnalysis).toBe(true);
expect(alignment.chartHeight).toBeGreaterThanOrEqual(340); 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 }) => { test("limit-up pool separates stock identity and restores the reason column", async ({ page }) => {
await mockApplication(page, session("user", true)); await mockApplication(page, session("user", true));
await page.goto("/index.html"); await page.goto("/index.html");
@@ -3735,99 +3868,3 @@ test("desktop header keeps commands in view and tape text unclipped across works
await page.screenshot({ path: path.join(shotDir, "admin-390-night.png") }); await page.screenshot({ path: path.join(shotDir, "admin-390-night.png") });
fs.writeFileSync(path.join(shotDir, "measurements.json"), `${JSON.stringify(measurements, null, 2)}\n`); 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") });
});
+151
View File
@@ -0,0 +1,151 @@
const { test, expect } = require("@playwright/test");
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") {
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");
});
+132
View File
@@ -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()
-24
View File
@@ -1,24 +0,0 @@
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()
+4 -3
View File
@@ -25,6 +25,7 @@ class DatabaseMigrationTests(unittest.TestCase):
("0002", "create_job_runs"), ("0002", "create_job_runs"),
("0003", "extend_llm_audit"), ("0003", "extend_llm_audit"),
("0004", "add_mentor_note"), ("0004", "add_mentor_note"),
("0005", "create_account_switch_grants"),
], ],
) )
columns = { columns = {
@@ -39,7 +40,7 @@ class DatabaseMigrationTests(unittest.TestCase):
count = connection.execute( count = connection.execute(
"SELECT COUNT(*) AS count FROM schema_migrations" "SELECT COUNT(*) AS count FROM schema_migrations"
).fetchone()["count"] ).fetchone()["count"]
self.assertEqual(count, 4) self.assertEqual(count, 5)
def test_database_with_recorded_0004_and_note_column_starts_without_reapply( def test_database_with_recorded_0004_and_note_column_starts_without_reapply(
self, self,
@@ -60,7 +61,7 @@ class DatabaseMigrationTests(unittest.TestCase):
count = connection.execute( count = connection.execute(
"SELECT COUNT(*) AS count FROM schema_migrations" "SELECT COUNT(*) AS count FROM schema_migrations"
).fetchone()["count"] ).fetchone()["count"]
self.assertEqual(count, 4) self.assertEqual(count, 5)
def test_old_database_without_0004_upgrades_and_adds_note_column(self) -> None: def test_old_database_without_0004_upgrades_and_adds_note_column(self) -> None:
with tempfile.TemporaryDirectory() as root: with tempfile.TemporaryDirectory() as root:
@@ -87,7 +88,7 @@ class DatabaseMigrationTests(unittest.TestCase):
"PRAGMA table_info(mentor_preferences)" "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) self.assertIn("note", note_rows)
def test_database_with_unknown_migration_is_rejected(self) -> None: def test_database_with_unknown_migration_is_rejected(self) -> None:
+16
View File
@@ -344,6 +344,22 @@ class FrontendContractTests(unittest.TestCase):
self.assertIn("max-height: var(--sentiment-history-max-height);", self.sentiment_styles) self.assertIn("max-height: var(--sentiment-history-max-height);", self.sentiment_styles)
self.assertIn("overflow: auto;", 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): def test_mentor_final_visual_fix_contract(self):
shell_styles = (STATIC_DIR / "shared" / "shell.css").read_text(encoding="utf-8") 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") mentor_html = (STATIC_DIR / "pages" / "mentor" / "page.html").read_text(encoding="utf-8")
+3
View File
@@ -65,8 +65,11 @@ class GovernanceRegistryTests(unittest.TestCase):
public, public,
{ {
("GET", "/api/health"), ("GET", "/api/health"),
("GET", "/api/auth/accounts"),
("POST", "/api/auth/login"), ("POST", "/api/auth/login"),
("POST", "/api/auth/register"), ("POST", "/api/auth/register"),
("POST", "/api/auth/switch"),
("POST", "/api/auth/forget"),
}, },
) )
+1 -150
View File
@@ -1,162 +1,13 @@
from __future__ import annotations from __future__ import annotations
import json
import tempfile
import unittest 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.engine import build_five_phase_field, hexagram_from_lines
from backend.features.heaven.http import HeavenHttpMixin from backend.features.heaven.knowledge import prepare_heaven_context
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 from backend.features.heaven.six_yao import build_six_yao_chart
class HeavenKnowledgeTests(unittest.TestCase): 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): def test_fortune_context_excludes_weighted_summary_and_adds_bounded_industry_symbols(self):
field = build_five_phase_field("2026-08-04") field = build_five_phase_field("2026-08-04")
prepared = prepare_heaven_context( prepared = prepare_heaven_context(
-19
View File
@@ -111,25 +111,6 @@ class RealtimeDashboardTests(unittest.TestCase):
self.assertEqual(quote["amount_billion"], 3.0) self.assertEqual(quote["amount_billion"], 3.0)
self.assertAlmostEqual(quote["turnover_rate"], 0.01) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
-317
View File
@@ -1,317 +0,0 @@
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()
-3
View File
@@ -17,9 +17,6 @@ registry, and verification tools.
`backend/features/*/routes.py` owners. `backend/features/*/routes.py` owners.
- `python tools/build_architecture_inventory.py [--check]`: generate or verify - `python tools/build_architecture_inventory.py [--check]`: generate or verify
`config/architecture-inventory.json` from the current source tree. `config/architecture-inventory.json` from the current source tree.
- `python tools/backfill_recent_snapshots.py --account <admin> [--lookback 60] [--dry-run]`:
auditable recent trading-day dashboard snapshot backfill. See
`docs/maintenance/行情历史补档.md`.
`verify_baseline.py` does not inspect a parent checkout or skip tests according to files outside `verify_baseline.py` does not inspect a parent checkout or skip tests according to files outside
this application. Historical comparison scripts were retired after final standalone acceptance; this application. Historical comparison scripts were retired after final standalone acceptance;
-112
View File
@@ -1,112 +0,0 @@
#!/usr/bin/env python3
"""Auditable recent trading-day dashboard snapshot backfill.
Examples:
python tools/backfill_recent_snapshots.py --account admin --dry-run
python tools/backfill_recent_snapshots.py --account admin --lookback 60
python tools/backfill_recent_snapshots.py --account admin --end-date 2026-08-27 --force
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import date
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from backend.application import SERVICE
from backend.bootstrap.config import normalize_date
from backend.features.market.backfill_history import DEFAULT_RECENT_TRADING_DAYS
def main() -> None:
parser = argparse.ArgumentParser(
description="Backfill the latest N real trading-day dashboard snapshots"
)
parser.add_argument(
"--account",
required=True,
help="Account that can resolve the shared Tushare token",
)
parser.add_argument(
"--end-date",
default=date.today().isoformat(),
help="Inclusive end date YYYY-MM-DD (default: today)",
)
parser.add_argument(
"--lookback",
type=int,
default=DEFAULT_RECENT_TRADING_DAYS,
help=f"Number of open trading days to cover (default {DEFAULT_RECENT_TRADING_DAYS}, max 60)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Plan only: classify missing gaps without writing",
)
parser.add_argument(
"--force",
action="store_true",
help="Re-sync days that already have snapshots",
)
parser.add_argument(
"--no-backup",
action="store_true",
help="Skip the SQLite backup API step (not recommended)",
)
parser.add_argument(
"--json",
action="store_true",
help="Print the full audit payload as JSON",
)
args = parser.parse_args()
user = SERVICE.database.user_by_username(args.account.strip())
if not user:
raise SystemExit("account not found")
SERVICE.bind_user(int(user["id"]))
end_date = normalize_date(args.end_date)
audit = SERVICE.backfill_recent_trading_days(
end_date=end_date,
lookback=args.lookback,
dry_run=args.dry_run,
force=args.force,
create_backup=not args.no_backup,
)
if args.json:
print(json.dumps(audit, ensure_ascii=False, indent=2))
raise SystemExit(0 if audit.get("ok") else 1)
print(
f"mode={audit['mode']} end={audit['end_date']} lookback={audit['lookback']} "
f"dry_run={audit['dry_run']}"
)
print(
f"present={audit['present_count']} missing={audit['missing_count']} "
f"succeeded={audit['succeeded_count']} skipped={audit['skipped_count']} "
f"failed={audit['failed_count']}"
)
if audit.get("backup_path"):
print(f"backup={audit['backup_path']}")
if audit.get("missing"):
print("missing_dates=" + ",".join(audit["missing"]))
if audit.get("created_dates"):
print("created_dates=" + ",".join(audit["created_dates"]))
failed = [row for row in audit.get("results") or [] if row.get("status") == "failed"]
for row in failed:
print(f"failed {row.get('requested_date')}: {row.get('error')}")
if not audit.get("ok"):
raise SystemExit(1)
print("backfill complete")
if __name__ == "__main__":
main()
+7 -1
View File
@@ -50,7 +50,13 @@ def _owner(path: str) -> str:
def _role(method: str, path: str) -> str: def _role(method: str, path: str) -> str:
if path == "/api/health" or path in {"/api/auth/register", "/api/auth/login"}: if path == "/api/health" or path in {
"/api/auth/register",
"/api/auth/login",
"/api/auth/accounts",
"/api/auth/switch",
"/api/auth/forget",
}:
return "public" return "public"
from api_access import required_role from api_access import required_role