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

零基础入门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

预期表名包含 userscategoriesentriesNoReferencedTableError 表示外键字符串与 __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,
        }
相关推荐
hy.z_7771 小时前
【软件测试】8. 自动化测试实践
python
楠楠子呀1 小时前
从零构建高性能 WhatsApp 链接生成器与重定向服务:架构与路由解析
redis·python·容器·架构·flink·散列表·flume
OPEN-F1 小时前
Python进阶教程:算法与数据结构入门
开发语言·python
薛定猫AI2 小时前
【技术干货】Claude Code多模型代理与反馈闭环:Python实现可验证的AI编程工作流
开发语言·python·ai编程
每天一道题2 小时前
从 async/await 到幂等恢复:把 Python 并发和 Agent Runtime 一次讲清楚
分布式·python
一只积极向上的小咸鱼2 小时前
pytorch 与资源核算
人工智能·pytorch·python
reasonsummer2 小时前
【办公类110-08】20260807园园通-“小班“户籍地址和居住地址补充完整+外省市所在“省市区”两个按钮手工填写(Python+EXCEL)
python·excel·园园通
whcyhhh2 小时前
头歌实践教学平台:数据科学与大数据技术导论(五)
大数据·数据库·python