零基础入门python25:新增账目——Decimal、日期和输入校验

零基础入门python25:新增账目------Decimal、日期和输入校验

一、上一篇课后练习讲解

删除分类前应查询是否存在 Transaction.category_id == category_id 的记录;有记录返回 409,避免账目出现悬空分类。真正生产项目还应使用外键保护。

上一篇课后练习完整答案

上一篇练习的要求已落实到下面完整文件;先运行项目测试,再用 curl 对照状态码和数据库持久化结果

答案要点:分类更新查询同时带 category_id 与 current_user.id;名称长度和联合唯一冲突分别返回 400/409。

文件:app/ledger.py

完整参考答案文件

完整文件:app/ledger.py

python 复制代码
from flask import Blueprint, request
from flask_login import login_required, current_user
from sqlalchemy.exc import IntegrityError
from .extensions import db
from .models import Category
bp = Blueprint("categories", __name__, url_prefix="/api/categories")
@bp.put("/<int:category_id>")
@login_required
def update(category_id):
    row = Category.query.filter_by(id=category_id, user_id=current_user.id).first()
    if row is None:
        return {"error": "not_found"}, 404
    name = str((request.get_json(silent=True) or {}).get("name", "")).strip()
    if not 1 <= len(name) <= 30:
        return {"error": "name_invalid"}, 400
    row.name = name
    try:
        db.session.commit()
    except IntegrityError:
        db.session.rollback()
        return {"error": "duplicate_name"}, 409
    return {"id": row.id, "name": row.name}

完整参考答案文件

本篇对应的交付源码完整文件:flask-ledger/app/ledger.py

python 复制代码
from datetime import date
from decimal import Decimal, InvalidOperation

from flask import Blueprint, request
from flask_login import current_user, login_required
from sqlalchemy import func

from .extensions import db
from .models import Category, Transaction

bp = Blueprint("ledger", __name__, url_prefix="/api")


def owned_category(category_id: int):
    return db.session.scalar(
        db.select(Category).where(Category.id == category_id, Category.user_id == current_user.id)
    )


@bp.get("/categories")
@login_required
def list_categories():
    rows = db.session.scalars(
        db.select(Category).where(Category.user_id == current_user.id).order_by(Category.name)
    ).all()
    return [{"id": row.id, "name": row.name} for row in rows]


@bp.post("/categories")
@login_required
def create_category():
    name = str((request.get_json(silent=True) or {}).get("name", "")).strip()
    if not 1 <= len(name) <= 40:
        return {"message": "分类名称长度应为1到40"}, 400
    exists = db.session.scalar(
        db.select(Category).where(Category.user_id == current_user.id, Category.name == name)
    )
    if exists:
        return {"message": "分类已存在"}, 409
    row = Category(name=name, user_id=current_user.id)
    db.session.add(row)
    db.session.commit()
    return {"id": row.id, "name": row.name}, 201


@bp.post("/transactions")
@login_required
def create_transaction():
    data = request.get_json(silent=True) or {}
    try:
        amount = Decimal(str(data.get("amount", "0"))).quantize(Decimal("0.01"))
        happened_on = date.fromisoformat(str(data.get("happened_on", date.today())))
        category_id = int(data.get("category_id"))
    except (InvalidOperation, ValueError, TypeError):
        return {"message": "金额、日期或分类格式不正确"}, 400
    kind = str(data.get("kind", ""))
    if kind not in {"income", "expense"} or amount <= 0:
        return {"message": "类型或金额不正确"}, 400
    if not owned_category(category_id):
        return {"message": "分类不存在"}, 404
    row = Transaction(
        kind=kind, amount=amount, note=str(data.get("note", ""))[:200],
        happened_on=happened_on, category_id=category_id, user_id=current_user.id,
    )
    db.session.add(row)
    db.session.commit()
    return row.to_dict(), 201


@bp.get("/transactions")
@login_required
def list_transactions():
    page = max(request.args.get("page", 1, type=int), 1)
    size = min(max(request.args.get("size", 10, type=int), 1), 100)
    query = db.select(Transaction).where(Transaction.user_id == current_user.id)
    if kind := request.args.get("kind"):
        query = query.where(Transaction.kind == kind)
    if category_id := request.args.get("category_id", type=int):
        query = query.where(Transaction.category_id == category_id)
    rows = db.session.scalars(
        query.order_by(Transaction.happened_on.desc(), Transaction.id.desc())
        .offset((page - 1) * size).limit(size)
    ).all()
    return {"page": page, "size": size, "items": [row.to_dict() for row in rows]}


