fix: enforce account data boundaries and real-data integrity

This commit is contained in:
leefer
2026-07-22 23:30:05 +08:00
parent 835bb947d9
commit 51d7c067a4
7 changed files with 541 additions and 92 deletions
+174 -21
View File
@@ -219,24 +219,43 @@ class ReviewDatabase:
CREATE TABLE IF NOT EXISTS screener_strategies ( CREATE TABLE IF NOT EXISTS screener_strategies (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
regimes TEXT NOT NULL, regimes TEXT NOT NULL,
formula TEXT NOT NULL, formula TEXT NOT NULL,
builtin INTEGER NOT NULL DEFAULT 0, builtin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS screener_runs ( CREATE TABLE IF NOT EXISTS screener_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
trade_date TEXT NOT NULL, trade_date TEXT NOT NULL,
regime TEXT NOT NULL, regime TEXT NOT NULL,
strategy_name TEXT NOT NULL, strategy_name TEXT NOT NULL,
formula TEXT NOT NULL, formula TEXT NOT NULL,
result TEXT NOT NULL, result TEXT NOT NULL,
created_at TEXT NOT NULL created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS mentor_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
mentor_id TEXT NOT NULL,
trade_date TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
meta TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_mentor_messages_conversation
ON mentor_messages(user_id, mentor_id, trade_date, id DESC);
""" """
) )
user_columns = { user_columns = {
@@ -309,6 +328,38 @@ class ReviewDatabase:
ON review_notes(user_id, trade_date DESC, id DESC) ON review_notes(user_id, trade_date DESC, id DESC)
""" """
) )
strategy_columns = {
str(row["name"]) for row in connection.execute("PRAGMA table_info(screener_strategies)")
}
if "user_id" not in strategy_columns:
connection.execute("ALTER TABLE screener_strategies ADD COLUMN user_id INTEGER")
run_columns = {
str(row["name"]) for row in connection.execute("PRAGMA table_info(screener_runs)")
}
if "user_id" not in run_columns:
connection.execute("ALTER TABLE screener_runs ADD COLUMN user_id INTEGER")
if first_user and first_user["id"]:
first_user_id = int(first_user["id"])
connection.execute(
"UPDATE screener_strategies SET user_id = ? WHERE builtin = 0 AND user_id IS NULL",
(first_user_id,),
)
connection.execute(
"UPDATE screener_runs SET user_id = ? WHERE user_id IS NULL",
(first_user_id,),
)
connection.execute(
"""
CREATE INDEX IF NOT EXISTS idx_screener_strategies_user
ON screener_strategies(user_id, builtin, updated_at DESC)
"""
)
connection.execute(
"""
CREATE INDEX IF NOT EXISTS idx_screener_runs_user_date
ON screener_runs(user_id, trade_date DESC, id DESC)
"""
)
def count_users(self) -> int: def count_users(self) -> int:
with self.connect() as connection: with self.connect() as connection:
@@ -658,6 +709,33 @@ class ReviewDatabase:
except json.JSONDecodeError: except json.JSONDecodeError:
return None return None
def get_latest_data_snapshot(
self,
kind: str,
cache_key_prefix: str,
maximum_cache_key: str,
exclude_source: str = "",
) -> dict[str, Any] | None:
source_clause = " AND source != ?" if exclude_source else ""
parameters: list[Any] = [kind, f"{cache_key_prefix}%", maximum_cache_key]
if exclude_source:
parameters.append(exclude_source)
with self.connect() as connection:
row = connection.execute(
f"""
SELECT payload FROM data_snapshots
WHERE kind = ? AND cache_key LIKE ? AND cache_key <= ?{source_clause}
ORDER BY cache_key DESC LIMIT 1
""",
parameters,
).fetchone()
if not row:
return None
try:
return json.loads(row["payload"])
except json.JSONDecodeError:
return None
def save_data_snapshot( def save_data_snapshot(
self, kind: str, cache_key: str, source: str, payload: dict[str, Any] self, kind: str, cache_key: str, source: str, payload: dict[str, Any]
) -> None: ) -> None:
@@ -1073,7 +1151,7 @@ class ReviewDatabase:
return result return result
def save_screener_strategy( def save_screener_strategy(
self, name: str, description: str, regimes: list[str], formula: dict[str, Any], self, user_id: int | None, name: str, description: str, regimes: list[str], formula: dict[str, Any],
builtin: bool = False, strategy_id: int | None = None, builtin: bool = False, strategy_id: int | None = None,
) -> int: ) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds") now = datetime.now().astimezone().isoformat(timespec="seconds")
@@ -1081,12 +1159,21 @@ class ReviewDatabase:
formula_json = json.dumps(formula, ensure_ascii=False, separators=(",", ":")) formula_json = json.dumps(formula, ensure_ascii=False, separators=(",", ":"))
with self.connect() as connection: with self.connect() as connection:
if strategy_id: if strategy_id:
if builtin:
cursor = connection.execute( cursor = connection.execute(
""" """
UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?, UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?,
builtin=?, updated_at=? WHERE id=? builtin=1, user_id=NULL, updated_at=? WHERE id=? AND builtin=1
""", """,
(name, description, regimes_json, formula_json, int(builtin), now, strategy_id), (name, description, regimes_json, formula_json, now, strategy_id),
)
else:
cursor = connection.execute(
"""
UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?,
updated_at=? WHERE id=? AND builtin=0 AND user_id=?
""",
(name, description, regimes_json, formula_json, now, strategy_id, int(user_id or 0)),
) )
if cursor.rowcount == 0: if cursor.rowcount == 0:
raise ValueError("选股策略不存在。") raise ValueError("选股策略不存在。")
@@ -1094,17 +1181,27 @@ class ReviewDatabase:
cursor = connection.execute( cursor = connection.execute(
""" """
INSERT INTO screener_strategies INSERT INTO screener_strategies
(name, description, regimes, formula, builtin, created_at, updated_at) (user_id, name, description, regimes, formula, builtin, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", """,
(name, description, regimes_json, formula_json, int(builtin), now, now), (None if builtin else int(user_id or 0), name, description, regimes_json, formula_json, int(builtin), now, now),
) )
return int(cursor.lastrowid) return int(cursor.lastrowid)
def list_screener_strategies(self) -> list[dict[str, Any]]: def list_screener_strategies(self, user_id: int | None = None) -> list[dict[str, Any]]:
with self.connect() as connection: with self.connect() as connection:
if user_id is None:
rows = connection.execute( rows = connection.execute(
"SELECT * FROM screener_strategies ORDER BY builtin DESC, updated_at DESC, id" "SELECT * FROM screener_strategies WHERE builtin = 1 ORDER BY updated_at DESC, id"
).fetchall()
else:
rows = connection.execute(
"""
SELECT * FROM screener_strategies
WHERE builtin = 1 OR user_id = ?
ORDER BY builtin DESC, updated_at DESC, id
""",
(int(user_id),),
).fetchall() ).fetchall()
result = [] result = []
for row in rows: for row in rows:
@@ -1115,24 +1212,26 @@ class ReviewDatabase:
result.append(item) result.append(item)
return result return result
def delete_screener_strategy(self, strategy_id: int) -> bool: def delete_screener_strategy(self, user_id: int, strategy_id: int) -> bool:
with self.connect() as connection: with self.connect() as connection:
row = connection.execute( row = connection.execute(
"SELECT builtin FROM screener_strategies WHERE id = ?", "SELECT builtin, user_id FROM screener_strategies WHERE id = ?",
(strategy_id,), (strategy_id,),
).fetchone() ).fetchone()
if not row: if not row:
raise ValueError("选股策略不存在。") raise ValueError("选股策略不存在。")
if bool(row["builtin"]): if bool(row["builtin"]):
raise ValueError("内置策略不能删除。") raise ValueError("内置策略不能删除。")
if int(row["user_id"] or 0) != int(user_id):
raise ValueError("无权删除其他账号的策略。")
cursor = connection.execute( cursor = connection.execute(
"DELETE FROM screener_strategies WHERE id = ? AND builtin = 0", "DELETE FROM screener_strategies WHERE id = ? AND builtin = 0 AND user_id = ?",
(strategy_id,), (strategy_id, int(user_id)),
) )
return cursor.rowcount > 0 return cursor.rowcount > 0
def save_screener_run( def save_screener_run(
self, trade_date: str, regime: str, strategy_name: str, self, user_id: int, trade_date: str, regime: str, strategy_name: str,
formula: dict[str, Any], result: dict[str, Any], formula: dict[str, Any], result: dict[str, Any],
) -> int: ) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds") now = datetime.now().astimezone().isoformat(timespec="seconds")
@@ -1140,23 +1239,23 @@ class ReviewDatabase:
cursor = connection.execute( cursor = connection.execute(
""" """
INSERT INTO screener_runs INSERT INTO screener_runs
(trade_date, regime, strategy_name, formula, result, created_at) (user_id, trade_date, regime, strategy_name, formula, result, created_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""", """,
(trade_date, regime, strategy_name, (int(user_id), trade_date, regime, strategy_name,
json.dumps(formula, ensure_ascii=False, separators=(",", ":")), json.dumps(formula, ensure_ascii=False, separators=(",", ":")),
json.dumps(result, ensure_ascii=False, separators=(",", ":")), now), json.dumps(result, ensure_ascii=False, separators=(",", ":")), now),
) )
return int(cursor.lastrowid) return int(cursor.lastrowid)
def latest_screener_run(self, trade_date: str) -> dict[str, Any] | None: def latest_screener_run(self, user_id: int, trade_date: str) -> dict[str, Any] | None:
with self.connect() as connection: with self.connect() as connection:
row = connection.execute( row = connection.execute(
""" """
SELECT id, trade_date, regime, strategy_name, result, created_at SELECT id, trade_date, regime, strategy_name, result, created_at
FROM screener_runs WHERE trade_date <= ? ORDER BY id DESC LIMIT 1 FROM screener_runs WHERE user_id = ? AND trade_date <= ? ORDER BY id DESC LIMIT 1
""", """,
(trade_date,), (int(user_id), trade_date),
).fetchone() ).fetchone()
if not row: if not row:
return None return None
@@ -1168,6 +1267,60 @@ class ReviewDatabase:
result["meta"]["created_at"] = row["created_at"] result["meta"]["created_at"] = row["created_at"]
return result return result
def save_mentor_exchange(
self,
user_id: int,
mentor_id: str,
trade_date: str,
question: str,
answer: str,
meta: str = "",
) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO mentor_messages
(user_id, mentor_id, trade_date, role, content, meta, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
[
(int(user_id), mentor_id, trade_date, "user", question, "", now),
(int(user_id), mentor_id, trade_date, "assistant", answer, meta, now),
],
)
connection.execute(
"""
DELETE FROM mentor_messages
WHERE user_id = ? AND id NOT IN (
SELECT id FROM mentor_messages WHERE user_id = ? ORDER BY id DESC LIMIT 500
)
""",
(int(user_id), int(user_id)),
)
def list_mentor_messages(
self, user_id: int, mentor_id: str, trade_date: str, limit: int = 100
) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT role, content, meta, created_at FROM mentor_messages
WHERE user_id = ? AND mentor_id = ? AND trade_date = ?
ORDER BY id DESC LIMIT ?
""",
(int(user_id), mentor_id, trade_date, max(1, min(500, int(limit)))),
).fetchall()
return [dict(row) for row in reversed(rows)]
def delete_mentor_messages(self, user_id: int, mentor_id: str, trade_date: str) -> int:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM mentor_messages WHERE user_id = ? AND mentor_id = ? AND trade_date = ?",
(int(user_id), mentor_id, trade_date),
)
return int(cursor.rowcount)
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:
+3 -3
View File
@@ -259,7 +259,7 @@ class ScreenerEngine:
existing = {item["name"] for item in self.database.list_screener_strategies() if item["builtin"]} existing = {item["name"] for item in self.database.list_screener_strategies() if item["builtin"]}
for strategy in BUILTIN_STRATEGIES: for strategy in BUILTIN_STRATEGIES:
if strategy["name"] not in existing: if strategy["name"] not in existing:
self.database.save_screener_strategy(**strategy, builtin=True) self.database.save_screener_strategy(None, **strategy, builtin=True)
def detect_regime(self, trade_date: str) -> dict[str, Any]: def detect_regime(self, trade_date: str) -> dict[str, Any]:
series = latest_contiguous_history( series = latest_contiguous_history(
@@ -336,7 +336,7 @@ class ScreenerEngine:
return result return result
def screen( def screen(
self, trade_date: str, formula: dict[str, Any], regime: str, self, user_id: int, trade_date: str, formula: dict[str, Any], regime: str,
strategy_name: str, run_backtest: bool = True, strategy_name: str, run_backtest: bool = True,
realtime_snapshot: dict[str, Any] | None = None, realtime_snapshot: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -384,7 +384,7 @@ class ScreenerEngine:
"disclaimer": "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。", "disclaimer": "概率为历史条件估计,不代表未来收益;退潮或样本不足时允许无候选。",
} }
run_id = self.database.save_screener_run( run_id = self.database.save_screener_run(
actual_date, regime, strategy_name, formula, result user_id, actual_date, regime, strategy_name, formula, result
) )
result["meta"]["run_id"] = run_id result["meta"]["run_id"] = run_id
return result return result
+119 -29
View File
@@ -18,7 +18,6 @@ from typing import Any
from urllib.parse import parse_qs, unquote, urlparse from urllib.parse import parse_qs, unquote, urlparse
from database import ReviewDatabase from database import ReviewDatabase
from demo_data import build_demo_dragon_tiger, build_demo_stock_detail
from heaven_agent import HeavenAgentError, interpret_heaven from heaven_agent import HeavenAgentError, interpret_heaven
from heaven_engine import ( from heaven_engine import (
_market_line_scores, _market_line_scores,
@@ -1074,7 +1073,7 @@ class DashboardService:
llm_access = self.llm_access_status() llm_access = self.llm_access_status()
return { return {
"configured": self.configured, "configured": self.configured,
"mode": "tushare" if self.configured else "demo", "mode": "tushare" if self.configured else "unavailable",
"llm_configured": self.llm_configured, "llm_configured": self.llm_configured,
"llm_model": self.llm_primary_model if self.llm_configured else "", "llm_model": self.llm_primary_model if self.llm_configured else "",
"llm_fallback_configured": self.llm_fallback_configured, "llm_fallback_configured": self.llm_fallback_configured,
@@ -1097,7 +1096,7 @@ class DashboardService:
"trade_date": normalized_date, "trade_date": normalized_date,
"regime": regime, "regime": regime,
"regimes": [{"id": key, "label": value} for key, value in REGIMES.items()], "regimes": [{"id": key, "label": value} for key, value in REGIMES.items()],
"strategies": self.database.list_screener_strategies(), "strategies": self.database.list_screener_strategies(self.current_user_id),
"factor_fields": [{"id": key, "label": value} for key, value in FACTOR_FIELDS.items()], "factor_fields": [{"id": key, "label": value} for key, value in FACTOR_FIELDS.items()],
"factor_data": { "factor_data": {
"date_count": len(factor_dates), "date_count": len(factor_dates),
@@ -1111,7 +1110,7 @@ class DashboardService:
"fallback_configured": self.llm_fallback_configured, "fallback_configured": self.llm_fallback_configured,
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "", "fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
}, },
"latest_result": self.database.latest_screener_run(normalized_date), "latest_result": self.database.latest_screener_run(self.current_user_id, normalized_date),
} }
def sync_screener_data(self, trade_date: str, lookback: int = 45) -> dict[str, Any]: def sync_screener_data(self, trade_date: str, lookback: int = 45) -> dict[str, Any]:
@@ -1191,14 +1190,19 @@ class DashboardService:
if not isinstance(regimes, list) or not regimes or any(item not in REGIMES for item in regimes): if not isinstance(regimes, list) or not regimes or any(item not in REGIMES for item in regimes):
raise ValueError("策略适用阶段不正确。") raise ValueError("策略适用阶段不正确。")
formula = self.screener.validate_formula(payload.get("formula") or {}) formula = self.screener.validate_formula(payload.get("formula") or {})
strategy_id = self.database.save_screener_strategy(name, description, regimes, formula) strategy_id = self.database.save_screener_strategy(
return {"id": strategy_id, "strategies": self.database.list_screener_strategies()} self.current_user_id, name, description, regimes, formula
)
return {
"id": strategy_id,
"strategies": self.database.list_screener_strategies(self.current_user_id),
}
def delete_screener_strategy(self, strategy_id: int) -> dict[str, Any]: def delete_screener_strategy(self, strategy_id: int) -> dict[str, Any]:
deleted = self.database.delete_screener_strategy(strategy_id) deleted = self.database.delete_screener_strategy(self.current_user_id, strategy_id)
return { return {
"deleted": deleted, "deleted": deleted,
"strategies": self.database.list_screener_strategies(), "strategies": self.database.list_screener_strategies(self.current_user_id),
} }
def mentor_setup(self, trade_date: str) -> dict[str, Any]: def mentor_setup(self, trade_date: str) -> dict[str, Any]:
@@ -1275,6 +1279,14 @@ class DashboardService:
"success", "success",
int(result.get("latency_ms") or 0), int(result.get("latency_ms") or 0),
) )
self.database.save_mentor_exchange(
self.current_user_id,
mentor_id,
trade_date,
question,
str(result.get("answer") or ""),
context["data_trade_date"],
)
return { return {
**result, **result,
"mentor": skill.public(), "mentor": skill.public(),
@@ -1284,6 +1296,21 @@ class DashboardService:
"notice": "主模型调用失败,已自动切换辅助模型。" if compiler == "fallback" else "", "notice": "主模型调用失败,已自动切换辅助模型。" if compiler == "fallback" else "",
} }
def mentor_messages(self, mentor_id: str, trade_date: str) -> list[dict[str, Any]]:
mentor_id = validate_text(mentor_id, "问师角色", 100, required=True)
trade_date = normalize_date(trade_date)
self.mentor_skills.get_skill(mentor_id)
return self.database.list_mentor_messages(
self.current_user_id, mentor_id, trade_date
)
def clear_mentor_messages(self, mentor_id: str, trade_date: str) -> int:
mentor_id = validate_text(mentor_id, "问师角色", 100, required=True)
trade_date = normalize_date(trade_date)
return self.database.delete_mentor_messages(
self.current_user_id, mentor_id, trade_date
)
@staticmethod @staticmethod
def _heaven_manual_schema(market_mode: str) -> dict[str, dict[str, Any]]: def _heaven_manual_schema(market_mode: str) -> dict[str, dict[str, Any]]:
intraday = market_mode == "intraday" intraday = market_mode == "intraday"
@@ -2585,7 +2612,7 @@ class DashboardService:
except TushareError as exc: except TushareError as exc:
raise ValueError(f"实时选股行情不可用,已停止筛选:{exc}") from exc raise ValueError(f"实时选股行情不可用,已停止筛选:{exc}") from exc
return self.screener.screen( return self.screener.screen(
trade_date, formula, regime, strategy_name, self.current_user_id, trade_date, formula, regime, strategy_name,
bool(payload.get("run_backtest", True)), bool(payload.get("run_backtest", True)),
realtime_snapshot, realtime_snapshot,
) )
@@ -2636,17 +2663,29 @@ class DashboardService:
self.database.save_data_snapshot(cache_kind, normalized_date, "tushare", payload) self.database.save_data_snapshot(cache_kind, normalized_date, "tushare", payload)
return payload return payload
demo = self._apply_seat_aliases(build_demo_dragon_tiger(normalized_date)) return {
demo["meta"] = { "meta": {
**demo.get("meta", {}),
"requested_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}", "requested_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
"source": "demo", "trade_date": f"{normalized_date[:4]}-{normalized_date[4:6]}-{normalized_date[6:8]}",
"status": "demo", "source": "unavailable",
"status": "unavailable",
"schema_version": 3, "schema_version": 3,
"cached": False, "cached": False,
"notice": "尚未配置 Tushare Token,当前展示演示数据。", "notice": "公共行情尚未配置,暂无龙虎榜数据。",
},
"summary": {
"trader_count": 0,
"identity_count": 0,
"operation_count": 0,
"active_stock_count": 0,
"seat_net_buy_million": 0,
"unclassified_count": 0,
"directory_count": 0,
},
"traders": [],
"unclassified_seats": [],
"rows": [],
} }
return demo
def _search_market_directory(self) -> list[dict[str, Any]]: def _search_market_directory(self) -> list[dict[str, Any]]:
cached = self.database.get_data_snapshot("search_directory", "ths") or {} cached = self.database.get_data_snapshot("search_directory", "ths") or {}
@@ -2935,12 +2974,12 @@ class DashboardService:
cache_key = f"{code}:{normalized_date}" cache_key = f"{code}:{normalized_date}"
if not force: if not force:
cached = self.database.get_data_snapshot("stock_detail", cache_key) cached = self.database.get_data_snapshot("stock_detail", cache_key)
if cached: if cached and str((cached.get("meta") or {}).get("source") or "") != "demo":
cached["meta"] = {**cached.get("meta", {}), "cached": True} cached["meta"] = {**cached.get("meta", {}), "cached": True}
return self._enrich_stock_detail(cached) return self._enrich_stock_detail(cached)
name, sector = self._stock_identity(code, normalized_date) name, sector = self._stock_identity(code, normalized_date)
source = "tushare" if self.configured else "demo" source = "tushare"
if self.configured: if self.configured:
try: try:
payload = TushareClient(self.token).stock_detail( payload = TushareClient(self.token).stock_detail(
@@ -2949,16 +2988,31 @@ class DashboardService:
if not payload.get("prices"): if not payload.get("prices"):
raise TushareError("No price history returned") raise TushareError("No price history returned")
except TushareError as exc: except TushareError as exc:
source = "demo" payload = self.database.get_latest_data_snapshot(
payload = build_demo_stock_detail( "stock_detail", f"{code}:", cache_key, exclude_source="demo"
code,
normalized_date,
name,
sector,
f"个股行情接口暂不可用,已回退演示数据。原因:{exc}",
) )
if not payload:
raise ValueError(f"暂无 {code} 的真实行情数据:{exc}") from exc
payload = copy.deepcopy(payload)
payload["meta"] = {
**payload.get("meta", {}),
"cached": True,
"notice": "最新行情暂不可用,已沿用最近真实收盘数据。",
}
return self._enrich_stock_detail(payload)
else: else:
payload = build_demo_stock_detail(code, normalized_date, name, sector) payload = self.database.get_latest_data_snapshot(
"stock_detail", f"{code}:", cache_key, exclude_source="demo"
)
if not payload:
raise ValueError(f"暂无 {code} 的真实行情数据,请等待后台完成首次同步。")
payload = copy.deepcopy(payload)
payload["meta"] = {
**payload.get("meta", {}),
"cached": True,
"notice": "公共行情尚未配置,已沿用最近真实收盘数据。",
}
return self._enrich_stock_detail(payload)
payload["meta"]["source"] = source payload["meta"]["source"] = source
payload["meta"]["cached"] = False payload["meta"]["cached"] = False
self.database.save_data_snapshot("stock_detail", cache_key, source, payload) self.database.save_data_snapshot("stock_detail", cache_key, source, payload)
@@ -3043,7 +3097,7 @@ class DashboardService:
return { return {
"meta": { "meta": {
"trade_date": resolved_date, "trade_date": resolved_date,
"source": detail_meta.get("source") or "demo", "source": detail_meta.get("source") or "unavailable",
"notice": detail_meta.get("notice") or "", "notice": detail_meta.get("notice") or "",
"intraday_status": intraday_status, "intraday_status": intraday_status,
"intraday_notice": intraday_notice, "intraday_notice": intraday_notice,
@@ -3317,7 +3371,10 @@ class RequestHandler(BaseHTTPRequestHandler):
return return
if parsed.path.startswith("/api/admin/") and not self.require_admin(): if parsed.path.startswith("/api/admin/") and not self.require_admin():
return return
if parsed.path in {"/api/screener/setup", "/api/mentors/setup", "/api/heaven/setup"} and not self.require_member(): if parsed.path in {
"/api/screener/setup", "/api/mentors/setup", "/api/mentors/messages",
"/api/heaven/setup",
} and not self.require_member():
return return
if parsed.path == "/api/admin/settings": if parsed.path == "/api/admin/settings":
self.send_json( self.send_json(
@@ -3462,6 +3519,20 @@ class RequestHandler(BaseHTTPRequestHandler):
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)
return return
if parsed.path == "/api/mentors/messages":
query = parse_qs(parsed.query)
try:
self.send_json(
{
"items": SERVICE.mentor_messages(
query.get("mentor_id", [""])[0],
query.get("trade_date", [date.today().isoformat()])[0],
)
}
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/heaven/setup": if parsed.path == "/api/heaven/setup":
query = parse_qs(parsed.query) query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0] trade_date = query.get("trade_date", [date.today().isoformat()])[0]
@@ -3537,12 +3608,18 @@ class RequestHandler(BaseHTTPRequestHandler):
self.save_note() self.save_note()
return return
if parsed.path == "/api/reasons": if parsed.path == "/api/reasons":
if not self.require_admin():
return
self.save_reason() self.save_reason()
return return
if parsed.path == "/api/seat-aliases": if parsed.path == "/api/seat-aliases":
if not self.require_admin():
return
self.save_seat_alias() self.save_seat_alias()
return return
if parsed.path == "/api/heaven/sector-phases": if parsed.path == "/api/heaven/sector-phases":
if not self.require_admin():
return
self.save_sector_phase_override() self.save_sector_phase_override()
return return
if parsed.path == "/api/backfill": if parsed.path == "/api/backfill":
@@ -3582,6 +3659,19 @@ class RequestHandler(BaseHTTPRequestHandler):
deleted = SERVICE.database.delete_user_birth_profile(SERVICE.current_user_id) deleted = SERVICE.database.delete_user_birth_profile(SERVICE.current_user_id)
self.send_json({"ok": True, "deleted": deleted}) self.send_json({"ok": True, "deleted": deleted})
return return
if parsed.path == "/api/mentors/messages":
if not self.require_member():
return
query = parse_qs(parsed.query)
try:
deleted = SERVICE.clear_mentor_messages(
query.get("mentor_id", [""])[0],
query.get("trade_date", [date.today().isoformat()])[0],
)
self.send_json({"ok": True, "deleted": deleted})
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
strategy_match = re.fullmatch(r"/api/screener/strategies/(\d+)", parsed.path) strategy_match = re.fullmatch(r"/api/screener/strategies/(\d+)", parsed.path)
if strategy_match: if strategy_match:
if not self.require_member(): if not self.require_member():
@@ -3608,7 +3698,7 @@ class RequestHandler(BaseHTTPRequestHandler):
return return
sector_phase_match = re.fullmatch(r"/api/heaven/sector-phases/(.+)", parsed.path) sector_phase_match = re.fullmatch(r"/api/heaven/sector-phases/(.+)", parsed.path)
if sector_phase_match: if sector_phase_match:
if not self.require_member(): if not self.require_admin():
return return
name = unquote(sector_phase_match.group(1)).strip() name = unquote(sector_phase_match.group(1)).strip()
deleted = SERVICE.database.delete_sector_phase_override(name) deleted = SERVICE.database.delete_sector_phase_override(name)
+33 -25
View File
@@ -286,6 +286,8 @@ async function applyAuthenticatedSession(session) {
updateAccountIdentityBadges(session.user?.membership || {}); updateAccountIdentityBadges(session.user?.membership || {});
document.querySelector("#settingsButton").hidden = !isAdmin; document.querySelector("#settingsButton").hidden = !isAdmin;
document.querySelector("#syncButton").hidden = !isAdmin; document.querySelector("#syncButton").hidden = !isAdmin;
document.querySelector("#reasonForm").hidden = !isAdmin;
document.querySelector("#sectorPhaseManager").hidden = !isAdmin;
const factorSyncButton = document.querySelector("#factorSyncButton"); const factorSyncButton = document.querySelector("#factorSyncButton");
if (factorSyncButton) factorSyncButton.hidden = false; if (factorSyncButton) factorSyncButton.hidden = false;
document.querySelector("#authGate").hidden = true; document.querySelector("#authGate").hidden = true;
@@ -1309,8 +1311,9 @@ function renderDragonTraderDetail(trader) {
function renderUnclassifiedSeats() { function renderUnclassifiedSeats() {
const seats = state.dragonTiger?.unclassified_seats || []; const seats = state.dragonTiger?.unclassified_seats || [];
document.querySelector("#dragonUnclassifiedSection").hidden = seats.length === 0; const canManage = state.user?.role === "admin";
document.querySelector("#dragonUnclassifiedFilter").hidden = seats.length === 0; document.querySelector("#dragonUnclassifiedSection").hidden = !canManage || seats.length === 0;
document.querySelector("#dragonUnclassifiedFilter").hidden = !canManage || seats.length === 0;
if (!seats.length && state.dragonFilter === "unclassified") { if (!seats.length && state.dragonFilter === "unclassified") {
state.dragonFilter = "all"; state.dragonFilter = "all";
document.querySelectorAll("[data-dragon-filter]").forEach((button) => { document.querySelectorAll("[data-dragon-filter]").forEach((button) => {
@@ -1826,7 +1829,7 @@ async function loadMentorSetup(force = false) {
state.mentorSetup = payload; state.mentorSetup = payload;
const selectedExists = payload.mentors.some((item) => item.id === state.selectedMentorId); const selectedExists = payload.mentors.some((item) => item.id === state.selectedMentorId);
state.selectedMentorId = selectedExists ? state.selectedMentorId : payload.mentors[0]?.id || ""; state.selectedMentorId = selectedExists ? state.selectedMentorId : payload.mentors[0]?.id || "";
state.mentorMessages = loadStoredMentorMessages(); state.mentorMessages = await loadMentorMessages();
renderMentorWorkspace(); renderMentorWorkspace();
} catch (error) { } catch (error) {
showMentorNotice(error.message || "问师模块加载失败"); showMentorNotice(error.message || "问师模块加载失败");
@@ -1854,13 +1857,14 @@ function renderMentorWorkspace() {
renderMentorMessages(); renderMentorMessages();
} }
function selectMentor(mentorId) { async function selectMentor(mentorId) {
if (mentorId === state.selectedMentorId) return; if (mentorId === state.selectedMentorId) return;
saveStoredMentorMessages();
state.selectedMentorId = mentorId; state.selectedMentorId = mentorId;
state.mentorMessages = loadStoredMentorMessages(); state.mentorMessages = [];
hideMentorNotice(); hideMentorNotice();
renderMentorWorkspace(); renderMentorWorkspace();
state.mentorMessages = await loadMentorMessages();
renderMentorMessages();
} }
function renderMentorMessages() { function renderMentorMessages() {
@@ -1925,7 +1929,6 @@ async function sendMentorQuestion(event) {
content: payload.answer, content: payload.answer,
meta: `${displayCompactDate(payload.data_trade_date)} · ${modelRole} ${payload.model} · ${number(payload.latency_ms)}ms`, meta: `${displayCompactDate(payload.data_trade_date)} · ${modelRole} ${payload.model} · ${number(payload.latency_ms)}ms`,
}); });
saveStoredMentorMessages();
if (payload.notice) showMentorNotice(payload.notice); if (payload.notice) showMentorNotice(payload.notice);
setStatus("问师回答完成"); setStatus("问师回答完成");
} catch (error) { } catch (error) {
@@ -1945,35 +1948,39 @@ function useMentorQuickPrompt(prompt) {
input.focus(); input.focus();
} }
function clearMentorConversation() { async function clearMentorConversation() {
if (!state.mentorMessages.length || !window.confirm("确定清空当前老师的对话记录吗?")) return; if (!state.mentorMessages.length || !window.confirm("确定清空当前老师的对话记录吗?")) return;
try {
const query = new URLSearchParams({
mentor_id: state.selectedMentorId,
trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
});
await apiRequest(`/api/mentors/messages?${query}`, "DELETE");
state.mentorMessages = []; state.mentorMessages = [];
try { localStorage.removeItem(mentorStorageKey()); } catch {}
hideMentorNotice(); hideMentorNotice();
renderMentorMessages(); renderMentorMessages();
} catch (error) {
showToast(error.message || "对话记录清空失败");
}
} }
function mentorStorageKey() { async function loadMentorMessages() {
const dateKey = (state.mentorSetup?.trade_date || elements.tradeDate.value).replaceAll("-", "");
return `xiaobai-mentor-chat:${state.selectedMentorId}:${dateKey}`;
}
function loadStoredMentorMessages() {
if (!state.selectedMentorId) return []; if (!state.selectedMentorId) return [];
try { try {
const messages = JSON.parse(localStorage.getItem(mentorStorageKey()) || "[]"); const query = new URLSearchParams({
if (!Array.isArray(messages)) return []; mentor_id: state.selectedMentorId,
return messages.filter((item) => ["user", "assistant"].includes(item?.role) && typeof item.content === "string").slice(-20); trade_date: state.mentorSetup?.trade_date || elements.tradeDate.value,
} catch { });
const payload = await apiRequest(`/api/mentors/messages?${query}`);
return (payload.items || []).filter(
(item) => ["user", "assistant"].includes(item?.role) && typeof item.content === "string",
).slice(-100);
} catch (error) {
showMentorNotice(error.message || "对话记录加载失败");
return []; return [];
} }
} }
function saveStoredMentorMessages() {
if (!state.selectedMentorId) return;
try { localStorage.setItem(mentorStorageKey(), JSON.stringify(state.mentorMessages.slice(-20))); } catch {}
}
function showMentorNotice(message) { function showMentorNotice(message) {
const notice = document.querySelector("#mentorNotice"); const notice = document.querySelector("#mentorNotice");
notice.textContent = message; notice.textContent = message;
@@ -2744,11 +2751,12 @@ function drawQiUseConnections(animate = false) {
function renderSectorPhaseOverrides(items) { function renderSectorPhaseOverrides(items) {
const container = document.querySelector("#sectorPhaseOverrides"); const container = document.querySelector("#sectorPhaseOverrides");
const canManage = state.user?.role === "admin";
container.innerHTML = items.length ? items.map((item) => ` container.innerHTML = items.length ? items.map((item) => `
<div class="sector-phase-override-row"> <div class="sector-phase-override-row">
<span class="phase-symbol phase-${phaseClass(item.element)}">${escapeHtml(item.element)}</span> <span class="phase-symbol phase-${phaseClass(item.element)}">${escapeHtml(item.element)}</span>
<strong>${escapeHtml(item.name)}</strong> <strong>${escapeHtml(item.name)}</strong>
<button class="icon-button" type="button" data-sector-phase-delete="${escapeHtml(item.name)}" title="删除手动归类" aria-label="删除 ${escapeHtml(item.name)} 的手动归类">×</button> ${canManage ? `<button class="icon-button" type="button" data-sector-phase-delete="${escapeHtml(item.name)}" title="删除手动归类" aria-label="删除 ${escapeHtml(item.name)} 的手动归类">×</button>` : ""}
</div> </div>
`).join("") : '<p class="sector-phase-empty">暂无手动归类</p>'; `).join("") : '<p class="sector-phase-empty">暂无手动归类</p>';
container.querySelectorAll("[data-sector-phase-delete]").forEach((button) => { container.querySelectorAll("[data-sector-phase-delete]").forEach((button) => {
+2 -2
View File
@@ -544,7 +544,7 @@
<div class="workspace-heading"><h3>历法细目</h3><span>中运、司天、在泉与节气定位</span></div> <div class="workspace-heading"><h3>历法细目</h3><span>中运、司天、在泉与节气定位</span></div>
<div id="fortuneMetrics" class="fortune-metrics"></div> <div id="fortuneMetrics" class="fortune-metrics"></div>
</section> </section>
<details class="sector-phase-manager"> <details id="sectorPhaseManager" class="sector-phase-manager">
<summary class="sector-phase-manager-heading"><strong>管理手动归类</strong><span>精确名称优先</span><i data-lucide="chevron-down" aria-hidden="true"></i></summary> <summary class="sector-phase-manager-heading"><strong>管理手动归类</strong><span>精确名称优先</span><i data-lucide="chevron-down" aria-hidden="true"></i></summary>
<div class="sector-phase-manager-body"> <div class="sector-phase-manager-body">
<form id="sectorPhaseForm" class="sector-phase-form"> <form id="sectorPhaseForm" class="sector-phase-form">
@@ -1036,7 +1036,7 @@
<h3>事件逻辑</h3> <h3>事件逻辑</h3>
<p id="detailReason">--</p> <p id="detailReason">--</p>
<span id="detailSector" class="sector-chip">--</span> <span id="detailSector" class="sector-chip">--</span>
<form id="reasonForm" class="inline-edit-form"> <form id="reasonForm" class="inline-edit-form" hidden>
<input id="reasonInput" type="text" maxlength="200" placeholder="修订事件逻辑"> <input id="reasonInput" type="text" maxlength="200" placeholder="修订事件逻辑">
<button class="button" type="submit">保存修订</button> <button class="button" type="submit">保存修订</button>
</form> </form>
+193
View File
@@ -0,0 +1,193 @@
from __future__ import annotations
import sqlite3
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from database import ReviewDatabase
from server import DashboardService, RequestHandler
FORMULA = {"all": [{"field": "change", "operator": ">", "value": 0}]}
class AccountDataBoundaryTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = tempfile.TemporaryDirectory()
self.database = ReviewDatabase(Path(self.temp.name) / "review.db")
self.first = self.database.create_user("first_user", "salt", "hash")
self.second = self.database.create_user("second_user", "salt", "hash")
def tearDown(self) -> None:
self.temp.cleanup()
def test_custom_strategies_are_private_and_builtins_are_shared(self):
builtin_id = self.database.save_screener_strategy(
None, "共享策略", "", ["repair"], FORMULA, builtin=True
)
private_id = self.database.save_screener_strategy(
self.first["id"], "甲的策略", "", ["repair"], FORMULA
)
first_names = {item["name"] for item in self.database.list_screener_strategies(self.first["id"])}
second_names = {item["name"] for item in self.database.list_screener_strategies(self.second["id"])}
self.assertEqual(first_names, {"共享策略", "甲的策略"})
self.assertEqual(second_names, {"共享策略"})
with self.assertRaises(ValueError):
self.database.delete_screener_strategy(self.second["id"], private_id)
with self.assertRaises(ValueError):
self.database.delete_screener_strategy(self.first["id"], builtin_id)
self.assertTrue(self.database.delete_screener_strategy(self.first["id"], private_id))
def test_screener_runs_are_private(self):
self.database.save_screener_run(
self.first["id"], "20260721", "repair", "甲的策略", FORMULA,
{"candidates": [{"code": "002141"}], "meta": {}},
)
self.assertEqual(
self.database.latest_screener_run(self.first["id"], "20260722")["candidates"][0]["code"],
"002141",
)
self.assertIsNone(self.database.latest_screener_run(self.second["id"], "20260722"))
def test_mentor_messages_are_scoped_by_user_mentor_and_date(self):
self.database.save_mentor_exchange(
self.first["id"], "mentor-a", "20260721", "怎么看?", "先看承接。", "20260721"
)
self.assertEqual(
[item["role"] for item in self.database.list_mentor_messages(
self.first["id"], "mentor-a", "20260721"
)],
["user", "assistant"],
)
self.assertEqual(
self.database.list_mentor_messages(self.second["id"], "mentor-a", "20260721"), []
)
self.assertEqual(
self.database.list_mentor_messages(self.first["id"], "mentor-b", "20260721"), []
)
self.assertEqual(
self.database.list_mentor_messages(self.first["id"], "mentor-a", "20260722"), []
)
self.assertEqual(
self.database.delete_mentor_messages(self.second["id"], "mentor-a", "20260721"), 0
)
self.assertEqual(
self.database.delete_mentor_messages(self.first["id"], "mentor-a", "20260721"), 2
)
def test_latest_data_snapshot_skips_demo_and_future_records(self):
self.database.save_data_snapshot(
"stock_detail", "002141:20260718", "tushare", {"marker": "real"}
)
self.database.save_data_snapshot(
"stock_detail", "002141:20260719", "demo", {"marker": "demo"}
)
self.database.save_data_snapshot(
"stock_detail", "002141:20260723", "tushare", {"marker": "future"}
)
payload = self.database.get_latest_data_snapshot(
"stock_detail", "002141:", "002141:20260722", exclude_source="demo"
)
self.assertEqual(payload["marker"], "real")
def test_stock_detail_never_falls_back_to_demo_data(self):
self.database.save_data_snapshot(
"stock_detail",
"002141:20260721",
"demo",
{"meta": {"source": "demo"}, "stock": {"code": "002141"}, "prices": [{}]},
)
service = DashboardService.__new__(DashboardService)
service.database = self.database
service._system_credentials = {"tushare_token": ""}
service._request_context = SimpleNamespace(user_id=self.first["id"])
with self.assertRaisesRegex(ValueError, "暂无 002141 的真实行情数据"):
service.get_stock_detail("002141", "2026-07-22")
class LegacyStrategyMigrationTests(unittest.TestCase):
def test_legacy_custom_strategy_and_run_move_to_first_admin(self):
with tempfile.TemporaryDirectory() as directory:
database_path = Path(directory) / "legacy.db"
connection = sqlite3.connect(database_path)
try:
connection.executescript(
"""
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_salt TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO users VALUES (1, 'legacy_admin', 'salt', 'hash', '2026-01-01', '2026-01-01');
CREATE TABLE screener_strategies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
regimes TEXT NOT NULL,
formula TEXT NOT NULL,
builtin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO screener_strategies VALUES
(1, '旧策略', '', '["repair"]', '{"all":[]}', 0, '2026-01-01', '2026-01-01'),
(2, '旧内置', '', '["repair"]', '{"all":[]}', 1, '2026-01-01', '2026-01-01');
CREATE TABLE screener_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
regime TEXT NOT NULL,
strategy_name TEXT NOT NULL,
formula TEXT NOT NULL,
result TEXT NOT NULL,
created_at TEXT NOT NULL
);
INSERT INTO screener_runs VALUES
(1, '20260721', 'repair', '旧策略', '{}', '{"meta":{}}', '2026-07-21');
"""
)
connection.commit()
finally:
connection.close()
migrated = ReviewDatabase(database_path)
strategies = migrated.list_screener_strategies(1)
owners = {item["name"]: item["user_id"] for item in strategies}
self.assertEqual(owners["旧策略"], 1)
self.assertIsNone(owners["旧内置"])
self.assertIsNotNone(migrated.latest_screener_run(1, "20260722"))
class PublicKnowledgePermissionTests(unittest.TestCase):
@staticmethod
def handler(path: str, method_name: str):
handler = RequestHandler.__new__(RequestHandler)
handler.path = path
handler.require_auth = lambda: True
handler.require_csrf = lambda: True
handler.require_admin = lambda: False
handler.require_member = lambda: True
setattr(handler, method_name, lambda: (_ for _ in ()).throw(AssertionError("mutation ran")))
return handler
def test_regular_user_cannot_change_reason_or_seat_alias(self):
RequestHandler.do_POST(self.handler("/api/reasons", "save_reason"))
RequestHandler.do_POST(self.handler("/api/seat-aliases", "save_seat_alias"))
def test_regular_user_cannot_change_sector_phase(self):
RequestHandler.do_POST(
self.handler("/api/heaven/sector-phases", "save_sector_phase_override")
)
RequestHandler.do_DELETE(
self.handler("/api/heaven/sector-phases/%E6%B2%B9%E6%B0%94", "send_json")
)
if __name__ == "__main__":
unittest.main()
+5
View File
@@ -44,6 +44,11 @@ class FrontendContractTests(unittest.TestCase):
self.assertEqual(views, navigation) self.assertEqual(views, navigation)
self.assertEqual(len(views), 13) self.assertEqual(len(views), 13)
def test_public_knowledge_editors_are_hidden_for_non_admins(self):
self.assertIn('document.querySelector("#reasonForm").hidden = !isAdmin;', self.script)
self.assertIn('document.querySelector("#sectorPhaseManager").hidden = !isAdmin;', self.script)
self.assertIn('const canManage = state.user?.role === "admin";', self.script)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()