* LICENSE —— MIT
* THIRD-PARTY-NOTICES —— 依赖清单、再分发合规说明(含随仓库分发的
Apache ECharts 5.6.0 / Apache-2.0)与自查清单
* CONTRIBUTING.md —— 开发环境、五层验证、必须遵守的不变量、提交规范
* SECURITY.md —— 漏洞私有报告渠道、已有措施、已知非目标
* CODE_OF_CONDUCT.md —— 改编自 Contributor Covenant 2.1
* .github/ —— Bug 报告 / 功能建议表单 + PR 模板
* .editorconfig —— 与 .gitattributes 保持一致
* 全部 Python / Shell 源文件加 SPDX-License-Identifier: MIT 头
* README 增加「开源与许可」章节与许可标识
160 行
4.8 KiB
Python
160 行
4.8 KiB
Python
# -*- coding: utf-8 -*-
|
|
# SPDX-License-Identifier: MIT
|
|
# Copyright (c) 2026 Wang Chuanli
|
|
|
|
"""SQLite 访问层。
|
|
|
|
并发约定(重要):
|
|
* WAL 模式 —— 采集写入期间页面查询不会被 `database is locked` 挡住
|
|
* 单写者 —— SQLite 同一时刻只允许一个写进程,所以采集必须串行
|
|
(由 scheduler / CLI 共享的 collect.lock 保证)
|
|
* busy_timeout=8s —— 偶发并发时等待而不是立刻报错
|
|
* 每个线程独立连接(sqlite3 默认禁止跨线程复用连接)
|
|
"""
|
|
import os
|
|
import sqlite3
|
|
import threading
|
|
from datetime import datetime
|
|
|
|
from . import config
|
|
|
|
_local = threading.local()
|
|
_init_lock = threading.Lock()
|
|
_initialized = False
|
|
|
|
|
|
def now_str():
|
|
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
|
def connect(path=None):
|
|
"""新建一个连接(调用方负责关闭)。"""
|
|
config.ensure_dirs()
|
|
conn = sqlite3.connect(path or config.SQLITE_PATH, timeout=8.0,
|
|
isolation_level=None) # autocommit,事务用显式 BEGIN
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA synchronous=NORMAL")
|
|
conn.execute("PRAGMA busy_timeout=8000")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
return conn
|
|
|
|
|
|
def thread_conn():
|
|
"""按线程缓存的连接(采集线程、调度线程各自一份)。"""
|
|
c = getattr(_local, "conn", None)
|
|
if c is None:
|
|
c = _local.conn = connect()
|
|
return c
|
|
|
|
|
|
def close_thread_conn():
|
|
c = getattr(_local, "conn", None)
|
|
if c is not None:
|
|
try:
|
|
c.close()
|
|
except sqlite3.Error:
|
|
pass
|
|
_local.conn = None
|
|
|
|
|
|
# ---------------- 初始化 ----------------
|
|
def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=None):
|
|
"""建表 + 灌默认配置。可重复执行(幂等)。"""
|
|
global _initialized
|
|
own = conn is None
|
|
conn = conn or connect()
|
|
try:
|
|
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.sql"),
|
|
"r", encoding="utf-8") as f:
|
|
conn.executescript(f.read())
|
|
# 默认配置(不覆盖已有值)
|
|
ts = now_str()
|
|
for k, v in config.DEFAULTS.items():
|
|
conn.execute("INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)",
|
|
(k, v, ts))
|
|
if create_admin:
|
|
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
|
if n == 0:
|
|
from .security import hash_password
|
|
pwd = admin_password or "admin123"
|
|
conn.execute(
|
|
"INSERT INTO users(username,password_hash,display_name,is_admin,created_at)"
|
|
" VALUES(?,?,?,1,?)", (admin_user, hash_password(pwd), "管理员", ts))
|
|
_initialized = True
|
|
finally:
|
|
if own:
|
|
conn.close()
|
|
|
|
|
|
# ---------------- 配置读写 ----------------
|
|
def get_setting(conn, key, default=None):
|
|
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
|
if row is None or row["value"] is None:
|
|
return config.DEFAULTS.get(key, default)
|
|
return row["value"]
|
|
|
|
|
|
def get_settings(conn, keys=None):
|
|
rows = conn.execute("SELECT key,value FROM settings").fetchall()
|
|
got = {r["key"]: r["value"] for r in rows}
|
|
out = dict(config.DEFAULTS)
|
|
out.update(got)
|
|
if keys:
|
|
return {k: out.get(k) for k in keys}
|
|
return out
|
|
|
|
|
|
def set_setting(conn, key, value):
|
|
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) "
|
|
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
|
|
(key, "" if value is None else str(value), now_str()))
|
|
|
|
|
|
def set_settings(conn, pairs):
|
|
for k, v in pairs.items():
|
|
set_setting(conn, k, v)
|
|
|
|
|
|
def get_int(conn, key, default=0):
|
|
try:
|
|
return int(float(get_setting(conn, key, default)))
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def get_float(conn, key, default=0.0):
|
|
try:
|
|
return float(get_setting(conn, key, default))
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def get_bool(conn, key, default=False):
|
|
v = str(get_setting(conn, key, "1" if default else "0")).strip().lower()
|
|
return v in ("1", "true", "yes", "on", "是")
|
|
|
|
|
|
# ---------------- 审计 ----------------
|
|
def audit(conn, action, actor=None, detail=None, ip=None):
|
|
conn.execute("INSERT INTO audit_log(at,actor,action,detail,ip) VALUES(?,?,?,?,?)",
|
|
(now_str(), actor, action, detail, ip))
|
|
|
|
|
|
# ---------------- Flask 集成 ----------------
|
|
def get_db():
|
|
from flask import g
|
|
if "db" not in g:
|
|
g.db = connect()
|
|
return g.db
|
|
|
|
|
|
def close_db(exc=None):
|
|
from flask import g
|
|
db = g.pop("db", None)
|
|
if db is not None:
|
|
try:
|
|
db.close()
|
|
except sqlite3.Error:
|
|
pass
|