Skip to content

操作指南:支出追踪 API

FT 参考:FT311(NENE2-FT/expenselog)——支出追踪:YYYY-MM-DD 日期格式校验,分类为开放字符串(无枚举),按类别月度汇总聚合,带 limit/offset 的偏移分页,PATCH 部分更新(仅更新提供的字段),日期范围过滤,静态路由 /summary 注册在动态 /{id} 之前,34 个测试 / 67 个断言全部通过。

本指南展示如何构建支持日期过滤、类别聚合、分页和部分更新的支出追踪 API。

数据库结构

sql
CREATE TABLE IF NOT EXISTS expenses (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    date        TEXT    NOT NULL,  -- YYYY-MM-DD
    amount      INTEGER NOT NULL,  -- 分为单位
    category    TEXT    NOT NULL,
    note        TEXT    NOT NULL DEFAULT '',
    created_at  TEXT    NOT NULL,
    updated_at  TEXT    NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_expenses_date     ON expenses (date);
CREATE INDEX IF NOT EXISTS idx_expenses_category ON expenses (category);

datecategory 上的索引支持快速过滤。amount 使用整数分为单位以避免浮点精度问题。

端点

方法路径描述
GET/expenses列出支出(可选过滤 + 分页)
POST/expenses创建支出
GET/expenses/summary按类别的月度汇总
GET/expenses/{id}获取单条支出
PATCH/expenses/{id}部分更新
DELETE/expenses/{id}删除

路由顺序/expenses/summary 必须在 /expenses/{id} 之前注册——否则 summary 会被捕获为 id 参数。

日期校验——YYYY-MM-DD

php
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
    $errors[] = new ValidationError('date', 'date must be in YYYY-MM-DD format.', 'invalid_format');
}

仅接受严格的 YYYY-MM-DD 格式。带时间组件或时区偏移的 ISO 8601 字符串会被拒绝。

类别——开放字符串(无枚举)

php
$category = isset($body['category']) && is_string($body['category']) && $body['category'] !== ''
    ? $body['category']
    : null;
if ($category === null) {
    $errors[] = new ValidationError('category', 'category is required.', 'required');
}

类别是自由格式字符串(非封闭枚举)。任何非空字符串均有效,允许使用 "food""transport""entertainment" 等类别,无需修改 schema。

月度汇总——YYYY-MM 格式

php
// 查询:SELECT category, SUM(amount), COUNT(*) WHERE date LIKE 'YYYY-MM-%'
$summary = $this->repository->monthlySummary($month);

return $this->json->create([
    'month'       => $summary->month,
    'total'       => $summary->total,
    'count'       => $summary->count,
    'by_category' => $summary->byCategory, // [{category, total, count}, ...]
]);

月份参数格式:YYYY-MM。聚合该月所有支出,按类别分组。

分页——基于偏移量

php
$pagination = PaginationQueryParser::parse($request);
// 返回:{ limit: int, offset: int }

$items = $this->repository->findAll($from, $to, $category, $pagination->limit, $pagination->offset);
$total = $this->repository->count($from, $to, $category);

return $this->json->create([
    'items'  => $items,
    'total'  => $total,
    'limit'  => $pagination->limit,
    'offset' => $pagination->offset,
]);

无效的 limit(非整数、负数、过大)→ 422。

PATCH——部分更新

php
// 仅更新请求体中存在的字段
$date     = array_key_exists('date', $body)     ? $body['date']     : null;
$amount   = array_key_exists('amount', $body)   ? $body['amount']   : null;
$category = array_key_exists('category', $body) ? $body['category'] : null;
$note     = array_key_exists('note', $body)     ? $body['note']     : null;

array_key_exists() 能区分"字段未提供"和"字段值为 null"。只有提供的字段才会被校验和更新。

日期范围过滤

php
// GET /expenses?date_from=2026-01-01&date_to=2026-01-31
$from = QueryStringParser::string($request, 'date_from');
$to   = QueryStringParser::string($request, 'date_to');
$category = QueryStringParser::string($request, 'category');

$items = $this->repository->findAll($from, $to, $category, $limit, $offset);

所有过滤参数均为可选。null 表示"该维度不过滤"。


反模式

反模式风险
/expenses/summary 之前注册 /expenses/{id}"summary" 被匹配为 id;summary 端点不可达
amount 存储为 FLOAT浮点精度问题:0.1 + 0.2 ≠ 0.3;应使用整数分
接受任意日期字符串(带时间的 ISO 8601)WHERE 子句中日期比较不一致
封闭类别枚举新类别需要数据库迁移
PATCH 使用 isset($body['field'])isset()null 值返回 false;应使用 array_key_exists()
count 查询与 list 查询过滤条件不同分页总数与实际过滤数量不匹配
date/category 无索引每次过滤列表请求都进行全表扫描

基于 MIT 许可证发布。