2. 需求分析与功能规划

好,咱们进入第二章。上一章我们把项目架子搭起来了,现在该往里面填最核心的东西——数据库。

做图片管理系统,说白了就是管两件事:文件存哪儿 和 文件信息记在哪儿。文件存磁盘或对象存储,信息存数据库。这一章我们就搞定后者。

2.1 先想清楚:我们要存哪些信息?

别急着写代码,先拿纸笔列一列。我个人习惯是,先画个思维导图,把一张图片从上传到被使用的全生命周期过一遍。

核心思路: 一张图片在系统里,除了文件本身,还需要记录「它是谁上传的」「什么时候上传的」「多大」「什么格式」「被哪些文章引用了」「有没有被处理过」等等。这些信息就是数据库表的字段来源。

我列一下最基础的需求:

  • 文件标识:唯一 ID、原始文件名、存储路径
  • 文件属性:文件大小、MIME 类型、宽高尺寸
  • 时间线:创建时间、更新时间
  • 归属:上传用户 ID、所属相册或分组
  • 状态:是否被删除(软删除)、是否公开
  • 扩展信息:图片描述、标签、MD5 哈希(用于去重)

嗯,这里要注意:MD5 哈希 是我在项目中吃过亏才加上的。有一次用户批量上传,同一个文件传了 5 遍,磁盘空间直接爆了。从那以后,我所有文件系统都会在入库前计算哈希,先查重再落盘。

2.2 表结构设计:一张核心表就够了?

初期我们只需要一张主表 images。但别小看这一张表,字段设计的好坏直接决定后面好不好扩展。

我见过很多新手把字段类型乱用,比如用 VARCHAR 存文件大小,用 TEXT 存宽高。这不行。数据库设计要「对得起数据类型」。

字段名 类型 约束 说明
id Integer Primary Key, Auto Increment 自增主键,内部标识
uuid String(36) Unique, Not Null, Index 对外暴露的唯一标识,防止 ID 遍历
original_name String(255) Not Null 用户上传时的原始文件名
storage_path String(512) Not Null, Unique 文件在存储系统中的相对路径
file_size BigInteger Not Null 文件字节数,用 BigInteger 防止大文件溢出
mime_type String(50) Not Null 如 image/jpeg, image/png
width Integer Nullable 图片宽度(像素),非图片文件可为空
height Integer Nullable 图片高度(像素)
md5_hash String(32) Index 文件 MD5,用于去重和完整性校验
description Text Nullable 图片描述或备注
is_deleted Boolean Default False 软删除标记
is_public Boolean Default True 是否公开可见
uploader_id Integer Foreign Key, Index 关联用户表(后续章节实现)
created_at DateTime Not Null, Default now 记录创建时间
updated_at DateTime Not Null, On Update now 记录最后更新时间

小技巧: 为什么不用 id 直接对外暴露?你想想看,如果用户知道图片 ID 是自增的,他完全可以遍历 ID 下载所有图片。用 UUID 对外,既保证了唯一性,又防止了信息泄露。我在第一个版本就踩过这个坑,后来全部重构了。

2.3 用 SQLAlchemy 把表变成 Python 代码

表设计好了,接下来用 SQLAlchemy 的 ORM 把它翻译成 Python 类。ORM 的好处是,你操作数据库就像操作普通对象一样,不用写原生 SQL。

先安装依赖:

pip install sqlalchemy pymysql

然后创建模型文件 app/models/image.py:

from datetime import datetime
import uuid
from sqlalchemy import (
    Column, Integer, String, BigInteger, Text,
    Boolean, DateTime, ForeignKey, Index
)
from sqlalchemy.orm import declarative_base

Base = declarative_base()

class Image(Base):
    __tablename__ = 'images'

    id = Column(Integer, primary_key=True, autoincrement=True)
    uuid = Column(
        String(36), unique=True, nullable=False, index=True,
        default=lambda: str(uuid.uuid4())
    )
    original_name = Column(String(255), nullable=False)
    storage_path = Column(String(512), nullable=False, unique=True)
    file_size = Column(BigInteger, nullable=False)
    mime_type = Column(String(50), nullable=False)
    width = Column(Integer, nullable=True)
    height = Column(Integer, nullable=True)
    md5_hash = Column(String(32), index=True)
    description = Column(Text, nullable=True)
    is_deleted = Column(Boolean, default=False)
    is_public = Column(Boolean, default=True)
    uploader_id = Column(Integer, ForeignKey('users.id'), index=True)
    created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
    updated_at = Column(
        DateTime, nullable=False,
        default=datetime.utcnow,
        onupdate=datetime.utcnow
    )

    def __repr__(self):
        return f"<Image(uuid='{self.uuid}', name='{self.original_name}')>"

