- Add company module (3-tier org, CEO planning, parallel departments) - Add company orchestrator, knowledge extractor, presets, scheduler - Add company API endpoints, models, and frontend views - Add 今天吃啥 PWA app (69 dishes, real images, offline support) - Add team_projects output directory structure - Add unified manage.ps1 for service lifecycle - Add Windows startup guide v1.0 - Add TTS troubleshooting doc - Update frontend (AgentChat UX overhaul, new views) - Update backend (voice engine fix, multi-tenant, RBAC) - Remove deprecated startup scripts and old docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1066 lines
39 KiB
Python
1066 lines
39 KiB
Python
"""
|
||
================================================================================
|
||
家庭阅读激励工具 — 照片管理服务 (PhotoService)
|
||
================================================================================
|
||
模块职责:
|
||
1. 设备相机/相册选取接口封装(桌面端模拟 → 文件路径导入)
|
||
2. 图片压缩:限制最大分辨率 1024px,JPEG 质量 80%
|
||
3. 本地文件系统存储:按 child_id/date 分目录
|
||
4. 缩略图生成:256px 正方形居中裁剪 + 缓存
|
||
5. 照片元数据 CRUD(SQLite photos 表)
|
||
6. 批量操作与清理
|
||
|
||
依赖: Pillow (PIL) >= 10.0, Python 3.10+
|
||
数据库: 读取同目录 01-schema.sql,自动生成 photos 迁移 DDL
|
||
|
||
Author: dev-frontend
|
||
Version: 1.0.0
|
||
Date: 2026-07-05
|
||
================================================================================
|
||
"""
|
||
|
||
import hashlib
|
||
import os
|
||
import shutil
|
||
import sqlite3
|
||
import threading
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, date
|
||
from io import BytesIO
|
||
from pathlib import Path
|
||
from typing import Optional, List, Tuple, Union
|
||
import logging
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 依赖检测:Pillow 用于图像处理,缺失时给出明确提示
|
||
# ---------------------------------------------------------------------------
|
||
try:
|
||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||
except ImportError:
|
||
raise ImportError(
|
||
"PhotoService 依赖 Pillow 库。请执行: pip install Pillow>=10.0"
|
||
)
|
||
|
||
logger = logging.getLogger("PhotoService")
|
||
|
||
# ============================================================================
|
||
# 常量 & 默认值
|
||
# ============================================================================
|
||
|
||
# 图片处理参数
|
||
MAX_DIMENSION = 1024 # 压缩后最大边长(像素)
|
||
JPEG_QUALITY = 80 # JPEG 压缩质量(1-100)
|
||
THUMBNAIL_SIZE = 256 # 缩略图边长(像素)
|
||
THUMBNAIL_SUFFIX = "_thumb" # 缩略图文件名后缀
|
||
|
||
# 支持的输入格式
|
||
SUPPORTED_INPUT_FORMATS = frozenset({
|
||
".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp", ".tiff", ".tif"
|
||
})
|
||
|
||
# 照片表 DDL(与 01-schema.sql 第8张表对应,作为迁移补充)
|
||
PHOTOS_TABLE_DDL = """
|
||
CREATE TABLE IF NOT EXISTS photos (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
child_id INTEGER NOT NULL,
|
||
reading_record_id INTEGER,
|
||
file_path TEXT NOT NULL,
|
||
thumbnail_path TEXT,
|
||
original_name TEXT,
|
||
file_size INTEGER,
|
||
width INTEGER,
|
||
height INTEGER,
|
||
file_hash TEXT,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||
|
||
FOREIGN KEY (child_id)
|
||
REFERENCES children(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (reading_record_id)
|
||
REFERENCES reading_records(id) ON DELETE SET NULL
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_photos_child ON photos(child_id);
|
||
CREATE INDEX IF NOT EXISTS idx_photos_record ON photos(reading_record_id);
|
||
CREATE INDEX IF NOT EXISTS idx_photos_hash ON photos(file_hash);
|
||
CREATE INDEX IF NOT EXISTS idx_photos_created ON photos(created_at);
|
||
"""
|
||
|
||
|
||
# ============================================================================
|
||
# 数据类
|
||
# ============================================================================
|
||
|
||
@dataclass
|
||
class PhotoRecord:
|
||
"""照片元数据(与 photos 表一一对应)"""
|
||
id: int
|
||
child_id: int
|
||
file_path: str
|
||
original_name: str = ""
|
||
thumbnail_path: Optional[str] = None
|
||
reading_record_id: Optional[int] = None
|
||
file_size: int = 0
|
||
width: int = 0
|
||
height: int = 0
|
||
file_hash: str = ""
|
||
created_at: str = ""
|
||
|
||
def to_dict(self) -> dict:
|
||
return {
|
||
"id": self.id,
|
||
"child_id": self.child_id,
|
||
"reading_record_id": self.reading_record_id,
|
||
"file_path": self.file_path,
|
||
"thumbnail_path": self.thumbnail_path,
|
||
"original_name": self.original_name,
|
||
"file_size": self.file_size,
|
||
"width": self.width,
|
||
"height": self.height,
|
||
"file_hash": self.file_hash,
|
||
"created_at": self.created_at,
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class CompressionResult:
|
||
"""压缩操作结果"""
|
||
original_size: Tuple[int, int]
|
||
compressed_size: Tuple[int, int]
|
||
original_bytes: int
|
||
compressed_bytes: int
|
||
compression_ratio: float # 压缩后 / 压缩前
|
||
|
||
@property
|
||
def saved_percent(self) -> float:
|
||
return round((1 - self.compression_ratio) * 100, 1)
|
||
|
||
|
||
# ============================================================================
|
||
# PhotoService 核心类
|
||
# ============================================================================
|
||
|
||
class PhotoService:
|
||
"""
|
||
照片管理服务 — 封装拍照/选图/压缩/存储/缩略图/删除全流程。
|
||
|
||
使用方式::
|
||
|
||
service = PhotoService(db_path="app.db", storage_root="./photos")
|
||
record = service.import_photo(child_id=1, source_path="/tmp/photo.jpg")
|
||
thumb = service.get_thumbnail(record.id)
|
||
service.delete_photo(record.id)
|
||
|
||
线程安全:写操作使用 threading.Lock 保护。
|
||
"""
|
||
|
||
# ------------------------------------------------------------------
|
||
# 构造 & 初始化
|
||
# ------------------------------------------------------------------
|
||
|
||
def __init__(
|
||
self,
|
||
db_path: str = "reading_app.db",
|
||
storage_root: str = "photos",
|
||
):
|
||
"""
|
||
Args:
|
||
db_path: SQLite 数据库文件路径
|
||
storage_root: 照片存储根目录(相对或绝对路径)
|
||
"""
|
||
self._db_path = os.path.abspath(db_path)
|
||
self._storage_root = Path(storage_root).resolve()
|
||
self._lock = threading.Lock()
|
||
|
||
# 确保存储根目录存在
|
||
self._storage_root.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 初始化数据库表
|
||
self._ensure_photos_table()
|
||
|
||
logger.info(
|
||
"PhotoService 初始化完成 | db=%s | storage=%s",
|
||
self._db_path, self._storage_root,
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 数据库管理(私有)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _get_connection(self) -> sqlite3.Connection:
|
||
"""获取数据库连接(每次新建,保证线程安全)"""
|
||
conn = sqlite3.connect(self._db_path)
|
||
conn.row_factory = sqlite3.Row
|
||
conn.execute("PRAGMA foreign_keys = ON")
|
||
conn.execute("PRAGMA journal_mode = WAL")
|
||
return conn
|
||
|
||
def _ensure_photos_table(self) -> None:
|
||
"""执行 photos 表迁移 DDL(幂等)"""
|
||
with self._get_connection() as conn:
|
||
conn.executescript(PHOTOS_TABLE_DDL)
|
||
conn.commit()
|
||
|
||
# ------------------------------------------------------------------
|
||
# 公开 API:导入照片(相机 / 相册统一入口)
|
||
# ------------------------------------------------------------------
|
||
|
||
def import_photo(
|
||
self,
|
||
child_id: int,
|
||
source_path: str,
|
||
reading_record_id: Optional[int] = None,
|
||
delete_source: bool = False,
|
||
) -> PhotoRecord:
|
||
"""
|
||
从相机拍摄或相册选取后导入照片。
|
||
|
||
完整流程:
|
||
1. 校验源文件存在 & 格式合法
|
||
2. 计算 SHA256 哈希(去重检测)
|
||
3. 压缩 → JPEG bytes
|
||
4. 存入本地文件系统(child_id/date 子目录)
|
||
5. 生成 256px 正方形缩略图
|
||
6. 写入 photos 元数据表
|
||
7. 返回 PhotoRecord
|
||
|
||
Args:
|
||
child_id: 孩子 ID
|
||
source_path: 源文件路径(相机/相册产出)
|
||
reading_record_id: 关联的阅读打卡记录(可选)
|
||
delete_source: 导入成功后是否删除源文件
|
||
|
||
Returns:
|
||
PhotoRecord 元数据
|
||
|
||
Raises:
|
||
FileNotFoundError: 源文件不存在
|
||
ValueError: 格式不支持 或 孩子不存在
|
||
OSError: 文件写入失败
|
||
"""
|
||
source_path = os.path.abspath(source_path)
|
||
|
||
# --- 1. 校验 ---
|
||
if not os.path.isfile(source_path):
|
||
raise FileNotFoundError(f"源文件不存在: {source_path}")
|
||
|
||
ext = Path(source_path).suffix.lower()
|
||
if ext not in SUPPORTED_INPUT_FORMATS:
|
||
raise ValueError(
|
||
f"不支持的图片格式 '{ext}',支持: {sorted(SUPPORTED_INPUT_FORMATS)}"
|
||
)
|
||
|
||
if not self._child_exists(child_id):
|
||
raise ValueError(f"孩子不存在: child_id={child_id}")
|
||
|
||
if reading_record_id is not None and not self._record_exists(reading_record_id):
|
||
raise ValueError(f"阅读记录不存在: record_id={reading_record_id}")
|
||
|
||
original_name = os.path.basename(source_path)
|
||
|
||
# --- 2. 哈希去重 ---
|
||
file_hash = self._compute_hash(source_path)
|
||
existing = self._find_by_hash(child_id, file_hash)
|
||
if existing is not None:
|
||
logger.info("检测到重复照片,返回已有记录 id=%s", existing.id)
|
||
return existing
|
||
|
||
# --- 3. 加载 & 压缩 ---
|
||
with Image.open(source_path) as img:
|
||
original_size = img.size
|
||
compressed_bytes, compressed_size = self._compress_image(img)
|
||
|
||
# --- 4. 存储原始文件 ---
|
||
dest_dir = self._get_child_date_dir(child_id)
|
||
dest_filename = self._generate_filename(child_id, file_hash)
|
||
dest_path = dest_dir / dest_filename
|
||
|
||
with open(dest_path, "wb") as f:
|
||
f.write(compressed_bytes.getvalue())
|
||
file_size = dest_path.stat().st_size
|
||
|
||
# --- 5. 生成缩略图 ---
|
||
thumb_path = self._generate_thumbnail(dest_path, child_id, file_hash)
|
||
|
||
# --- 6. 写入数据库 ---
|
||
with self._lock:
|
||
with self._get_connection() as conn:
|
||
cursor = conn.execute(
|
||
"""INSERT INTO photos
|
||
(child_id, reading_record_id, file_path, thumbnail_path,
|
||
original_name, file_size, width, height, file_hash)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||
(
|
||
child_id,
|
||
reading_record_id,
|
||
str(dest_path),
|
||
str(thumb_path) if thumb_path else None,
|
||
original_name,
|
||
file_size,
|
||
compressed_size[0],
|
||
compressed_size[1],
|
||
file_hash,
|
||
),
|
||
)
|
||
photo_id = cursor.lastrowid
|
||
conn.commit()
|
||
|
||
# --- 7. 可选:清理源文件 ---
|
||
if delete_source:
|
||
try:
|
||
os.remove(source_path)
|
||
except OSError:
|
||
logger.warning("无法删除源文件: %s", source_path)
|
||
|
||
record = self.get_photo(photo_id)
|
||
assert record is not None
|
||
logger.info(
|
||
"照片导入成功 | id=%s | child=%s | %sx%s | %.1f KB",
|
||
photo_id, child_id, record.width, record.height, file_size / 1024,
|
||
)
|
||
return record
|
||
|
||
def import_photo_from_bytes(
|
||
self,
|
||
child_id: int,
|
||
image_bytes: bytes,
|
||
original_name: str = "camera_capture.jpg",
|
||
reading_record_id: Optional[int] = None,
|
||
) -> PhotoRecord:
|
||
"""
|
||
从内存中的图片字节数据导入(适用于相机直接拍摄返回 bytes 的场景)。
|
||
|
||
流程与 import_photo 一致,但跳过源文件校验步骤。
|
||
"""
|
||
if not self._child_exists(child_id):
|
||
raise ValueError(f"孩子不存在: child_id={child_id}")
|
||
|
||
# 哈希去重
|
||
file_hash = hashlib.sha256(image_bytes).hexdigest()
|
||
existing = self._find_by_hash(child_id, file_hash)
|
||
if existing is not None:
|
||
logger.info("检测到重复照片,返回已有记录 id=%s", existing.id)
|
||
return existing
|
||
|
||
# 压缩
|
||
with Image.open(BytesIO(image_bytes)) as img:
|
||
compressed_bytes, compressed_size = self._compress_image(img)
|
||
|
||
# 存储
|
||
dest_dir = self._get_child_date_dir(child_id)
|
||
dest_filename = self._generate_filename(child_id, file_hash)
|
||
dest_path = dest_dir / dest_filename
|
||
dest_path.write_bytes(compressed_bytes.getvalue())
|
||
file_size = dest_path.stat().st_size
|
||
|
||
# 缩略图
|
||
thumb_path = self._generate_thumbnail(dest_path, child_id, file_hash)
|
||
|
||
# 入库
|
||
with self._lock:
|
||
with self._get_connection() as conn:
|
||
cursor = conn.execute(
|
||
"""INSERT INTO photos
|
||
(child_id, reading_record_id, file_path, thumbnail_path,
|
||
original_name, file_size, width, height, file_hash)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||
(
|
||
child_id, reading_record_id, str(dest_path),
|
||
str(thumb_path) if thumb_path else None,
|
||
original_name, file_size,
|
||
compressed_size[0], compressed_size[1], file_hash,
|
||
),
|
||
)
|
||
photo_id = cursor.lastrowid
|
||
conn.commit()
|
||
|
||
return self.get_photo(photo_id) # type: ignore[return-value]
|
||
|
||
# ------------------------------------------------------------------
|
||
# 公开 API:查询
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_photo(self, photo_id: int) -> Optional[PhotoRecord]:
|
||
"""按 ID 查询单条照片记录"""
|
||
with self._get_connection() as conn:
|
||
row = conn.execute(
|
||
"SELECT * FROM photos WHERE id = ?", (photo_id,)
|
||
).fetchone()
|
||
return self._row_to_record(row) if row else None
|
||
|
||
def get_photos_by_child(
|
||
self,
|
||
child_id: int,
|
||
limit: int = 50,
|
||
offset: int = 0,
|
||
) -> List[PhotoRecord]:
|
||
"""查询某个孩子的照片列表(按时间倒序)"""
|
||
with self._get_connection() as conn:
|
||
rows = conn.execute(
|
||
"""SELECT * FROM photos
|
||
WHERE child_id = ?
|
||
ORDER BY created_at DESC
|
||
LIMIT ? OFFSET ?""",
|
||
(child_id, limit, offset),
|
||
).fetchall()
|
||
return [self._row_to_record(r) for r in rows]
|
||
|
||
def get_photos_by_record(self, reading_record_id: int) -> List[PhotoRecord]:
|
||
"""查询某次阅读打卡关联的照片"""
|
||
with self._get_connection() as conn:
|
||
rows = conn.execute(
|
||
"SELECT * FROM photos WHERE reading_record_id = ? ORDER BY created_at",
|
||
(reading_record_id,),
|
||
).fetchall()
|
||
return [self._row_to_record(r) for r in rows]
|
||
|
||
def get_photo_count(self, child_id: int) -> int:
|
||
"""统计某孩子的照片总数"""
|
||
with self._get_connection() as conn:
|
||
row = conn.execute(
|
||
"SELECT COUNT(*) as cnt FROM photos WHERE child_id = ?",
|
||
(child_id,),
|
||
).fetchone()
|
||
return row["cnt"] if row else 0
|
||
|
||
def get_total_storage_size(self, child_id: Optional[int] = None) -> int:
|
||
"""统计照片存储空间(字节),可选按孩子过滤"""
|
||
if child_id is not None:
|
||
sql = "SELECT COALESCE(SUM(file_size), 0) as total FROM photos WHERE child_id = ?"
|
||
params = (child_id,)
|
||
else:
|
||
sql = "SELECT COALESCE(SUM(file_size), 0) as total FROM photos"
|
||
params = ()
|
||
with self._get_connection() as conn:
|
||
row = conn.execute(sql, params).fetchone()
|
||
return row["total"] if row else 0
|
||
|
||
# ------------------------------------------------------------------
|
||
# 公开 API:缩略图
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_thumbnail_path(self, photo_id: int) -> Optional[str]:
|
||
"""获取照片的缩略图文件路径"""
|
||
record = self.get_photo(photo_id)
|
||
if record is None:
|
||
return None
|
||
if record.thumbnail_path and os.path.isfile(record.thumbnail_path):
|
||
return record.thumbnail_path
|
||
# 如果缩略图丢失,尝试重建
|
||
if record.file_path and os.path.isfile(record.file_path):
|
||
logger.info("缩略图丢失,正在重建 photo_id=%s", photo_id)
|
||
file_hash = record.file_hash or self._compute_hash(record.file_path)
|
||
new_thumb = self._generate_thumbnail(
|
||
Path(record.file_path), record.child_id, file_hash
|
||
)
|
||
if new_thumb:
|
||
self._update_thumbnail_path(photo_id, str(new_thumb))
|
||
return str(new_thumb)
|
||
return None
|
||
|
||
def get_thumbnail_bytes(self, photo_id: int) -> Optional[bytes]:
|
||
"""获取缩略图的二进制数据(供前端直接展示)"""
|
||
path = self.get_thumbnail_path(photo_id)
|
||
if path is None:
|
||
return None
|
||
return Path(path).read_bytes()
|
||
|
||
# ------------------------------------------------------------------
|
||
# 公开 API:原始图片
|
||
# ------------------------------------------------------------------
|
||
|
||
def get_photo_bytes(self, photo_id: int) -> Optional[bytes]:
|
||
"""获取原始(压缩后)图片的二进制数据"""
|
||
record = self.get_photo(photo_id)
|
||
if record is None or not os.path.isfile(record.file_path):
|
||
return None
|
||
return Path(record.file_path).read_bytes()
|
||
|
||
def get_photo_path(self, photo_id: int) -> Optional[str]:
|
||
"""获取原始图片文件路径"""
|
||
record = self.get_photo(photo_id)
|
||
if record is None or not os.path.isfile(record.file_path):
|
||
return None
|
||
return record.file_path
|
||
|
||
# ------------------------------------------------------------------
|
||
# 公开 API:更新 & 删除
|
||
# ------------------------------------------------------------------
|
||
|
||
def link_to_record(self, photo_id: int, reading_record_id: int) -> bool:
|
||
"""将照片关联到某次阅读打卡记录"""
|
||
with self._lock:
|
||
with self._get_connection() as conn:
|
||
conn.execute(
|
||
"UPDATE photos SET reading_record_id = ? WHERE id = ?",
|
||
(reading_record_id, photo_id),
|
||
)
|
||
conn.commit()
|
||
affected = conn.total_changes
|
||
return affected > 0
|
||
|
||
def unlink_from_record(self, photo_id: int) -> bool:
|
||
"""解除照片与阅读记录关联"""
|
||
with self._lock:
|
||
with self._get_connection() as conn:
|
||
conn.execute(
|
||
"UPDATE photos SET reading_record_id = NULL WHERE id = ?",
|
||
(photo_id,),
|
||
)
|
||
conn.commit()
|
||
affected = conn.total_changes
|
||
return affected > 0
|
||
|
||
def delete_photo(self, photo_id: int) -> bool:
|
||
"""
|
||
删除照片及其缩略图(数据库记录 + 物理文件)。
|
||
|
||
Returns:
|
||
True 如果成功删除,False 如果记录不存在。
|
||
"""
|
||
record = self.get_photo(photo_id)
|
||
if record is None:
|
||
return False
|
||
|
||
with self._lock:
|
||
# 先清理物理文件
|
||
for path_str in (record.file_path, record.thumbnail_path):
|
||
if path_str and os.path.isfile(path_str):
|
||
try:
|
||
os.remove(path_str)
|
||
except OSError:
|
||
logger.warning("删除文件失败: %s", path_str)
|
||
|
||
# 再删除数据库记录
|
||
with self._get_connection() as conn:
|
||
conn.execute("DELETE FROM photos WHERE id = ?", (photo_id,))
|
||
conn.commit()
|
||
|
||
logger.info("照片已删除 | id=%s | child=%s", photo_id, record.child_id)
|
||
return True
|
||
|
||
def delete_photos_by_child(self, child_id: int) -> int:
|
||
"""删除某孩子的所有照片(物理文件+记录),返回删除数量"""
|
||
records = self.get_photos_by_child(child_id, limit=999999)
|
||
count = 0
|
||
for record in records:
|
||
if self.delete_photo(record.id):
|
||
count += 1
|
||
# 清理空目录
|
||
child_dir = self._storage_root / str(child_id)
|
||
self._cleanup_empty_dirs(child_dir)
|
||
return count
|
||
|
||
def cleanup_orphan_files(self) -> Tuple[int, int]:
|
||
"""
|
||
清理孤儿文件:物理存在但数据库无记录的照片/缩略图。
|
||
|
||
Returns:
|
||
(删除的文件数, 释放的字节数)
|
||
"""
|
||
# 获取数据库中所有文件路径
|
||
with self._get_connection() as conn:
|
||
rows = conn.execute(
|
||
"SELECT file_path, thumbnail_path FROM photos"
|
||
).fetchall()
|
||
db_paths: set = set()
|
||
for row in rows:
|
||
if row["file_path"]:
|
||
db_paths.add(os.path.normpath(row["file_path"]))
|
||
if row["thumbnail_path"]:
|
||
db_paths.add(os.path.normpath(row["thumbnail_path"]))
|
||
|
||
deleted_count = 0
|
||
freed_bytes = 0
|
||
|
||
for root, _, files in os.walk(self._storage_root):
|
||
for fname in files:
|
||
fpath = os.path.normpath(os.path.join(root, fname))
|
||
if fpath not in db_paths:
|
||
try:
|
||
fsize = os.path.getsize(fpath)
|
||
os.remove(fpath)
|
||
deleted_count += 1
|
||
freed_bytes += fsize
|
||
except OSError:
|
||
pass
|
||
|
||
self._cleanup_empty_dirs(self._storage_root)
|
||
logger.info(
|
||
"孤儿文件清理完成 | 删除 %s 个文件 | 释放 %.1f KB",
|
||
deleted_count, freed_bytes / 1024,
|
||
)
|
||
return deleted_count, freed_bytes
|
||
|
||
# ------------------------------------------------------------------
|
||
# 公开 API:批量导入
|
||
# ------------------------------------------------------------------
|
||
|
||
def import_photos_batch(
|
||
self,
|
||
child_id: int,
|
||
source_paths: List[str],
|
||
reading_record_id: Optional[int] = None,
|
||
) -> List[PhotoRecord]:
|
||
"""批量导入多张照片,返回成功导入的 PhotoRecord 列表"""
|
||
results: List[PhotoRecord] = []
|
||
for path in source_paths:
|
||
try:
|
||
record = self.import_photo(child_id, path, reading_record_id)
|
||
results.append(record)
|
||
except (ValueError, FileNotFoundError, OSError) as exc:
|
||
logger.warning("批量导入跳过: %s — %s", path, exc)
|
||
logger.info("批量导入完成 | 成功 %s/%s", len(results), len(source_paths))
|
||
return results
|
||
|
||
# ==================================================================
|
||
# 核心图像处理(私有)
|
||
# ==================================================================
|
||
|
||
def _compress_image(
|
||
self,
|
||
img: Image.Image,
|
||
max_dimension: int = MAX_DIMENSION,
|
||
quality: int = JPEG_QUALITY,
|
||
) -> Tuple[BytesIO, Tuple[int, int]]:
|
||
"""
|
||
压缩图片:缩放 + RGB 转换 + JPEG 编码。
|
||
|
||
Args:
|
||
img: PIL Image 对象
|
||
max_dimension: 最大边长
|
||
quality: JPEG 质量
|
||
|
||
Returns:
|
||
(BytesIO 压缩数据, (宽度, 高度))
|
||
"""
|
||
original_mode = img.mode
|
||
|
||
# --- 尺寸缩放(保持宽高比) ---
|
||
w, h = img.size
|
||
if max(w, h) > max_dimension:
|
||
ratio = max_dimension / max(w, h)
|
||
new_w = int(w * ratio)
|
||
new_h = int(h * ratio)
|
||
img = img.resize((new_w, new_h), Image.LANCZOS)
|
||
logger.debug("尺寸缩放: %sx%s → %sx%s", w, h, new_w, new_h)
|
||
|
||
# --- 色彩模式处理 ---
|
||
# 透明通道 → 白色背景填充
|
||
if original_mode in ("RGBA", "LA", "P", "PA"):
|
||
if original_mode == "P":
|
||
img = img.convert("RGBA")
|
||
background = Image.new("RGBA" if img.mode == "RGBA" else "RGB",
|
||
img.size, (255, 255, 255, 255))
|
||
if img.mode == "RGBA":
|
||
background.paste(img, mask=img.split()[3])
|
||
else:
|
||
background.paste(img)
|
||
img = background
|
||
|
||
# 统一转为 RGB(JPEG 不支持 RGBA)
|
||
if img.mode != "RGB":
|
||
img = img.convert("RGB")
|
||
|
||
# --- JPEG 编码 ---
|
||
buf = BytesIO()
|
||
img.save(buf, format="JPEG", quality=quality, optimize=True)
|
||
buf.seek(0)
|
||
|
||
return buf, img.size
|
||
|
||
def _generate_thumbnail(
|
||
self,
|
||
image_path: Path,
|
||
child_id: int,
|
||
file_hash: str,
|
||
) -> Optional[Path]:
|
||
"""
|
||
生成正方形缩略图:居中裁剪 → 256×256 → JPEG。
|
||
|
||
Args:
|
||
image_path: 原图路径
|
||
child_id: 孩子 ID
|
||
file_hash: 文件哈希(用于命名)
|
||
|
||
Returns:
|
||
缩略图文件路径,失败返回 None
|
||
"""
|
||
thumb_dir = self._storage_root / str(child_id) / "thumbnails"
|
||
thumb_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
thumb_filename = f"{file_hash[:16]}{THUMBNAIL_SUFFIX}.jpg"
|
||
thumb_path = thumb_dir / thumb_filename
|
||
|
||
# 如果缩略图已存在且未过期,直接返回
|
||
if thumb_path.exists():
|
||
return thumb_path
|
||
|
||
try:
|
||
with Image.open(image_path) as img:
|
||
thumb = ImageOps.fit(
|
||
img,
|
||
(THUMBNAIL_SIZE, THUMBNAIL_SIZE),
|
||
method=Image.LANCZOS,
|
||
centering=(0.5, 0.5),
|
||
)
|
||
if thumb.mode in ("RGBA", "P"):
|
||
thumb = thumb.convert("RGB")
|
||
thumb.save(thumb_path, format="JPEG", quality=75, optimize=True)
|
||
logger.debug("缩略图已生成: %s", thumb_path)
|
||
return thumb_path
|
||
except (OSError, UnidentifiedImageError) as exc:
|
||
logger.error("缩略图生成失败: %s — %s", image_path, exc)
|
||
return None
|
||
|
||
# ------------------------------------------------------------------
|
||
# 辅助方法(私有)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _get_child_date_dir(self, child_id: int) -> Path:
|
||
"""获取 child_id/YYYY-MM 子目录并确保存在"""
|
||
today = date.today()
|
||
dir_path = self._storage_root / str(child_id) / today.strftime("%Y-%m")
|
||
dir_path.mkdir(parents=True, exist_ok=True)
|
||
return dir_path
|
||
|
||
@staticmethod
|
||
def _generate_filename(child_id: int, file_hash: str) -> str:
|
||
"""生成唯一文件名: {child_id}_{hash前12位}_{时间戳}.jpg"""
|
||
ts = datetime.now().strftime("%H%M%S%f")[:10]
|
||
return f"{child_id}_{file_hash[:12]}_{ts}.jpg"
|
||
|
||
@staticmethod
|
||
def _compute_hash(file_path: str) -> str:
|
||
"""计算文件的 SHA256 哈希"""
|
||
sha = hashlib.sha256()
|
||
with open(file_path, "rb") as f:
|
||
for chunk in iter(lambda: f.read(8192), b""):
|
||
sha.update(chunk)
|
||
return sha.hexdigest()
|
||
|
||
def _find_by_hash(self, child_id: int, file_hash: str) -> Optional[PhotoRecord]:
|
||
"""按哈希查找已有照片(去重)"""
|
||
with self._get_connection() as conn:
|
||
row = conn.execute(
|
||
"SELECT * FROM photos WHERE child_id = ? AND file_hash = ? LIMIT 1",
|
||
(child_id, file_hash),
|
||
).fetchone()
|
||
return self._row_to_record(row) if row else None
|
||
|
||
def _child_exists(self, child_id: int) -> bool:
|
||
"""校验孩子是否存在"""
|
||
with self._get_connection() as conn:
|
||
row = conn.execute(
|
||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
||
).fetchone()
|
||
return row is not None
|
||
|
||
def _record_exists(self, record_id: int) -> bool:
|
||
"""校验阅读记录是否存在"""
|
||
with self._get_connection() as conn:
|
||
row = conn.execute(
|
||
"SELECT 1 FROM reading_records WHERE id = ?", (record_id,)
|
||
).fetchone()
|
||
return row is not None
|
||
|
||
def _update_thumbnail_path(self, photo_id: int, path: str) -> None:
|
||
"""更新数据库中的缩略图路径"""
|
||
with self._lock:
|
||
with self._get_connection() as conn:
|
||
conn.execute(
|
||
"UPDATE photos SET thumbnail_path = ? WHERE id = ?",
|
||
(path, photo_id),
|
||
)
|
||
conn.commit()
|
||
|
||
@staticmethod
|
||
def _row_to_record(row: sqlite3.Row) -> PhotoRecord:
|
||
"""将 SQLite Row 转为 PhotoRecord 数据类"""
|
||
return PhotoRecord(
|
||
id=row["id"],
|
||
child_id=row["child_id"],
|
||
reading_record_id=row["reading_record_id"],
|
||
file_path=row["file_path"],
|
||
thumbnail_path=row["thumbnail_path"],
|
||
original_name=row["original_name"] or "",
|
||
file_size=row["file_size"] or 0,
|
||
width=row["width"] or 0,
|
||
height=row["height"] or 0,
|
||
file_hash=row["file_hash"] or "",
|
||
created_at=row["created_at"],
|
||
)
|
||
|
||
@staticmethod
|
||
def _cleanup_empty_dirs(root: Path) -> None:
|
||
"""递归删除空目录(自底向上)"""
|
||
for dirpath, _, _ in os.walk(root, topdown=False):
|
||
p = Path(dirpath)
|
||
if p != root and not any(p.iterdir()):
|
||
try:
|
||
p.rmdir()
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
# ============================================================================
|
||
# 模块自测:生成示例测试图片并验证核心功能
|
||
# ============================================================================
|
||
|
||
def _generate_sample_images() -> None:
|
||
"""
|
||
生成 photos/ 目录下的示例测试图片与缩略图。
|
||
|
||
产出:
|
||
photos/test/
|
||
├── sample_original.jpg # 原始样图 (1920×1280)
|
||
├── sample_small.jpg # 小图 (640×480)
|
||
├── sample_portrait.jpg # 竖图 (800×1200)
|
||
├── sample_ultra_large.jpg # 超大图 (4096×2160)
|
||
└── thumbnails/
|
||
├── *_thumb.jpg # 各图对应缩略图
|
||
"""
|
||
test_dir = Path(__file__).resolve().parent / "photos" / "test"
|
||
test_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
samples = {
|
||
"sample_original.jpg": (1920, 1280, "blue"),
|
||
"sample_small.jpg": (640, 480, "green"),
|
||
"sample_portrait.jpg": (800, 1200, "orange"),
|
||
"sample_ultra_large.jpg": (4096, 2160, "red"),
|
||
}
|
||
|
||
for filename, (w, h, color) in samples.items():
|
||
filepath = test_dir / filename
|
||
if filepath.exists():
|
||
continue # 不覆盖已有文件
|
||
|
||
img = Image.new("RGB", (w, h), color)
|
||
# 添加文字标注
|
||
from PIL import ImageDraw, ImageFont
|
||
draw = ImageDraw.Draw(img)
|
||
text = f"{filename}\n{w}×{h}"
|
||
# 使用默认字体
|
||
bbox = draw.textbbox((0, 0), text)
|
||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||
draw.text(
|
||
((w - tw) // 2, (h - th) // 2),
|
||
text,
|
||
fill="white",
|
||
align="center",
|
||
)
|
||
img.save(filepath, format="JPEG", quality=95)
|
||
print(f" ✓ 生成示例图: {filepath} ({w}×{h})")
|
||
|
||
# 同时生成缩略图(手动调用核心方法)
|
||
thumb_dir = test_dir / "thumbnails"
|
||
thumb_dir.mkdir(exist_ok=True)
|
||
thumb_path = thumb_dir / filename.replace(".jpg", "_thumb.jpg")
|
||
if not thumb_path.exists():
|
||
thumb = ImageOps.fit(
|
||
img, (256, 256), method=Image.LANCZOS, centering=(0.5, 0.5)
|
||
)
|
||
thumb.save(thumb_path, format="JPEG", quality=75)
|
||
print(f" ✓ 生成缩略图: {thumb_path} (256×256)")
|
||
|
||
print(f"\n示例图片生成完毕,位于: {test_dir}")
|
||
|
||
|
||
def _run_self_test() -> None:
|
||
"""执行模块自测,验证核心 API 功能"""
|
||
import tempfile
|
||
import sys
|
||
|
||
print("=" * 60)
|
||
print("PhotoService 自测")
|
||
print("=" * 60)
|
||
|
||
# 临时数据库
|
||
db_path = os.path.join(tempfile.gettempdir(), "photo_service_test.db")
|
||
storage = os.path.join(tempfile.gettempdir(), "photo_test_storage")
|
||
|
||
# 准备:建 children / reading_records 表(外键依赖)
|
||
conn = sqlite3.connect(db_path)
|
||
conn.executescript("""
|
||
CREATE TABLE IF NOT EXISTS children (
|
||
id INTEGER PRIMARY KEY, name TEXT, total_points INTEGER DEFAULT 0,
|
||
is_active INTEGER DEFAULT 1, created_at TEXT, updated_at TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS reading_records (
|
||
id INTEGER PRIMARY KEY, child_id INTEGER, book_id INTEGER,
|
||
read_date TEXT, duration_minutes INTEGER, pages_read INTEGER,
|
||
notes TEXT, created_at TEXT, FOREIGN KEY(child_id) REFERENCES children(id)
|
||
);
|
||
INSERT OR IGNORE INTO children (id, name, total_points, is_active, created_at, updated_at)
|
||
VALUES (1, '测试孩子A', 100, 1, datetime('now'), datetime('now'));
|
||
INSERT OR IGNORE INTO reading_records (id, child_id, book_id, read_date, duration_minutes, created_at)
|
||
VALUES (1, 1, 1, '2026-07-05', 30, datetime('now'));
|
||
""")
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
svc = PhotoService(db_path=db_path, storage_root=storage)
|
||
|
||
# 准备测试图片
|
||
test_img = Image.new("RGB", (1920, 1280), "purple")
|
||
img_bytes_io = BytesIO()
|
||
test_img.save(img_bytes_io, format="JPEG", quality=95)
|
||
raw_bytes = img_bytes_io.getvalue()
|
||
|
||
# --- 测试1: 从 bytes 导入 ---
|
||
print("\n[测试1] import_photo_from_bytes")
|
||
record = svc.import_photo_from_bytes(
|
||
child_id=1,
|
||
image_bytes=raw_bytes,
|
||
original_name="test_capture.jpg",
|
||
reading_record_id=1,
|
||
)
|
||
assert record is not None
|
||
assert record.child_id == 1
|
||
assert record.reading_record_id == 1
|
||
assert os.path.isfile(record.file_path)
|
||
print(f" ✓ 导入成功: id={record.id}, size={record.width}x{record.height}, "
|
||
f"hash={record.file_hash[:16]}...")
|
||
|
||
# --- 测试2: 缩略图 ---
|
||
print("\n[测试2] 缩略图生成与获取")
|
||
thumb_bytes = svc.get_thumbnail_bytes(record.id)
|
||
assert thumb_bytes is not None
|
||
assert len(thumb_bytes) > 0
|
||
# 验证缩略图尺寸
|
||
thumb_img = Image.open(BytesIO(thumb_bytes))
|
||
assert thumb_img.size == (256, 256), f"预期256×256,实际{thumb_img.size}"
|
||
print(f" ✓ 缩略图正确: {thumb_img.size}, {len(thumb_bytes)} bytes")
|
||
|
||
# --- 测试3: 重复导入去重 ---
|
||
print("\n[测试3] 哈希去重")
|
||
dup_record = svc.import_photo_from_bytes(
|
||
child_id=1, image_bytes=raw_bytes, original_name="dup.jpg"
|
||
)
|
||
assert dup_record.id == record.id, "去重失败:应返回已有记录"
|
||
print(f" ✓ 去重正确: 重复导入返回同一ID={dup_record.id}")
|
||
|
||
# --- 测试4: 查询 ---
|
||
print("\n[测试4] 查询 API")
|
||
photos = svc.get_photos_by_child(1)
|
||
assert len(photos) == 1
|
||
assert svc.get_photo_count(1) == 1
|
||
single = svc.get_photo(record.id)
|
||
assert single is not None and single.child_id == 1
|
||
print(f" ✓ 查询正常: count={svc.get_photo_count(1)}")
|
||
|
||
# --- 测试5: 关联更新 ---
|
||
print("\n[测试5] 关联/解绑记录")
|
||
svc.unlink_from_record(record.id)
|
||
updated = svc.get_photo(record.id)
|
||
assert updated is not None and updated.reading_record_id is None
|
||
svc.link_to_record(record.id, 1)
|
||
relinked = svc.get_photo(record.id)
|
||
assert relinked is not None and relinked.reading_record_id == 1
|
||
print(" ✓ 关联/解绑正常")
|
||
|
||
# --- 测试6: 存储统计 ---
|
||
print("\n[测试6] 存储空间统计")
|
||
total = svc.get_total_storage_size(child_id=1)
|
||
assert total > 0
|
||
print(f" ✓ 存储统计: {total / 1024:.1f} KB (child_id=1)")
|
||
|
||
# --- 测试7: 删除 ---
|
||
print("\n[测试7] 删除照片")
|
||
photo_id = record.id
|
||
deleted = svc.delete_photo(photo_id)
|
||
assert deleted
|
||
assert svc.get_photo(photo_id) is None
|
||
assert svc.get_photo_count(1) == 0
|
||
print(f" ✓ 删除成功: id={photo_id}")
|
||
|
||
# --- 测试8: 批量导入 + 清理 ---
|
||
print("\n[测试8] 批量导入 + 孤儿清理")
|
||
records = svc.import_photos_batch(1, [
|
||
os.path.join(os.path.dirname(__file__) or ".", "photos", "test", f)
|
||
for f in ["sample_small.jpg", "sample_portrait.jpg"]
|
||
if os.path.exists(os.path.join(
|
||
os.path.dirname(__file__) or ".", "photos", "test", f
|
||
))
|
||
])
|
||
print(f" ✓ 批量导入: {len(records)} 张")
|
||
orphan_count, _ = svc.cleanup_orphan_files()
|
||
print(f" ✓ 孤儿清理: {orphan_count} 个文件")
|
||
|
||
# --- 清理 ---
|
||
svc.delete_photos_by_child(1)
|
||
import shutil as _shutil
|
||
_shutil.rmtree(storage, ignore_errors=True)
|
||
os.remove(db_path)
|
||
|
||
print("\n" + "=" * 60)
|
||
print("全部测试通过!")
|
||
print("=" * 60)
|
||
|
||
|
||
# ============================================================================
|
||
# CLI 入口
|
||
# ============================================================================
|
||
|
||
if __name__ == "__main__":
|
||
import argparse
|
||
|
||
parser = argparse.ArgumentParser(
|
||
description="家庭阅读激励工具 - 照片管理服务 (PhotoService)",
|
||
)
|
||
sub = parser.add_subparsers(dest="command")
|
||
|
||
# 生成示例图片
|
||
sub.add_parser("sample", help="生成示例测试图片到 photos/test/ 目录")
|
||
|
||
# 运行自测
|
||
sub.add_parser("test", help="运行模块自测")
|
||
|
||
# 导入照片
|
||
import_cmd = sub.add_parser("import", help="导入照片")
|
||
import_cmd.add_argument("child_id", type=int)
|
||
import_cmd.add_argument("source", help="源图片路径")
|
||
import_cmd.add_argument("--db", default="reading_app.db")
|
||
import_cmd.add_argument("--storage", default="photos")
|
||
|
||
# 查询
|
||
list_cmd = sub.add_parser("list", help="列出某孩子的照片")
|
||
list_cmd.add_argument("child_id", type=int)
|
||
list_cmd.add_argument("--db", default="reading_app.db")
|
||
list_cmd.add_argument("--storage", default="photos")
|
||
|
||
# 删除
|
||
del_cmd = sub.add_parser("delete", help="删除照片")
|
||
del_cmd.add_argument("photo_id", type=int)
|
||
del_cmd.add_argument("--db", default="reading_app.db")
|
||
del_cmd.add_argument("--storage", default="photos")
|
||
|
||
args = parser.parse_args()
|
||
|
||
if args.command == "sample":
|
||
_generate_sample_images()
|
||
|
||
elif args.command == "test":
|
||
_generate_sample_images()
|
||
_run_self_test()
|
||
|
||
elif args.command == "import":
|
||
svc = PhotoService(db_path=args.db, storage_root=args.storage)
|
||
record = svc.import_photo(args.child_id, args.source)
|
||
print(record.to_dict())
|
||
|
||
elif args.command == "list":
|
||
svc = PhotoService(db_path=args.db, storage_root=args.storage)
|
||
for r in svc.get_photos_by_child(args.child_id):
|
||
print(f"[{r.id}] {r.original_name} | {r.width}x{r.height} | "
|
||
f"{r.file_size/1024:.1f}KB | {r.created_at}")
|
||
|
||
elif args.command == "delete":
|
||
svc = PhotoService(db_path=args.db, storage_root=args.storage)
|
||
ok = svc.delete_photo(args.photo_id)
|
||
print(f"删除{'成功' if ok else '失败(记录不存在)'}")
|
||
|
||
else:
|
||
# 默认:生成示例 + 运行自测
|
||
_generate_sample_images()
|
||
_run_self_test()
|