零基础入门python21:Flask账本的数据模型------用户、分类和账目

一、上一篇课后练习讲解
上一篇要求使用环境变量并验证测试配置。参考测试通过 create_app({'TESTING': True, 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:'}) 创建临时应用,再在应用上下文中建表。生产配置永远不应写进测试文件。
上一篇课后练习完整答案
上一篇练习的要求已落实到下面完整文件;先运行项目测试,再用 curl 对照状态码和数据库持久化结果
答案要点:Config 与 TestConfig 分离,环境变量只在配置层读取,测试使用内存 SQLite,蓝图和扩展仍由 create_app 装配。
文件:app/config.py
完整参考答案文件
完整文件:app/config.py
python
import os
class Config:
APP_VERSION = os.getenv("APP_VERSION", "0.1.0")
SECRET_KEY = os.getenv("SECRET_KEY", "dev-only-change-me")
SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///ledger.db")
SQLALCHEMY_TRACK_MODIFICATIONS = False
class TestConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
完整参考答案文件
本篇对应的交付源码完整文件:flask-ledger/app/init.py
python
from flask import Flask
from .auth import bp as auth_bp
from .extensions import db, login_manager
from .ledger import bp as ledger_bp
def create_app(test_config=None):
app = Flask(__name__)
app.config.from_mapping(
SECRET_KEY="dev-change-me",
SQLALCHEMY_DATABASE_URI="sqlite:///ledger.db",
SQLALCHEMY_TRACK_MODIFICATIONS=False,
)
if test_config:
app.config.update(test_config)
db.init_app(app)
login_manager.init_app(app)
login_manager.unauthorized_handler(lambda: ({"message": "请先登录"}, 401))
app.register_blueprint(auth_bp)
app.register_blueprint(ledger_bp)
@app.get("/api/health")
def health():
return {"status": "ok"}
with app.app_context():
db.create_all()
return app
验收命令:python -m pytest -q(Django 项目使用 python manage.py test)。预期测试通过;若失败先检查迁移、配置和事务回滚。
二、本篇完成什么
建立账本的三类数据:用户拥有多个分类和账目,账目必须属于一个分类。金额用 Numeric(12, 2),不能用浮点数。

三、模型代码
python
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
class Category(db.Model):
__table_args__ = (db.UniqueConstraint('user_id', 'name'),)
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(40), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, index=True)
class Transaction(db.Model):
id = db.Column(db.Integer, primary_key=True)
kind = db.Column(db.String(10), nullable=False)
amount = db.Column(db.Numeric(12, 2), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, index=True)
category_id = db.Column(db.Integer, db.ForeignKey('category.id'), nullable=False)
联合唯一约束让同一用户不能创建两个同名分类;它必须在数据库中实现,因为两个并发请求可能同时通过 Python 的"先查询再新增"。外键保证账目不能指向不存在的用户或分类。
四、迁移与验收
教学项目先用 db.create_all() 初始化测试库;生产项目应使用 Flask-Migrate 记录迁移。运行测试后检查:负金额不会由模型自动变合法,业务层还需要校验 kind 和金额范围。课后练习:给 Transaction 增加 happened_on 日期字段,并写一个按日期查询的测试。
项目增量:三个核心模型
账本需要 User、Category、Entry 三个模型。Entry 的 user_id 是数据隔离关键,金额使用 Numeric 而不是 Float,日期字段用于月度报表。
python
class Entry(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.ForeignKey('user.id'), nullable=False, index=True)
category_id = db.Column(db.ForeignKey('category.id'), nullable=False)
amount = db.Column(db.Numeric(12, 2), nullable=False)
happened_on = db.Column(db.Date, nullable=False)
迁移后插入收入和支出,查询时必须带当前用户过滤。课后练习增加数据库约束并验证非法金额。
五、从业务名词到表结构:先画关系再写模型
账本不是"把一行 JSON 塞进数据库"。用户、分类、账目之间有明确关系:一个用户拥有多条账目和分类;一条账目可以没有分类,但不能没有所属用户。把关系写成表格后再编码,能够提前发现"删除用户时账目怎么办"这类问题。
text
user (1) ───< category (0..n)
│
└───────< entry (1..n) ─── category (0..1)
对应的模型示例(项目实际代码还包含时间字段和索引):
python
class Entry(db.Model):
__tablename__ = "entries"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
category_id = db.Column(db.Integer, db.ForeignKey("categories.id"), nullable=True)
amount = db.Column(db.Numeric(12, 2), nullable=False)
happened_on = db.Column(db.Date, nullable=False, index=True)
note = db.Column(db.String(200), nullable=False, default="")
Numeric(12, 2) 表示最多 12 位、2 位小数;金额不能用 Float,因为二进制浮点会出现 0.1 + 0.2 != 0.3 的舍入误差。user_id 加索引是因为之后的每一个列表查询都会按用户过滤。category_id 允许为空,表示用户暂时不分类,但 user_id 必须存在,这是领域规则而非界面约束。
六、迁移和回滚的最小流程
开发阶段可以使用 db.create_all(),但多人协作和生产发布必须使用迁移工具。每次模型改变都要记录"从哪个版本变到哪个版本":
powershell
flask --app run.py db init # 只在项目第一次执行
flask --app run.py db migrate -m "add entry and category"
flask --app run.py db upgrade
flask --app run.py db current
执行 migrate 后一定要打开生成的迁移文件检查 SQL;自动生成器不会理解"把字段改名"与"删掉旧字段"的业务含义。发布失败时使用 flask db downgrade -1 回退一个版本,先恢复服务,再分析迁移脚本。
七、上一篇练习的实现和数据隔离
同一用户的同名分类唯一,约束必须包含 user_id,而不是只对 name 加全局唯一:
python
class Category(db.Model):
__table_args__ = (
db.UniqueConstraint("user_id", "name", name="uq_category_user_name"),
)
API 查询也必须带用户条件:Category.query.filter_by(id=category_id, user_id=current_user.id).first_or_404()。只按 id 查出来再判断所有权,虽然看似可行,却很容易在另一个接口忘记判断;把所有权放进 SQL 条件是更可靠的默认行为。
八、验收输出与排错
powershell
python -c "from app import create_app; from app.extensions import db; app=create_app({'TESTING':True}); app.app_context().push(); print([t.name for t in db.metadata.sorted_tables])"
python -m pytest -q
预期表名包含 users、categories、entries。NoReferencedTableError 表示外键字符串与 __tablename__ 不一致;UNIQUE constraint failed 说明约束生效,接口层应把它转成 409,而不是返回 500。下篇会实现注册,并把数据库异常转换为用户能看懂的错误。
九、本篇练习
写一个 seed_demo() 函数,创建一个用户、两个分类和三条账目;函数重复执行不得产生重复数据。要求使用事务,失败时回滚。下一篇开始注册接口,会复用这个事务边界。
本篇结束:完整模块文件
下面是交付项目中真实存在的完整文件 flask-ledger/app/models.py。它覆盖本篇新增逻辑以及前文已经完成的依赖代码;复制单个函数会丢失上下文,因此这里提供整份文件。
python
from datetime import date, datetime, timezone
from decimal import Decimal
from flask_login import UserMixin
from werkzeug.security import check_password_hash, generate_password_hash
from .extensions import db, login_manager
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
def set_password(self, password: str) -> None:
self.password_hash = generate_password_hash(password)
def check_password(self, password: str) -> bool:
return check_password_hash(self.password_hash, password)
@login_manager.user_loader
def load_user(user_id: str):
return db.session.get(User, int(user_id))
class Category(db.Model):
__table_args__ = (db.UniqueConstraint("user_id", "name"),)
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(40), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
class Transaction(db.Model):
id = db.Column(db.Integer, primary_key=True)
kind = db.Column(db.String(10), nullable=False)
amount = db.Column(db.Numeric(12, 2), nullable=False)
note = db.Column(db.String(200), default="")
happened_on = db.Column(db.Date, default=date.today, nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
category_id = db.Column(db.Integer, db.ForeignKey("category.id"), nullable=False)
def to_dict(self) -> dict:
return {
"id": self.id,
"kind": self.kind,
"amount": str(Decimal(self.amount).quantize(Decimal("0.01"))),
"note": self.note,
"happened_on": self.happened_on.isoformat(),
"category_id": self.category_id,
}