这里有几个细节我想强调一下:

  • uuid 字段的默认值:我用 lambda: str(uuid.uuid4()) 而不是直接传 uuid.uuid4()。为什么?因为直接传的话,所有新记录的 UUID 会在 Python 启动时一次性生成,变成同一个值。用 lambda 可以保证每次创建新对象时重新生成。
  • BigInteger 存文件大小:图片文件动辄几十 MB,用 Integer 最大只能存 2GB 左右,不够用。BigInteger 可以存到 9.22EB,放心。
  • onupdate 参数:这个参数会在每次更新记录时自动把当前时间写进去,省得你手动维护 updated_at。

注意: 生产环境中 datetime.utcnow 建议配合 server_default 和 server_onupdate 使用,让数据库自己管理时间,避免应用服务器时间不一致导致的问题。这里为了演示清晰,先用 Python 端处理。

2.4 建立数据库连接与初始化

模型写好了,得有个地方初始化数据库连接。我习惯在 app/database.py 里统一管理:

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session
from app.config import Config

engine = create_engine(
    Config.SQLALCHEMY_DATABASE_URI,
    pool_size=10,
    max_overflow=20,
    echo=False  # 开发时可设为 True 查看 SQL 日志
)

SessionLocal = scoped_session(sessionmaker(bind=engine))

def init_db():
    """创建所有表结构"""
    from app.models.image import Base
    Base.metadata.create_all(bind=engine)

def get_db():
    """获取数据库会话(用于依赖注入)"""
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

然后在 app/config.py 里配置数据库连接字符串:

import os

class Config:
    # 优先从环境变量读取,方便部署
    DB_HOST = os.getenv('DB_HOST', 'localhost')
    DB_PORT = os.getenv('DB_PORT', '3306')
    DB_USER = os.getenv('DB_USER', 'root')
    DB_PASSWORD = os.getenv('DB_PASSWORD', 'password')
    DB_NAME = os.getenv('DB_NAME', 'image_manager')

    SQLALCHEMY_DATABASE_URI = (
        f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}"
        f"@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4"
    )

个人经验: 连接池的 pool_size 和 max_overflow 要根据并发量调整。我之前一个项目没配连接池,高并发时数据库连接数直接打满,服务挂了。一般 Web 应用 pool_size=10, max_overflow=20 起步,后续用压测数据调优。

2.5 验证一下:跑通创建表的流程

写个简单的脚本测试:

# scripts/init_db.py
import sys
sys.path.append('..')

from app.database import init_db, engine
from app.models.image import Base

if __name__ == '__main__':
    print("正在创建数据库表...")
    Base.metadata.create_all(bind=engine)
    print("完成!")

    # 查看已创建的表
    inspector = inspect(engine)
    tables = inspector.get_table_names()
    print(f"当前数据库中的表:{tables}")

运行后,如果看到 ['images'] 输出,说明表创建成功。你可以用 MySQL 客户端连上去看看,字段类型、约束、索引都应该和我们设计的一致。

2.6 本章知识结构图

下面这张图把本章的核心逻辑串起来了,从需求分析到表设计,再到 ORM 实现和初始化,一目了然:

数据库设计与模型创建 · 知识结构 1. 需求分析 2. 字段设计 3. ORM 建模 需求清单 文件标识 / 属性 / 时间线 归属 / 状态 / 扩展信息 MD5 去重 / 软删除 images 表字段 id / uuid / original_name storage_path / file_size mime_type / width / height SQLAlchemy declarative_base Column / 类型 约束 / 索引 4. 数据库连接与初始化 5. 创建表 · 验证通过 核心原则:先设计再编码,字段类型对号入座,索引按需添加,软删除留后路

2.7 避坑指南 & 最佳实践

最后,分享几个我在实际项目中踩过的坑,希望能帮你省点时间:

  • 字段类型别凑合:文件大小用 Integer 存?等你遇到 2.1GB 的图片就傻眼了。宽高用 VARCHAR 存?查询时没法做数值比较。类型选错,后面改表结构代价很大。
  • 索引不是越多越好:每个字段都加索引,写入性能会急剧下降。一般只在 uuid、md5_hash、uploader_id、created_at 这些高频查询字段上加索引就够了。
  • 软删除 vs 硬删除:我建议默认用软删除(is_deleted 标记)。用户误删了图片,你还能从回收站捞回来。等确认数据确实不需要了,再跑定时任务物理删除。
  • 字符集用 utf8mb4:MySQL 的 utf8 最多存 3 字节的 Unicode,存不了 emoji 和生僻字。用 utf8mb4 才能兼容所有字符。连接字符串里加上 ?charset=utf8mb4。