mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
合并 0.3.6 分支全部内容 (36 提交, 详细历史见 0.3.6 分支): ■ 数据与插件 - TdxQuant 数据源: 通达信量化接入 (实时/五档/日K/复权/分钟), tdxaidata 收进默认依赖 - 扩展表两级拉取配方: 概念/行业出厂即带完整可拉配置, 可作自定义接入参考示例 - 能力门禁日志带生效源; 标的池解析只走 instruments 维表 ■ 页面扩展化 - 11 个官方页面全量迁移为单文件扩展 (custom/*/extension.tsx), 删目录即卸载 - 运行时页面包系统: 打包产物上传即挂载, 免重建宿主 - 新装用户扩展页默认隐藏, 老用户升级菜单无感 ■ AI - Responses 协议接入; 会话历史与长任务跨设备同步 (服务端注册表) - 助手头部快捷入口 + 悬浮球拖拽 zoom 坐标系修复 + 系统设置开关 ■ 界面 - 设置「关于」子菜单: 版权/更新/打赏 + 开源许可弹窗与合规声明双保险 - 侧栏两态收起、数据/AI 助手快捷行 (同步呼吸光晕)、核心/扩展 ext 标记 - 界面缩放默认 90%; 菜单设置徽标 Swiss 化; 策略池「叠加」分组 - 情绪评分口径可配置; 财务画像 5 张子表手动拉取 ■ 文档与其他 - README 重构「核心+扩展」主线 + 架构图重画; 开放端点数核实为 65 - 版本号 bump 0.3.6; tdx 物化库测试夹具补 macOS dylib
180 lines
5.1 KiB
Python
180 lines
5.1 KiB
Python
"""Key / 凭据本地存储(§14)。
|
|
|
|
存储位置:`data/user_data/secrets.json`,权限 0600。
|
|
优先级:secrets.json > .env > 空(Free 模式)。
|
|
|
|
UI 改 Key 时只动这个文件,不动 .env。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from app.services.fs_utils import atomic_write_text
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# (mtime_ns, size) 签名缓存 — 与 preferences.load 同模式。
|
|
# 实时行情每轮的 webhook/邮件判定会多次读 secrets, 避免每次全文件读+JSON 解析。
|
|
_cache: dict | None = None
|
|
_cache_sig: tuple[int, int] | None = None
|
|
|
|
|
|
def _path() -> Path:
|
|
from app.config import settings
|
|
p = settings.data_dir / "user_data" / "secrets.json"
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
return p
|
|
|
|
|
|
def file_path() -> Path:
|
|
"""secrets.json 绝对路径 (设置页展示用)。
|
|
|
|
该文件是 AI 与数据源 Key 的统一配置源, 支持用户用编辑器直接修改;
|
|
load() 按 mtime 签名缓存失效, 外部保存后下一次请求即读取最新内容,
|
|
无需重启。
|
|
"""
|
|
return _path()
|
|
|
|
|
|
def load() -> dict:
|
|
"""读取 secrets.json (带 mtime 签名缓存)。返回深拷贝, 调用方可自由修改。"""
|
|
global _cache, _cache_sig
|
|
p = _path()
|
|
try:
|
|
sig = (p.stat().st_mtime_ns, p.stat().st_size)
|
|
except OSError:
|
|
return {}
|
|
if _cache is not None and sig == _cache_sig:
|
|
return copy.deepcopy(_cache)
|
|
try:
|
|
data = json.loads(p.read_text(encoding="utf-8"))
|
|
except FileNotFoundError:
|
|
return {}
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("secrets.json malformed: %s", e)
|
|
return {}
|
|
_cache = data
|
|
_cache_sig = sig
|
|
return copy.deepcopy(_cache)
|
|
|
|
|
|
def save(updates: dict) -> dict:
|
|
"""合并写入(不会清掉未提及的字段)。返回新内容。"""
|
|
current = load()
|
|
current.update({k: v for k, v in updates.items() if v is not None})
|
|
p = _path()
|
|
atomic_write_text(
|
|
p, json.dumps(current, indent=2, ensure_ascii=False), mode=0o600,
|
|
)
|
|
return current
|
|
|
|
|
|
def clear(*keys: str) -> dict:
|
|
"""清掉指定字段(留空清全部)。"""
|
|
p = _path()
|
|
if not p.exists():
|
|
return {}
|
|
if not keys:
|
|
p.unlink()
|
|
return {}
|
|
current = load()
|
|
for k in keys:
|
|
current.pop(k, None)
|
|
atomic_write_text(
|
|
p, json.dumps(current, indent=2, ensure_ascii=False), mode=0o600,
|
|
)
|
|
return current
|
|
|
|
|
|
def get_tickflow_key() -> str:
|
|
"""取当前 TickFlow Key:secrets.json 优先,否则 .env。"""
|
|
val = load().get("tickflow_api_key")
|
|
if val:
|
|
return val
|
|
from app.config import settings
|
|
return settings.tickflow_api_key or ""
|
|
|
|
|
|
def get_ai_key() -> str:
|
|
"""取当前 AI Key:secrets.json 优先,否则 .env。"""
|
|
val = load().get("ai_api_key")
|
|
if val:
|
|
return val
|
|
from app.config import settings
|
|
return settings.ai_api_key or ""
|
|
|
|
|
|
def get_ai_config(key: str, default: str = "") -> str:
|
|
"""取 AI 配置项:secrets.json 优先,否则 config。"""
|
|
val = load().get(key)
|
|
if val:
|
|
return val
|
|
from app.config import settings
|
|
return getattr(settings, key, default) or default
|
|
|
|
|
|
def get_ai_config_int(key: str, default: int) -> int:
|
|
"""取 AI 数值配置项 (如 ai_max_output_tokens): secrets.json 优先,否则 config。"""
|
|
val = load().get(key)
|
|
if val is not None:
|
|
try:
|
|
return int(val)
|
|
except (TypeError, ValueError):
|
|
logger.warning("ai config %s is not an int: %r", key, val)
|
|
from app.config import settings
|
|
return int(getattr(settings, key, default) or default)
|
|
|
|
|
|
def get_custom_webhook_secret() -> str:
|
|
"""Return the optional HMAC secret for the generic outbound webhook."""
|
|
return str(load().get("custom_webhook_secret") or "")
|
|
|
|
|
|
def set_custom_webhook_secret(secret: str) -> str:
|
|
"""Persist or clear the generic outbound webhook HMAC secret."""
|
|
value = (secret or "").strip()
|
|
if value:
|
|
save({"custom_webhook_secret": value})
|
|
else:
|
|
clear("custom_webhook_secret")
|
|
return value
|
|
|
|
|
|
def get_email_smtp_password() -> str:
|
|
"""Return the SMTP password used by the email notification channel."""
|
|
return str(load().get("email_smtp_password") or "")
|
|
|
|
|
|
def set_email_smtp_password(password: str) -> str:
|
|
"""Persist or clear the SMTP password used by email notifications."""
|
|
value = password or ""
|
|
if value:
|
|
save({"email_smtp_password": value})
|
|
else:
|
|
clear("email_smtp_password")
|
|
return value
|
|
|
|
|
|
def get_env_backed_secret(field: str, env_name: str) -> str:
|
|
"""取环境变量后备的密钥(插件 API Key 等):secrets.json 优先,否则环境变量。
|
|
|
|
与 get_tickflow_key 同优先级语义:UI 写入 secrets.json 后即覆盖 .env。
|
|
"""
|
|
val = load().get(field)
|
|
if val:
|
|
return str(val).strip()
|
|
return os.environ.get(env_name, "").strip()
|
|
|
|
|
|
def mask(key: str, prefix: int = 4, suffix: int = 4) -> str:
|
|
"""脱敏显示。"""
|
|
if not key:
|
|
return ""
|
|
if len(key) <= prefix + suffix:
|
|
return "•" * len(key)
|
|
return f"{key[:prefix]}{'•' * 6}{key[-suffix:]}"
|