Initial commit: snapAna 截图智能整理工具

包含 FastAPI 后端、React 前端、队列/OCR/标签/待办等完整功能。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wjl
2026-05-27 15:45:50 +08:00
commit 5c028d7952
76 changed files with 10467 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
"""数据库引擎、会话与初始化。
使用 SQLAlchemy 2.0 + SQLite。FTS5 虚拟表通过原生 SQL 创建,并配套触发器
让 OCR/AI 字段更新时自动同步到全文索引。
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import Iterator
from sqlalchemy import create_engine, event, text
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import settings
class Base(DeclarativeBase):
"""全局声明性 Base。"""
engine = create_engine(
settings.db_url,
echo=False,
future=True,
connect_args={"check_same_thread": False},
)
@event.listens_for(engine, "connect")
def _sqlite_pragmas(dbapi_connection, _connection_record):
"""启用外键、WAL、忙等待等 SQLite 优化项。"""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
def get_session() -> Iterator[Session]:
"""FastAPI 依赖注入:每个请求一个会话。"""
with SessionLocal() as session:
yield session
@contextmanager
def session_scope() -> Iterator[Session]:
"""常规上下文管理:自动 commit/rollback。"""
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
# FTS5 虚拟表与触发器 SQL(独立维护,便于以后调整字段)
_FTS_SCHEMA_SQL = [
"""
CREATE VIRTUAL TABLE IF NOT EXISTS screenshots_fts
USING fts5(
ocr_text,
ai_title,
ai_summary,
ai_suggestion,
content='screenshot_meta',
content_rowid='screenshot_id',
tokenize='unicode61'
);
""",
"""
CREATE TRIGGER IF NOT EXISTS screenshot_meta_ai
AFTER INSERT ON screenshot_meta BEGIN
INSERT INTO screenshots_fts(rowid, ocr_text, ai_title, ai_summary, ai_suggestion)
VALUES (new.screenshot_id,
coalesce(new.ocr_text, ''),
coalesce(new.ai_title, ''),
coalesce(new.ai_summary, ''),
coalesce(new.ai_suggestion, ''));
END;
""",
"""
CREATE TRIGGER IF NOT EXISTS screenshot_meta_ad
AFTER DELETE ON screenshot_meta BEGIN
INSERT INTO screenshots_fts(screenshots_fts, rowid, ocr_text, ai_title, ai_summary, ai_suggestion)
VALUES('delete', old.screenshot_id,
coalesce(old.ocr_text, ''),
coalesce(old.ai_title, ''),
coalesce(old.ai_summary, ''),
coalesce(old.ai_suggestion, ''));
END;
""",
"""
CREATE TRIGGER IF NOT EXISTS screenshot_meta_au
AFTER UPDATE ON screenshot_meta BEGIN
INSERT INTO screenshots_fts(screenshots_fts, rowid, ocr_text, ai_title, ai_summary, ai_suggestion)
VALUES('delete', old.screenshot_id,
coalesce(old.ocr_text, ''),
coalesce(old.ai_title, ''),
coalesce(old.ai_summary, ''),
coalesce(old.ai_suggestion, ''));
INSERT INTO screenshots_fts(rowid, ocr_text, ai_title, ai_summary, ai_suggestion)
VALUES (new.screenshot_id,
coalesce(new.ocr_text, ''),
coalesce(new.ai_title, ''),
coalesce(new.ai_summary, ''),
coalesce(new.ai_suggestion, ''));
END;
""",
]
def init_db() -> None:
"""启动时建表并装配 FTS5、灌入默认分类。"""
from app.models import register_all # noqa: F401
register_all()
Base.metadata.create_all(engine)
with engine.begin() as conn:
for stmt in _FTS_SCHEMA_SQL:
conn.execute(text(stmt))
_migrate_legacy_schema(conn)
# 启动期 seed 默认分类(即使首次启动也能在「设置」/筛选页看到分类)
from app.services.analyze import ensure_default_categories
ensure_default_categories()
def _migrate_legacy_schema(conn) -> None:
"""轻量迁移:旧版本的 screenshots.category_id 没有外键。
SQLite 不支持 ALTER TABLE 加外键,但删除分类时 ON DELETE SET NULL 失效
会导致悬空引用。检测到旧表时,主动用一次性 SQL 清理掉无效引用并打日志,
建议用户用「分类管理」页重建索引。
"""
pragma_rows = conn.execute(
text("PRAGMA foreign_key_list(screenshots)")
).fetchall()
has_cat_fk = any(row[2] == "categories" for row in pragma_rows)
if not has_cat_fk:
# 清理悬空 category_id,避免列表统计出错
conn.execute(
text(
"UPDATE screenshots SET category_id = NULL "
"WHERE category_id IS NOT NULL "
"AND category_id NOT IN (SELECT id FROM categories)"
)
)