@bp.delete("/transactions/<int:transaction_id>")
@login_required
def delete_transaction(transaction_id: int):
    row = db.session.scalar(
        db.select(Transaction).where(
            Transaction.id == transaction_id, Transaction.user_id == current_user.id
        )
    )
    if not row:
        return {"message": "账目不存在"}, 404
    db.session.delete(row)
    db.session.commit()
    return "", 204


@bp.get("/statistics/monthly")
@login_required
def monthly_statistics():
    month = request.args.get("month", date.today().strftime("%Y-%m"))
    try:
        start = date.fromisoformat(month + "-01")
    except ValueError:
        return {"message": "月份格式应为YYYY-MM"}, 400
    end = date(start.year + (start.month == 12), 1 if start.month == 12 else start.month + 1, 1)
    rows = db.session.execute(
        db.select(Transaction.kind, func.sum(Transaction.amount))
        .where(Transaction.user_id == current_user.id,
               Transaction.happened_on >= start, Transaction.happened_on < end)
        .group_by(Transaction.kind)
    ).all()
    totals = {"income": Decimal("0"), "expense": Decimal("0")}
    totals.update({kind: Decimal(total) for kind, total in rows})
    return {"month": month, "income": str(totals["income"]),
            "expense": str(totals["expense"]),
            "balance": str(totals["income"] - totals["expense"])}

验收命令:python -m pytest -q(Django 项目使用 python manage.py test)。预期测试通过;若失败先检查迁移、配置和事务回滚。

二、本篇完成什么

实现新增收入/支出接口。金额从 JSON 字符串转换为 Decimal,日期解析为 date,分类必须属于当前用户。

三、为什么金额使用 Decimal

python 复制代码
from decimal import Decimal, InvalidOperation


def parse_amount(value: object) -> Decimal:
    """从客户端输入得到两位小数,拒绝空值、负数和非法文本。"""
    try:
        amount = Decimal(str(value)).quantize(Decimal('0.01'))
    except (InvalidOperation, ValueError):
        raise ValueError('金额格式不正确')
    if amount <= 0:
        raise ValueError('金额必须大于0')
    return amount

不要先转换成 float 再转换 Decimal,否则浮点误差已经发生。JSON 中金额推荐使用字符串,例如 "35.50"

四、验收请求

powershell 复制代码
curl -b cookies.txt -X POST http://127.0.0.1:5000/api/transactions -H "Content-Type: application/json" -d "{\"kind\":\"expense\",\"amount\":\"35.50\",\"category_id\":1,\"note\":\"午餐\"}"

正确请求返回 201 和 "amount":"35.50";金额为 0、kind 为未知值、分类属于其他用户时分别返回 400、400、404。课后练习:增加 happened_on 的未来日期限制。

项目增量:账目新增的金额和日期校验

金额使用 Decimal,表单层负责友好提示,模型和数据库约束负责最后防线;日期使用 date 而不是不受控的字符串。

python 复制代码
amount = Decimal(form.amount.data).quantize(Decimal('0.01'))
if amount <= 0:
    raise ValidationError('金额必须大于 0')
entry = Entry(user_id=current_user.id, category_id=category.id,
              amount=amount, kind=form.kind.data,
              happened_on=form.happened_on.data)
db.session.add(entry)
db.session.commit()

提交失败必须 rollback;不能把浮点金额先写入数据库再期待 Numeric 自动修正。课后练习增加收入、支出、非法日期和跨用户分类四个测试。

五、为什么金额必须走 Decimal

HTTP JSON 中的 12.30 到达 Python 后可能是 float,如果直接参与计算会出现不可见的小数误差。项目约定:进入服务层立即转为 Decimal,写入数据库使用 Numeric,输出时再格式化为两位小数。

python 复制代码
from decimal import Decimal, InvalidOperation
from datetime import date

