From 93709804d811505f788d95155c0ecda98723cd12 Mon Sep 17 00:00:00 2001 From: fancy Date: Thu, 15 Jan 2026 11:57:16 +0800 Subject: [PATCH] feat: migrate history storage to sqlite --- backend/app.py | 120 +++--- backend/models.py | 88 +++++ backend/services/history.py | 608 +++++++----------------------- pyproject.toml | 1 + scripts/migrate_json_to_sqlite.py | 86 +++++ uv.lock | 104 +++++ 6 files changed, 482 insertions(+), 525 deletions(-) create mode 100644 backend/models.py create mode 100644 scripts/migrate_json_to_sqlite.py diff --git a/backend/app.py b/backend/app.py index 79fa19a..60413d3 100644 --- a/backend/app.py +++ b/backend/app.py @@ -5,6 +5,8 @@ from flask_cors import CORS from backend.config import Config from backend.routes import register_routes +from backend.models import db +import os def setup_logging(): @@ -20,17 +22,16 @@ def setup_logging(): console_handler = logging.StreamHandler(sys.stdout) console_handler.setLevel(logging.DEBUG) console_format = logging.Formatter( - '\n%(asctime)s | %(levelname)-8s | %(name)s\n' - ' └─ %(message)s', - datefmt='%H:%M:%S' + "\n%(asctime)s | %(levelname)-8s | %(name)s\n └─ %(message)s", + datefmt="%H:%M:%S", ) console_handler.setFormatter(console_format) root_logger.addHandler(console_handler) # 设置各模块的日志级别 - logging.getLogger('backend').setLevel(logging.DEBUG) - logging.getLogger('werkzeug').setLevel(logging.INFO) - logging.getLogger('urllib3').setLevel(logging.WARNING) + logging.getLogger("backend").setLevel(logging.DEBUG) + logging.getLogger("werkzeug").setLevel(logging.INFO) + logging.getLogger("urllib3").setLevel(logging.WARNING) return root_logger @@ -41,27 +42,42 @@ def create_app(): logger.info("🚀 正在启动 红墨 AI图文生成器...") # 检查是否存在前端构建产物(Docker 环境) - frontend_dist = Path(__file__).parent.parent / 'frontend' / 'dist' + frontend_dist = Path(__file__).parent.parent / "frontend" / "dist" if frontend_dist.exists(): logger.info("📦 检测到前端构建产物,启用静态文件托管模式") - app = Flask( - __name__, - static_folder=str(frontend_dist), - static_url_path='' - ) + app = Flask(__name__, static_folder=str(frontend_dist), static_url_path="") else: logger.info("🔧 开发模式,前端请单独启动") app = Flask(__name__) app.config.from_object(Config) - CORS(app, resources={ - r"/api/*": { - "origins": Config.CORS_ORIGINS, - "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - "allow_headers": ["Content-Type"], - } - }) + # 数据库配置 + db_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "history", "redink.db" + ) + app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + + # 确保 history 目录存在 + os.makedirs(os.path.dirname(db_path), exist_ok=True) + + db.init_app(app) + + with app.app_context(): + db.create_all() + _ensure_history_indexes() + + CORS( + app, + resources={ + r"/api/*": { + "origins": Config.CORS_ORIGINS, + "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + "allow_headers": ["Content-Type"], + } + }, + ) # 注册所有 API 路由 register_routes(app) @@ -71,16 +87,18 @@ def create_app(): # 根据是否有前端构建产物决定根路由行为 if frontend_dist.exists(): - @app.route('/') + + @app.route("/") def serve_index(): - return send_from_directory(app.static_folder, 'index.html') + return send_from_directory(app.static_folder, "index.html") # 处理 Vue Router 的 HTML5 History 模式 @app.errorhandler(404) def fallback(e): - return send_from_directory(app.static_folder, 'index.html') + return send_from_directory(app.static_folder, "index.html") else: - @app.route('/') + + @app.route("/") def index(): return { "message": "红墨 AI图文生成器 API", @@ -89,13 +107,29 @@ def index(): "health": "/api/health", "outline": "POST /api/outline", "generate": "POST /api/generate", - "images": "GET /api/images/" - } + "images": "GET /api/images/", + }, } return app +def _ensure_history_indexes(): + """确保历史记录相关索引存在(幂等)""" + from sqlalchemy import text + + statements = [ + "CREATE INDEX IF NOT EXISTS idx_history_records_status ON history_records (status)", + "CREATE INDEX IF NOT EXISTS idx_history_records_created_at ON history_records (created_at)", + "CREATE INDEX IF NOT EXISTS idx_history_records_task_id ON history_records (task_id)", + "CREATE INDEX IF NOT EXISTS idx_history_records_status_created_at ON history_records (status, created_at)", + ] + + for stmt in statements: + db.session.execute(text(stmt)) + db.session.commit() + + def _validate_config_on_startup(logger): """启动时验证配置""" from pathlib import Path @@ -104,19 +138,19 @@ def _validate_config_on_startup(logger): logger.info("📋 检查配置文件...") # 检查 text_providers.yaml - text_config_path = Path(__file__).parent.parent / 'text_providers.yaml' + text_config_path = Path(__file__).parent.parent / "text_providers.yaml" if text_config_path.exists(): try: - with open(text_config_path, 'r', encoding='utf-8') as f: + with open(text_config_path, "r", encoding="utf-8") as f: text_config = yaml.safe_load(f) or {} - active = text_config.get('active_provider', '未设置') - providers = list(text_config.get('providers', {}).keys()) + active = text_config.get("active_provider", "未设置") + providers = list(text_config.get("providers", {}).keys()) logger.info(f"✅ 文本生成配置: 激活={active}, 可用服务商={providers}") # 检查激活的服务商是否有 API Key - if active in text_config.get('providers', {}): - provider = text_config['providers'][active] - if not provider.get('api_key'): + if active in text_config.get("providers", {}): + provider = text_config["providers"][active] + if not provider.get("api_key"): logger.warning(f"⚠️ 文本服务商 [{active}] 未配置 API Key") else: logger.info(f"✅ 文本服务商 [{active}] API Key 已配置") @@ -126,19 +160,19 @@ def _validate_config_on_startup(logger): logger.warning("⚠️ text_providers.yaml 不存在,将使用默认配置") # 检查 image_providers.yaml - image_config_path = Path(__file__).parent.parent / 'image_providers.yaml' + image_config_path = Path(__file__).parent.parent / "image_providers.yaml" if image_config_path.exists(): try: - with open(image_config_path, 'r', encoding='utf-8') as f: + with open(image_config_path, "r", encoding="utf-8") as f: image_config = yaml.safe_load(f) or {} - active = image_config.get('active_provider', '未设置') - providers = list(image_config.get('providers', {}).keys()) + active = image_config.get("active_provider", "未设置") + providers = list(image_config.get("providers", {}).keys()) logger.info(f"✅ 图片生成配置: 激活={active}, 可用服务商={providers}") # 检查激活的服务商是否有 API Key - if active in image_config.get('providers', {}): - provider = image_config['providers'][active] - if not provider.get('api_key'): + if active in image_config.get("providers", {}): + provider = image_config["providers"][active] + if not provider.get("api_key"): logger.warning(f"⚠️ 图片服务商 [{active}] 未配置 API Key") else: logger.info(f"✅ 图片服务商 [{active}] API Key 已配置") @@ -150,10 +184,6 @@ def _validate_config_on_startup(logger): logger.info("✅ 配置检查完成") -if __name__ == '__main__': +if __name__ == "__main__": app = create_app() - app.run( - host=Config.HOST, - port=Config.PORT, - debug=Config.DEBUG - ) + app.run(host=Config.HOST, port=Config.PORT, debug=Config.DEBUG) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..e989c38 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,88 @@ +from datetime import datetime +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import Index +import json + +db = SQLAlchemy() + + +class HistoryRecord(db.Model): + """历史记录模型""" + + __tablename__ = "history_records" + __table_args__ = ( + Index("idx_history_records_status", "status"), + Index("idx_history_records_created_at", "created_at"), + Index("idx_history_records_task_id", "task_id"), + Index("idx_history_records_status_created_at", "status", "created_at"), + ) + + id = db.Column(db.String(36), primary_key=True) # 使用 UUID + title = db.Column(db.String(255), nullable=False) + status = db.Column(db.String(50), nullable=False, default="draft") + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column( + db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow + ) + + # 存储 JSON 数据为 TEXT + _outline_json = db.Column("outline", db.Text, nullable=True) + _images_json = db.Column("images", db.Text, nullable=True) + + thumbnail = db.Column(db.String(255), nullable=True) + page_count = db.Column(db.Integer, default=0) + task_id = db.Column(db.String(100), nullable=True) + + @property + def outline(self): + if self._outline_json: + try: + return json.loads(self._outline_json) + except Exception: + return {} + return {} + + @outline.setter + def outline(self, value): + self._outline_json = json.dumps(value, ensure_ascii=False) + + @property + def images(self): + if self._images_json: + try: + return json.loads(self._images_json) + except Exception: + return {"task_id": self.task_id, "generated": []} + return {"task_id": self.task_id, "generated": []} + + @images.setter + def images(self, value): + self._images_json = json.dumps(value, ensure_ascii=False) + + def to_dict(self): + """转为字典供 API 返回""" + return { + "id": self.id, + "title": self.title, + "status": self.status, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "outline": self.outline, + "images": self.images, + "thumbnail": self.thumbnail, + "page_count": self.page_count, + "task_id": self.task_id, + } + + def to_index_dict(self): + """转为简要字典供列表 API 返回""" + return { + "id": self.id, + "title": self.title, + "status": self.status, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "thumbnail": self.thumbnail, + "page_count": self.page_count, + "task_id": self.task_id, + } diff --git a/backend/services/history.py b/backend/services/history.py index 0e5cd8a..c25b302 100644 --- a/backend/services/history.py +++ b/backend/services/history.py @@ -1,196 +1,68 @@ """ -历史记录服务 +历史记录服务 (SQLite 版) 负责管理绘本生成历史记录的存储、查询、更新和删除。 支持草稿、生成中、完成等多种状态流转。 """ import os -import json import uuid from datetime import datetime from typing import Dict, List, Optional, Any -from pathlib import Path -from enum import Enum +from backend.models import db, HistoryRecord class RecordStatus: """历史记录状态常量""" - DRAFT = "draft" # 草稿:已创建大纲,未开始生成 + + DRAFT = "draft" # 草稿:已创建大纲,未开始生成 GENERATING = "generating" # 生成中:正在生成图片 - PARTIAL = "partial" # 部分完成:有部分图片生成 - COMPLETED = "completed" # 已完成:所有图片已生成 - ERROR = "error" # 错误:生成过程中出现错误 + PARTIAL = "partial" # 部分完成:有部分图片生成 + COMPLETED = "completed" # 已完成:所有图片已生成 + ERROR = "error" # 错误:生成过程中出现错误 class HistoryService: def __init__(self): """ 初始化历史记录服务 - - 创建历史记录存储目录和索引文件 """ - # 历史记录存储目录(项目根目录/history) + # 兼容旧路径,用于存储图片 self.history_dir = os.path.join( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))), - "history" + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "history" ) os.makedirs(self.history_dir, exist_ok=True) - # 索引文件路径 - self.index_file = os.path.join(self.history_dir, "index.json") - self._init_index() - - def _init_index(self) -> None: - """ - 初始化索引文件 - - 如果索引文件不存在,则创建一个空索引 - """ - if not os.path.exists(self.index_file): - with open(self.index_file, "w", encoding="utf-8") as f: - json.dump({"records": []}, f, ensure_ascii=False, indent=2) - - def _load_index(self) -> Dict: - """ - 加载索引文件 - - Returns: - Dict: 索引数据,包含 records 列表 - """ - try: - with open(self.index_file, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: - return {"records": []} - - def _save_index(self, index: Dict) -> None: - """ - 保存索引文件 - - Args: - index: 索引数据 - """ - with open(self.index_file, "w", encoding="utf-8") as f: - json.dump(index, f, ensure_ascii=False, indent=2) - - def _get_record_path(self, record_id: str) -> str: - """ - 获取历史记录文件路径 - - Args: - record_id: 记录 ID - - Returns: - str: 记录文件的完整路径 - """ - return os.path.join(self.history_dir, f"{record_id}.json") - def create_record( - self, - topic: str, - outline: Dict, - task_id: Optional[str] = None + self, topic: str, outline: Dict, task_id: Optional[str] = None ) -> str: - """ - 创建新的历史记录 - - 初始状态为 draft(草稿),表示大纲已创建但尚未开始生成图片。 - - Args: - topic: 绘本主题/标题 - outline: 大纲内容,包含 pages 数组等信息 - task_id: 关联的生成任务 ID(可选) - - Returns: - str: 新创建的记录 ID(UUID 格式) - - 状态流转: - 新建 -> draft(草稿状态) - """ - # 生成唯一记录 ID + """创建新的历史记录""" record_id = str(uuid.uuid4()) - now = datetime.now().isoformat() - - # 创建完整的记录对象 - record = { - "id": record_id, - "title": topic, - "created_at": now, - "updated_at": now, - "outline": outline, # 保存完整的大纲数据 - "images": { - "task_id": task_id, - "generated": [] # 初始无生成图片 - }, - "status": RecordStatus.DRAFT, # 初始状态:草稿 - "thumbnail": None # 初始无缩略图 - } - # 保存完整记录到独立文件 - record_path = self._get_record_path(record_id) - with open(record_path, "w", encoding="utf-8") as f: - json.dump(record, f, ensure_ascii=False, indent=2) - - # 更新索引(用于快速列表查询) - index = self._load_index() - index["records"].insert(0, { - "id": record_id, - "title": topic, - "created_at": now, - "updated_at": now, - "status": RecordStatus.DRAFT, # 索引中也记录状态 - "thumbnail": None, - "page_count": len(outline.get("pages", [])), # 预期页数 - "task_id": task_id - }) - self._save_index(index) + record = HistoryRecord( + id=record_id, + title=topic, + status=RecordStatus.DRAFT, + outline=outline, + task_id=task_id, + page_count=len(outline.get("pages", [])), + ) + + db.session.add(record) + db.session.commit() return record_id def get_record(self, record_id: str) -> Optional[Dict]: - """ - 获取历史记录详情 - - Args: - record_id: 记录 ID - - Returns: - Optional[Dict]: 记录详情,如果不存在则返回 None - - 返回数据包含: - - id: 记录 ID - - title: 标题 - - created_at: 创建时间 - - updated_at: 更新时间 - - outline: 大纲内容 - - images: 图片信息(task_id 和 generated 列表) - - status: 当前状态 - - thumbnail: 缩略图文件名 - """ - record_path = self._get_record_path(record_id) - - if not os.path.exists(record_path): - return None - - try: - with open(record_path, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: + """获取历史记录详情""" + record = HistoryRecord.query.get(record_id) + if not record: return None + return record.to_dict() def record_exists(self, record_id: str) -> bool: - """ - 检查历史记录是否存在 - - Args: - record_id: 记录 ID - - Returns: - bool: 记录是否存在 - """ - record_path = self._get_record_path(record_id) - return os.path.exists(record_path) + """检查历史记录是否存在""" + return HistoryRecord.query.get(record_id) is not None def update_record( self, @@ -198,416 +70,192 @@ def update_record( outline: Optional[Dict] = None, images: Optional[Dict] = None, status: Optional[str] = None, - thumbnail: Optional[str] = None + thumbnail: Optional[str] = None, ) -> bool: - """ - 更新历史记录 - - 支持部分更新,只更新提供的字段。 - 每次更新都会自动刷新 updated_at 时间戳。 - - Args: - record_id: 记录 ID - outline: 大纲内容(可选,用于修改大纲) - images: 图片信息(可选,包含 task_id 和 generated 列表) - status: 状态(可选) - thumbnail: 缩略图文件名(可选) - - Returns: - bool: 更新是否成功,记录不存在时返回 False - - 状态流转说明: - draft -> generating: 开始生成图片 - generating -> partial: 部分图片生成完成 - generating -> completed: 所有图片生成完成 - generating -> error: 生成过程出错 - partial -> generating: 继续生成剩余图片 - partial -> completed: 剩余图片生成完成 - """ - # 获取现有记录 - record = self.get_record(record_id) + """更新历史记录""" + record = HistoryRecord.query.get(record_id) if not record: return False - # 更新时间戳 - now = datetime.now().isoformat() - record["updated_at"] = now - - # 更新大纲内容(支持修改大纲) if outline is not None: - record["outline"] = outline + record.outline = outline + record.page_count = len(outline.get("pages", [])) - # 更新图片信息 if images is not None: - record["images"] = images + record.images = images + if images.get("task_id"): + record.task_id = images.get("task_id") - # 更新状态(状态流转) if status is not None: - record["status"] = status + record.status = status - # 更新缩略图 if thumbnail is not None: - record["thumbnail"] = thumbnail - - # 保存完整记录 - record_path = self._get_record_path(record_id) - with open(record_path, "w", encoding="utf-8") as f: - json.dump(record, f, ensure_ascii=False, indent=2) - - # 同步更新索引 - index = self._load_index() - for idx_record in index["records"]: - if idx_record["id"] == record_id: - idx_record["updated_at"] = now - - # 更新状态 - if status: - idx_record["status"] = status - - # 更新缩略图 - if thumbnail: - idx_record["thumbnail"] = thumbnail - - # 更新页数(如果大纲被修改) - if outline: - idx_record["page_count"] = len(outline.get("pages", [])) - - # 更新任务 ID - if images is not None and images.get("task_id"): - idx_record["task_id"] = images.get("task_id") - - break + record.thumbnail = thumbnail - self._save_index(index) + db.session.commit() return True def delete_record(self, record_id: str) -> bool: - """ - 删除历史记录 - - 会同时删除: - 1. 记录 JSON 文件 - 2. 关联的任务图片目录 - 3. 索引中的记录 - - Args: - record_id: 记录 ID - - Returns: - bool: 删除是否成功,记录不存在时返回 False - """ - record = self.get_record(record_id) + """删除历史记录""" + record = HistoryRecord.query.get(record_id) if not record: return False - # 删除关联的任务图片目录 - if record.get("images") and record["images"].get("task_id"): - task_id = record["images"]["task_id"] - task_dir = os.path.join(self.history_dir, task_id) + # 删除关联的任务图片目录 (物理删除) + if record.task_id: + task_dir = os.path.join(self.history_dir, record.task_id) if os.path.exists(task_dir) and os.path.isdir(task_dir): try: import shutil + shutil.rmtree(task_dir) - print(f"已删除任务目录: {task_dir}") except Exception as e: print(f"删除任务目录失败: {task_dir}, {e}") - # 删除记录 JSON 文件 - record_path = self._get_record_path(record_id) - try: - os.remove(record_path) - except Exception: - return False - - # 从索引中移除 - index = self._load_index() - index["records"] = [r for r in index["records"] if r["id"] != record_id] - self._save_index(index) - + db.session.delete(record) + db.session.commit() return True def list_records( - self, - page: int = 1, - page_size: int = 20, - status: Optional[str] = None + self, page: int = 1, page_size: int = 20, status: Optional[str] = None ) -> Dict: - """ - 分页获取历史记录列表 - - Args: - page: 页码,从 1 开始 - page_size: 每页记录数 - status: 状态过滤(可选),支持:draft/generating/partial/completed/error - - Returns: - Dict: 分页结果 - - records: 当前页的记录列表 - - total: 总记录数 - - page: 当前页码 - - page_size: 每页大小 - - total_pages: 总页数 - """ - index = self._load_index() - records = index.get("records", []) + """分页获取历史记录列表""" + query = HistoryRecord.query - # 按状态过滤 if status: - records = [r for r in records if r.get("status") == status] + query = query.filter_by(status=status) - # 分页计算 - total = len(records) - start = (page - 1) * page_size - end = start + page_size - page_records = records[start:end] + pagination = query.order_by(HistoryRecord.created_at.desc()).paginate( + page=page, per_page=page_size, error_out=False + ) return { - "records": page_records, - "total": total, + "records": [r.to_index_dict() for r in pagination.items], + "total": pagination.total, "page": page, "page_size": page_size, - "total_pages": (total + page_size - 1) // page_size + "total_pages": pagination.pages, } def search_records(self, keyword: str) -> List[Dict]: - """ - 根据关键词搜索历史记录 - - Args: - keyword: 搜索关键词(不区分大小写) - - Returns: - List[Dict]: 匹配的记录列表(按创建时间倒序) - """ - index = self._load_index() - records = index.get("records", []) - - # 不区分大小写的标题搜索 - keyword_lower = keyword.lower() - results = [ - r for r in records - if keyword_lower in r.get("title", "").lower() - ] + """根据关键词搜索历史记录""" + results = ( + HistoryRecord.query.filter(HistoryRecord.title.ilike(f"%{keyword}%")) + .order_by(HistoryRecord.created_at.desc()) + .all() + ) - return results + return [r.to_index_dict() for r in results] def get_statistics(self) -> Dict: - """ - 获取历史记录统计信息 - - Returns: - Dict: 统计数据 - - total: 总记录数 - - by_status: 各状态的记录数 - - draft: 草稿数 - - generating: 生成中数 - - partial: 部分完成数 - - completed: 已完成数 - - error: 错误数 - """ - index = self._load_index() - records = index.get("records", []) - - total = len(records) - status_count = {} + """获取历史记录统计信息""" + total = HistoryRecord.query.count() # 统计各状态的记录数 - for record in records: - status = record.get("status", RecordStatus.DRAFT) - status_count[status] = status_count.get(status, 0) + 1 + from sqlalchemy import func - return { - "total": total, - "by_status": status_count - } + status_counts = ( + db.session.query(HistoryRecord.status, func.count(HistoryRecord.id)) + .group_by(HistoryRecord.status) + .all() + ) + + by_status = {status: count for status, count in status_counts} + + return {"total": total, "by_status": by_status} def scan_and_sync_task_images(self, task_id: str) -> Dict[str, Any]: - """ - 扫描任务文件夹,同步图片列表 - - 根据实际生成的图片数量自动更新记录状态: - - 无图片 -> draft(草稿) - - 部分图片 -> partial(部分完成) - - 全部图片 -> completed(已完成) - - Args: - task_id: 任务 ID - - Returns: - Dict[str, Any]: 扫描结果 - - success: 是否成功 - - record_id: 关联的记录 ID - - task_id: 任务 ID - - images_count: 图片数量 - - images: 图片文件名列表 - - status: 更新后的状态 - - error: 错误信息(失败时) - """ + """扫描任务文件夹,同步图片列表""" task_dir = os.path.join(self.history_dir, task_id) if not os.path.exists(task_dir) or not os.path.isdir(task_dir): - return { - "success": False, - "error": f"任务目录不存在: {task_id}" - } + return {"success": False, "error": f"任务目录不存在: {task_id}"} try: - # 扫描目录下所有图片文件(排除缩略图) image_files = [] for filename in os.listdir(task_dir): - # 跳过缩略图文件(以 thumb_ 开头) - if filename.startswith('thumb_'): + if filename.startswith("thumb_"): continue - if filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg'): + if filename.lower().endswith((".png", ".jpg", ".jpeg")): image_files.append(filename) - # 按文件名排序(数字排序) - def get_index(filename): - try: - return int(filename.split('.')[0]) - except: - return 999 - - image_files.sort(key=get_index) - - # 查找关联的历史记录 - index = self._load_index() - record_id = None - for rec in index.get("records", []): - # 通过遍历所有记录,找到 task_id 匹配的记录 - record_detail = self.get_record(rec["id"]) - if record_detail and record_detail.get("images", {}).get("task_id") == task_id: - record_id = rec["id"] - break - - if record_id: - # 更新历史记录 - record = self.get_record(record_id) - if record: - # 根据生成图片数量判断状态 - expected_count = len(record.get("outline", {}).get("pages", [])) - actual_count = len(image_files) - - if actual_count == 0: - status = RecordStatus.DRAFT # 无图片:草稿 - elif actual_count >= expected_count: - status = RecordStatus.COMPLETED # 全部完成 - else: - status = RecordStatus.PARTIAL # 部分完成 - - # 更新图片列表和状态 - self.update_record( - record_id, - images={ - "task_id": task_id, - "generated": image_files - }, - status=status, - thumbnail=image_files[0] if image_files else None - ) - - return { - "success": True, - "record_id": record_id, - "task_id": task_id, - "images_count": len(image_files), - "images": image_files, - "status": status - } - - # 没有关联的记录,返回扫描结果 + image_files.sort( + key=lambda x: int(x.split(".")[0]) if x.split(".")[0].isdigit() else 999 + ) + + record = HistoryRecord.query.filter_by(task_id=task_id).first() + if record: + expected_count = record.page_count + actual_count = len(image_files) + + if actual_count == 0: + status = RecordStatus.DRAFT + elif actual_count >= expected_count: + status = RecordStatus.COMPLETED + else: + status = RecordStatus.PARTIAL + + self.update_record( + record.id, + images={"task_id": task_id, "generated": image_files}, + status=status, + thumbnail=image_files[0] if image_files else None, + ) + + return { + "success": True, + "record_id": record.id, + "task_id": task_id, + "images_count": len(image_files), + "images": image_files, + "status": status, + } + return { "success": True, "task_id": task_id, "images_count": len(image_files), "images": image_files, - "no_record": True + "no_record": True, } except Exception as e: - return { - "success": False, - "error": f"扫描任务失败: {str(e)}" - } + return {"success": False, "error": f"扫描任务失败: {str(e)}"} def scan_all_tasks(self) -> Dict[str, Any]: - """ - 扫描所有任务文件夹,同步图片列表 - - 批量扫描 history 目录下的所有任务文件夹, - 同步图片列表并更新记录状态。 - - Returns: - Dict[str, Any]: 扫描结果统计 - - success: 是否成功 - - total_tasks: 扫描的任务总数 - - synced: 成功同步的任务数 - - failed: 失败的任务数 - - orphan_tasks: 孤立任务列表(有图片但无记录) - - results: 详细结果列表 - - error: 错误信息(失败时) - """ + """扫描所有任务文件夹,同步图片列表""" if not os.path.exists(self.history_dir): - return { - "success": False, - "error": "历史记录目录不存在" - } + return {"success": False, "error": "历史记录目录不存在"} try: - synced_count = 0 - failed_count = 0 - orphan_tasks = [] # 没有关联记录的任务 results = [] - - # 遍历 history 目录 for item in os.listdir(self.history_dir): item_path = os.path.join(self.history_dir, item) + if os.path.isdir(item_path): + results.append(self.scan_and_sync_task_images(item)) - # 只处理目录(任务文件夹) - if not os.path.isdir(item_path): - continue - - # 假设任务文件夹名就是 task_id - task_id = item - - # 扫描并同步 - result = self.scan_and_sync_task_images(task_id) - results.append(result) - - if result.get("success"): - if result.get("no_record"): - orphan_tasks.append(task_id) - else: - synced_count += 1 - else: - failed_count += 1 + synced = sum( + 1 for r in results if r.get("success") and not r.get("no_record") + ) + failed = sum(1 for r in results if not r.get("success")) + orphan = [r["task_id"] for r in results if r.get("no_record")] return { "success": True, "total_tasks": len(results), - "synced": synced_count, - "failed": failed_count, - "orphan_tasks": orphan_tasks, - "results": results + "synced": synced, + "failed": failed, + "orphan_tasks": orphan, + "results": results, } - except Exception as e: - return { - "success": False, - "error": f"扫描所有任务失败: {str(e)}" - } + return {"success": False, "error": f"扫描所有任务失败: {str(e)}"} _service_instance = None def get_history_service() -> HistoryService: - """ - 获取历史记录服务实例(单例模式) - - Returns: - HistoryService: 历史记录服务实例 - """ global _service_instance if _service_instance is None: _service_instance = HistoryService() diff --git a/pyproject.toml b/pyproject.toml index 1c6e925..4ecb792 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "pyyaml>=6.0.0", "requests>=2.31.0", "pillow>=12.0.0", + "flask-sqlalchemy>=3.1.0", ] [build-system] diff --git a/scripts/migrate_json_to_sqlite.py b/scripts/migrate_json_to_sqlite.py new file mode 100644 index 0000000..fd6fe5c --- /dev/null +++ b/scripts/migrate_json_to_sqlite.py @@ -0,0 +1,86 @@ +import os +import json +import sys +from pathlib import Path + +# 添加项目根目录到 sys.path +root_dir = Path(__file__).parent.parent +sys.path.append(str(root_dir)) + +from backend.app import create_app +from backend.models import db, HistoryRecord +from datetime import datetime + + +def migrate(): + app = create_app() + with app.app_context(): + history_dir = root_dir / "history" + index_file = history_dir / "index.json" + + if not index_file.exists(): + print("未找到 index.json,跳过迁移。") + return + + print(f"正在从 {index_file} 加载数据...") + with open(index_file, "r", encoding="utf-8") as f: + index_data = json.load(f) + + records_to_migrate = index_data.get("records", []) + print(f"找到 {len(records_to_migrate)} 条记录。") + + for idx_record in records_to_migrate: + record_id = idx_record["id"] + + # 检查数据库中是否已存在 + if HistoryRecord.query.get(record_id): + print(f"记录 {record_id} 已存在,跳过。") + continue + + # 尝试加载详细记录文件 + record_file = history_dir / f"{record_id}.json" + if record_file.exists(): + with open(record_file, "r", encoding="utf-8") as f: + detail = json.load(f) + else: + print(f"警告:找不到详情文件 {record_file},使用索引数据。") + detail = idx_record + + # 创建模型实例 + # 处理时间格式 (ISO 格式) + def parse_date(date_str): + try: + return datetime.fromisoformat(date_str.replace("Z", "+00:00")) + except: + return datetime.utcnow() + + new_record = HistoryRecord( + id=record_id, + title=detail.get("title", idx_record.get("title", "无标题")), + status=detail.get("status", idx_record.get("status", "draft")), + created_at=parse_date( + detail.get("created_at", idx_record.get("created_at", "")) + ), + updated_at=parse_date( + detail.get("updated_at", idx_record.get("updated_at", "")) + ), + thumbnail=detail.get("thumbnail", idx_record.get("thumbnail")), + page_count=detail.get("page_count", idx_record.get("page_count", 0)), + task_id=detail.get("images", {}).get("task_id") + if isinstance(detail.get("images"), dict) + else detail.get("task_id"), + ) + + # 设置 JSON 字段 + new_record.outline = detail.get("outline", {}) + new_record.images = detail.get("images", {}) + + db.session.add(new_record) + print(f"已添加记录: {record_id} ({new_record.title})") + + db.session.commit() + print("迁移完成!") + + +if __name__ == "__main__": + migrate() diff --git a/uv.lock b/uv.lock index 0f2b358..b3bddfd 100644 --- a/uv.lock +++ b/uv.lock @@ -176,6 +176,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/f8/01bf35a3afd734345528f98d0353f2a978a476528ad4d7e78b70c4d149dd/flask_cors-6.0.1-py3-none-any.whl", hash = "sha256:c7b2cbfb1a31aa0d2e5341eea03a6805349f7a61647daee1a15c46bbe981494c", size = 13244, upload-time = "2025-06-11T01:32:07.352Z" }, ] +[[package]] +name = "flask-sqlalchemy" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899, upload-time = "2023-09-11T21:42:36.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125, upload-time = "2023-09-11T21:42:34.514Z" }, +] + [[package]] name = "google-auth" version = "2.43.0" @@ -209,6 +222,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/66/03f663e7bca7abe9ccfebe6cb3fe7da9a118fd723a5abb278d6117e7990e/google_genai-1.52.0-py3-none-any.whl", hash = "sha256:c8352b9f065ae14b9322b949c7debab8562982f03bf71d44130cd2b798c20743", size = 261219, upload-time = "2025-11-21T02:18:54.515Z" }, ] +[[package]] +name = "greenlet" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" }, + { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71", size = 301164, upload-time = "2025-12-04T14:42:51.577Z" }, + { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, + { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, + { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, + { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, + { url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" }, + { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, + { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, + { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, + { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, + { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" }, + { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -670,6 +730,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/1c/769552a9d840065137272ebe86ffbb0bc92b0f1e0a68ee5266a225f8cd7b/sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56", size = 2153860, upload-time = "2025-12-10T20:03:23.843Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f8/9be54ff620e5b796ca7b44670ef58bc678095d51b0e89d6e3102ea468216/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b", size = 3309379, upload-time = "2025-12-09T22:06:07.461Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2b/60ce3ee7a5ae172bfcd419ce23259bb874d2cddd44f67c5df3760a1e22f9/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac", size = 3309948, upload-time = "2025-12-09T22:09:57.643Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/bac8d393f5db550e4e466d03d16daaafd2bad1f74e48c12673fb499a7fc1/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606", size = 3261239, upload-time = "2025-12-09T22:06:08.879Z" }, + { url = "https://files.pythonhosted.org/packages/6f/12/43dc70a0528c59842b04ea1c1ed176f072a9b383190eb015384dd102fb19/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c", size = 3284065, upload-time = "2025-12-09T22:09:59.454Z" }, + { url = "https://files.pythonhosted.org/packages/cf/9c/563049cf761d9a2ec7bc489f7879e9d94e7b590496bea5bbee9ed7b4cc32/sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177", size = 2113480, upload-time = "2025-12-09T21:29:57.03Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fa/09d0a11fe9f15c7fa5c7f0dd26be3d235b0c0cbf2f9544f43bc42efc8a24/sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2", size = 2138407, upload-time = "2025-12-09T21:29:58.556Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f", size = 3333760, upload-time = "2025-12-09T22:11:02.66Z" }, + { url = "https://files.pythonhosted.org/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d", size = 3348268, upload-time = "2025-12-09T22:13:49.054Z" }, + { url = "https://files.pythonhosted.org/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4", size = 3278144, upload-time = "2025-12-09T22:11:04.14Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/310ecb5657221f3e1bd5288ed83aa554923fb5da48d760a9f7622afeb065/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6", size = 3313907, upload-time = "2025-12-09T22:13:50.598Z" }, + { url = "https://files.pythonhosted.org/packages/5c/39/69c0b4051079addd57c84a5bfb34920d87456dd4c90cf7ee0df6efafc8ff/sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953", size = 2112182, upload-time = "2025-12-09T21:39:30.824Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4e/510db49dd89fc3a6e994bee51848c94c48c4a00dc905e8d0133c251f41a7/sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1", size = 2139200, upload-time = "2025-12-09T21:39:32.321Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" }, + { url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" }, + { url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" }, + { url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" }, + { url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" }, + { url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" }, + { url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" }, + { url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" }, + { url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, +] + [[package]] name = "tenacity" version = "9.1.2" @@ -770,6 +872,7 @@ source = { editable = "." } dependencies = [ { name = "flask" }, { name = "flask-cors" }, + { name = "flask-sqlalchemy" }, { name = "google-genai" }, { name = "pillow" }, { name = "python-dotenv" }, @@ -781,6 +884,7 @@ dependencies = [ requires-dist = [ { name = "flask", specifier = ">=3.0.0" }, { name = "flask-cors", specifier = ">=4.0.0" }, + { name = "flask-sqlalchemy", specifier = ">=3.1.0" }, { name = "google-genai", specifier = ">=1.0.0" }, { name = "pillow", specifier = ">=12.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" },