Files
aiagent/team_projects/b9b14925-f054-43d0-b2f0-6ed6b38dbe4b/Company-context-SQLiteFlutter-React-Nati/07-photo_service.py
renjianbo f2e65a8fbb feat: virtual company module, team projects, PWA dishes app, and startup scripts overhaul
- 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>
2026-07-10 22:37:56 +08:00

1066 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
================================================================================
家庭阅读激励工具 — 照片管理服务 (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()