def parse_amount(raw) -> Decimal:
    """把用户输入转换为两位小数;任何非法输入都转成统一业务错误。"""
    try:
        value = Decimal(str(raw)).quantize(Decimal("0.01"))
    except (InvalidOperation, ValueError, TypeError):
        raise ValueError("amount_invalid")
    if value <= 0 or value > Decimal("99999999.99"):
        raise ValueError("amount_out_of_range")
    return value

def parse_happened_on(raw: str) -> date:
    try:
        value = date.fromisoformat(raw)
    except (TypeError, ValueError):
        raise ValueError("date_invalid")
    if value > date.today():
        raise ValueError("date_in_future")
    return value

注意 Decimal(str(raw)) 而不是 Decimal(raw):前者先把浮点的展示值转成字符串,避免把二进制尾数带进金额。日期采用 ISO 8601(YYYY-MM-DD),好处是可以直接排序,也能被 JavaScript 和数据库一致解析。

六、事务内创建账目

python 复制代码
@ledger_bp.post("/entries")
@login_required
def create_entry():
    data = request.get_json(silent=True) or {}
    try:
        amount = parse_amount(data.get("amount"))
        happened_on = parse_happened_on(data.get("happened_on"))
    except ValueError as exc:
        return {"error": str(exc)}, 400
    category = None
    if data.get("category_id") is not None:
        category = Category.query.filter_by(
            id=data["category_id"], user_id=current_user.id
        ).first()
        if category is None:
            return {"error": "category_not_found"}, 404
    row = Entry(user_id=current_user.id, category=category,
                amount=amount, happened_on=happened_on,
                note=str(data.get("note", "")).strip()[:200])
    db.session.add(row)
    db.session.commit()
    return {"id": row.id, "amount": format(row.amount, ".2f")}, 201

先查分类的所有权,再创建账目;如果只验证分类存在,就会出现跨用户挂载分类的越权。note 截断是最后一道保护,前端仍要显示剩余长度。后续还会在模型层加入 CheckConstraint,让脚本导入数据时也遵守规则。

七、验收与排错

