文件
workbuddy-portal/workbuddy_portal/db.py
T
wangchuanli 23799b4ea5 feat(oss): 补齐开源声明体系(MIT + 第三方声明 + 贡献/安全/行为准则)
* 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 增加「开源与许可」章节与许可标识
2026-09-14 16:15:26 +08:00

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