零基础入门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/02;TypeError 多半是缺少 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"])}