powershell 复制代码
curl -Method Post http://127.0.0.1:5000/api/ledger/entries `
  -ContentType application/json -WebSession $s `
  -Body '{"amount":"12.30","happened_on":"2025-01-02","note":"咖啡"}'

响应中金额应为字符串 "12.30"date.fromisoformat 报错时检查是否传了 2025/01/02TypeError 多半是缺少 amount;500 则查看数据库列类型和迁移是否已执行。

八、上一篇练习讲解与本篇练习

上一篇分类更新需要处理同名冲突,做法仍是"按当前用户过滤 + 捕获唯一约束"。本篇练习:实现 PATCH /entries/<id>,只能修改自己的账目;增加金额为 0、超过上限、未来日期、非法日期四组测试。下一篇将把这些账目按页查询,并解释 offset 分页的性能代价。

本篇结束:完整模块文件

下面是交付项目中真实存在的完整文件 flask-ledger/app/ledger.py。它覆盖本篇新增逻辑以及前文已经完成的依赖代码;复制单个函数会丢失上下文,因此这里提供整份文件。

python 复制代码
from datetime import date
from decimal import Decimal, InvalidOperation

from flask import Blueprint, request
from flask_login import current_user, login_required
from sqlalchemy import func

from .extensions import db
from .models import Category, Transaction

bp = Blueprint("ledger", __name__, url_prefix="/api")


def owned_category(category_id: int):
    return db.session.scalar(
        db.select(Category).where(Category.id == category_id, Category.user_id == current_user.id)
    )


@bp.get("/categories")
@login_required
def list_categories():
    rows = db.session.scalars(
        db.select(Category).where(Category.user_id == current_user.id).order_by(Category.name)
    ).all()
    return [{"id": row.id, "name": row.name} for row in rows]


@bp.post("/categories")
@login_required
def create_category():
    name = str((request.get_json(silent=True) or {}).get("name", "")).strip()
    if not 1 <= len(name) <= 40:
        return {"message": "分类名称长度应为1到40"}, 400
    exists = db.session.scalar(
        db.select(Category).where(Category.user_id == current_user.id, Category.name == name)
    )
    if exists:
        return {"message": "分类已存在"}, 409
    row = Category(name=name, user_id=current_user.id)
    db.session.add(row)
    db.session.commit()
    return {"id": row.id, "name": row.name}, 201


@bp.post("/transactions")
@login_required
def create_transaction():
    data = request.get_json(silent=True) or {}
    try:
        amount = Decimal(str(data.get("amount", "0"))).quantize(Decimal("0.01"))
        happened_on = date.fromisoformat(str(data.get("happened_on", date.today())))
        category_id = int(data.get("category_id"))
    except (InvalidOperation, ValueError, TypeError):
        return {"message": "金额、日期或分类格式不正确"}, 400
    kind = str(data.get("kind", ""))
    if kind not in {"income", "expense"} or amount <= 0:
        return {"message": "类型或金额不正确"}, 400
    if not owned_category(category_id):
        return {"message": "分类不存在"}, 404
    row = Transaction(
        kind=kind, amount=amount, note=str(data.get("note", ""))[:200],
        happened_on=happened_on, category_id=category_id, user_id=current_user.id,
    )
    db.session.add(row)
    db.session.commit()
    return row.to_dict(), 201


@bp.get("/transactions")
@login_required
def list_transactions():
    page = max(request.args.get("page", 1, type=int), 1)
    size = min(max(request.args.get("size", 10, type=int), 1), 100)
    query = db.select(Transaction).where(Transaction.user_id == current_user.id)
    if kind := request.args.get("kind"):
        query = query.where(Transaction.kind == kind)
    if category_id := request.args.get("category_id", type=int):
        query = query.where(Transaction.category_id == category_id)
    rows = db.session.scalars(
        query.order_by(Transaction.happened_on.desc(), Transaction.id.desc())
        .offset((page - 1) * size).limit(size)
    ).all()
    return {"page": page, "size": size, "items": [row.to_dict() for row in rows]}


@bp.delete("/transactions/<int:transaction_id>")
@login_required
def delete_transaction(transaction_id: int):
    row = db.session.scalar(
        db.select(Transaction).where(
            Transaction.id == transaction_id, Transaction.user_id == current_user.id
        )
    )
    if not row:
        return {"message": "账目不存在"}, 404
    db.session.delete(row)
    db.session.commit()
    return "", 204


@bp.get("/statistics/monthly")
@login_required
def monthly_statistics():
    month = request.args.get("month", date.today().strftime("%Y-%m"))
    try:
        start = date.fromisoformat(month + "-01")
    except ValueError:
        return {"message": "月份格式应为YYYY-MM"}, 400
    end = date(start.year + (start.month == 12), 1 if start.month == 12 else start.month + 1, 1)
    rows = db.session.execute(
        db.select(Transaction.kind, func.sum(Transaction.amount))
        .where(Transaction.user_id == current_user.id,
               Transaction.happened_on >= start, Transaction.happened_on < end)
        .group_by(Transaction.kind)
    ).all()
    totals = {"income": Decimal("0"), "expense": Decimal("0")}
    totals.update({kind: Decimal(total) for kind, total in rows})
    return {"month": month, "income": str(totals["income"]),
            "expense": str(totals["expense"]),
            "balance": str(totals["income"] - totals["expense"])}
相关推荐
北风toto1 小时前
阿里云 MaxCompute通过python脚本调用odps流程和执行sql
python·阿里云·odps
qq_161111271 小时前
Pillow项目深度解析:Python图像处理的终极武器
图像处理·python·pillow·开源库·历史解析
梅孔立1 小时前
Pi-Agent 终极极简配置文档(Java+Vue+Python爬虫 4-5千文件项目)
java·vue.js·python
李可以量化1 小时前
Redis Client 从了解到精通(二)下:String 类型进阶操作全解
redis·git·python·量化交易·qmt
leisoo80971 小时前
财报舞弊预警系统实战用Python挖掘应收存货异常因子 IG50免费开源股票数据API接口
开发语言·python
cui_ruicheng2 小时前
LangChain 应用开发(十二):Agent 中间件与内置中间件
人工智能·python·中间件·langchain
zx_741484812 小时前
【Python 入门】Python 正则表达式零基础讲解:基础语法 + 元字符 + 常用案例
python·正则表达式
weixin199701080162 小时前
《大促护航:电商API限流与降级,双11峰值500万调用的架构复盘》(附Python源码)
python·架构·wpf
2601_957883842 小时前
2026年8月:华硕笔记本维修相关信息
python·电脑