使用此 Next.js/React 应用跟踪您的支出





5.00/5 (4投票s)
Granola 是一款用于跟踪支出的 Web 应用,具有自定义类别、报告和图表。
引言
在本文中,我们将本地运行 Granola 应用,并或许激发您对其进行扩展的兴趣。
源代码可在 https://github.com/jeromevonk/granola 获取。
它做什么?
该应用旨在手动控制您的支出。以下是使用场景:
- 创建用户
- 用户创建自己的类别和子类别。
- 存储支出
- 分析支出
- 列出月度支出
- 搜索支出
- 生成有关月度/年度支出的报告和图表
概念
分类
用户必须先创建类别,然后才能创建支出。类别至少需要有一个子类别。每笔创建的支出都将与一个子类别相关联。主类别仅用于可视化目的。
费用
支出对象形式如下:
{
"year": 2022,
"month": 12,
"day": null,
"description": "Test",
"details": "via Postman",
"amountPaid": 222,
"amountReimbursed": 22,
"category": 13,
"recurring": true
}
year
(YYYY) 和month
(1-12) 必须是整数。day
可以是整数 (1-31),也可以是null
。这意味着支出发生在某个特定月份,但没有具体的日期。description
和details
(如果提供) 必须是string
。details
是可选的,可以是null
。amountPaid
必须大于0
的数字,表示支付的金额。amountReimbursed
必须是数字,但可以为0
。它用于表示您因任何原因 (保险/现金返还/他人代付) 收到的任何报销金额,而您想将其记录下来。如果不想记录,则将其保留为零。category
必须是一个代表子类别的整数。recurring
,如果为true
,则表示这是一项通常每月发生的支出。在此应用中,这将产生两种效果:- 它将在支出列表中以粗体显示。
- 用户可以选择将经常性支出复制到下个月。在执行此操作时,他们可以选择是否复制金额,或者将其设置为零并在以后进行编辑。
架构
该应用使用 Next.js 框架使用 React 构建。我们还使用 API 路由 来提供后端功能。
API
以下是各 API 端点及其简要说明。为了更全面的理解,您可以将 此文件 导入到 Postman 中。
POST /api/users/authenticate
(使用电子邮件和密码进行用户身份验证)POST /api/users/register
(创建用户)DELETE /api/users
(删除用户)GET /api/categories
(获取已登录用户的所有创建的类别)POST /api/categories
(创建新类别)PATCH /api/categories/:id
(重命名类别)DELETE /api/categories/:id
(删除类别)GET /api/expenses
(获取已登录用户的所有支出)GET /api/expenses/:year/:month
(获取特定年月的支出)GET /api/expenses/years
(获取用户创建至少一笔支出的年份列表)POST /api/expenses
(创建新支出)POST /api/expenses/recurring
(将经常性支出复制到下个月)PUT /api/expenses/:id
(编辑支出)DELETE /api/expenses
(删除一批支出)DELETE /api/expenses/:id
(删除特定支出)GET /api/stats/year-evolution
(按年份 (可选按类别) 分组的支出数据,用于图表展示)GET /api/stats/month-evolution
(按月份 (可选按类别) 分组的支出数据,用于图表展示)GET /api/stats/category-report
(按月份、年份和类别分组的支出数据,用于表格展示)
身份验证
API 身份验证是通过 JSON Web Token (JWT) 完成的。以下是流程:
- 当用户注册时,会向
/api/users/register
发送一个包含用户名和密码的请求。- 密码使用 bcrypt 进行哈希处理,并与用户名一起保存在数据库中。请注意,保存在数据库中的不是密码本身。
- 当用户登录时,他们会发送密码,该密码会被 哈希处理并与数据库中存储的密码进行比较。如果匹配,API 将返回一个 JWT。
- 在用户登录后,任何用于检索/创建/更新/删除支出或类别的 API 请求都必须在请求头中发送 JWT。
数据库
在此项目中,我们使用 PostgreSQL 数据库,具有以下表:
用户
一个非常直接的表。
为每个用户分配一个 id,并存储用户名和哈希密码。
CREATE TABLE IF NOT EXISTS public.users
(
id integer NOT NULL GENERATED ALWAYS AS IDENTITY _
( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1 ),
username character varying(20) COLLATE pg_catalog."default" NOT NULL,
hash bytea NOT NULL,
CONSTRAINT user_pkey PRIMARY KEY (id)
);
分类
在此表中,我们可以使用一种称为 邻接列表树 的策略来存储主类别和子类别。
每个类别都有自己的 id
并属于特定的 user_id
。 与 users 表存在外键约束。
子类别将有一个 parent_id
,它必须是同一表中的有效 id (也是一个外键,这次是同一表中的)。如果是主类别,则 parent_id
为 null
。
CREATE TABLE IF NOT EXISTS public.category
(
id integer NOT NULL GENERATED ALWAYS AS IDENTITY _
( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1 ),
user_id integer NOT NULL,
parent_id integer,
title character varying(25) COLLATE pg_catalog."default" NOT NULL,
CONSTRAINT unique_id UNIQUE (id),
CONSTRAINT self FOREIGN KEY (parent_id)
REFERENCES public.category (id) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT user_id FOREIGN KEY (user_id)
REFERENCES public.users (id) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE
)
请注意为约束设置了 ON UPDATE CASCADE
和 ON DELETE CASCADE
。这意味着如果删除了用户,其类别也将被删除。同样,如果删除了主类别,其子类别也将被删除。
可以使用如下简单查询来查找主类别和子类别:
/* Finding the main categories*/
SELECT *
FROM category
WHERE user_id = 1 AND parent_id IS NULL;
/* Finding sub-categories */
SELECT *
FROM category
WHERE user_id = 1 AND parent_id IS NOT NULL;
费用
此表将保存支出。它与我们之前看到的支出对象非常相似,但有一些约束。
CREATE TABLE IF NOT EXISTS public.expense
(
last_modified timestamp without time zone DEFAULT now(),
id integer NOT NULL GENERATED ALWAYS AS IDENTITY _
( INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1 ),
user_id integer NOT NULL,
category integer NOT NULL,
year smallint NOT NULL,
month smallint NOT NULL,
day smallint,
recurring boolean DEFAULT false,
amount_paid numeric(7,2) NOT NULL,
amount_reimbursed numeric(7,2) NOT NULL DEFAULT 0,
description character varying(70) COLLATE pg_catalog."default" NOT NULL,
details character varying(70) COLLATE pg_catalog."default",
CONSTRAINT expense_pkey PRIMARY KEY (id),
CONSTRAINT expense_category_fkey FOREIGN KEY (category)
REFERENCES public.category (id) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT expense_user_id_fkey FOREIGN KEY (user_id)
REFERENCES public.users (id) MATCH SIMPLE
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT amount_paid CHECK (amount_reimbursed <= amount_paid),
CONSTRAINT description CHECK (length(description::text) > 2),
CONSTRAINT valid_day CHECK (day >= 1 AND day <= 31),
CONSTRAINT valid_month CHECK (month >= 1 AND month <= 12),
CONSTRAINT valid_year CHECK (year > 2011 AND year < 2050)
)
TABLESPACE pg_default;
-- Index: year
CREATE INDEX IF NOT EXISTS year
ON public.expense USING btree
(year ASC NULLS LAST)
TABLESPACE pg_default;
-- Trigger
CREATE FUNCTION sync_lastmod() RETURNS trigger AS $$
BEGIN
NEW.last_modified := NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER
sync_lastmod
BEFORE UPDATE ON
expense
FOR EACH ROW EXECUTE PROCEDURE
sync_lastmod();
一些注意事项:
- 一个
last_modified
时间戳列。在创建支出 (DEFAULT now()
) 或更新支出 (查看触发器sync_lastmod
) 时,它会自动填充。 - 与 users 和 category 表的外键 (也带有
CASCADE
)。 - 在
year
列上创建索引,以便按年份进行高效查询。 - 列类型经过精心选择,以优化数据使用。
- 在尽可能深的级别设置约束是一个非常好的做法,以避免意外行为。使用了以下约束:
year
必须在 2011 年到 2050 年之间 (我个人情况,如果您喜欢可以更改!)。month
必须在 1 到 12 之间。day
必须在 1 到 31 之间。description
和details
限制为 70 个字符 (description 不能为空,至少需要 2 个字符)。- 关于
amount_paid
和amount_reimbursed
:- numeric(7,2) 表示 7 位有效数字,其中小数部分有 2 位。这意味着允许的最大数字是 99,999.99 (如果您需要更大的数字,请更改此设置!)
amount_paid
必须大于或等于amount_reimbursed
。
- 列的顺序很重要,尤其是在磁盘使用方面!更多细节请参阅 这个问题。
在数据库中创建约束并不意味着您不必在 API 或前端进行相同的验证 (这样做可以更快地为用户提供反馈),这是防止错误的额外一层。
支出视图
我们存储了 amount_paid
和 amount_reimbursed
。我们将在支出列表中将其展示给用户,但对于报告和图表,我们希望考虑 (amount_paid
- amount_reimbursed
)。这里的一个好方法是创建一个视图,其中只包含我们需要的列,并且每次创建或更新支出时都会自动更新。
请注意,我们创建了 amount_spent
列。
CREATE OR REPLACE VIEW public.expense_view
AS
SELECT
expense.user_id,
expense.year,
expense.month,
expense.day,
expense.category,
expense.recurring,
COALESCE(expense.amount_paid, 0::numeric) - _
COALESCE(expense.amount_reimbursed, 0::numeric) AS amount_spent
FROM expense;
例如,用于趋势图的查询将针对 expense_view
进行,如下所示:
SELECT year, month, SUM(amount_spent) as sum
FROM public.expense_view
WHERE user_id = 1
and year BETWEEN 2021 and 2022
GROUP BY year, month
ORDER BY year, month;
而用于支出列表的查询将针对 expense
表。
SELECT id, user_id, year, month, day, description, details, _
recurring, amount_paid, amount_reimbursed, last_modified
FROM public.expense
WHERE user_id = 1
AND year = 2022
ORDER BY id asc;
前端
此项目使用 Material UI 作为设计系统和 UI 工具。您可能会发现按钮、文本、输入框、日期选择器与 Google 产品中的组件非常相似。
登录/注册
这两个页面非常相似。它们都依赖于 UserPasswordForm 组件,该组件创建了一个简单的表单,包含两个输入框 (用户名和密码)。
Components
总共有 18 个可重用组件。例如:
- ExpensesTable 用于列出支出,无论是按月份列出还是按关键字搜索。
- AppBar 出现在所有页面上。
- YearPicker 在项目中多次出现。
自定义 App 组件
Next.js 使用 App 组件 来初始化页面。您可以控制页面初始化,从而允许您:
- 在页面更改之间保持布局持久化。
- 在导航时保持状态。
- 将数据注入页面。
- 添加全局 CSS。
在此项目中,我们已经完成了:
- 类别仅在用户登录时获取一次,并作为上下文提供给所有页面。这是因为用户通常不会经常更改他们的类别。我们期望他们创建一次后就很少更改。因此,我们每个登录只加载一次,除非用户对其进行更改 (在这种情况下,会进行完全重新加载)。
- 屏幕尺寸 (用于响应式布局调整) 和可见性设置 (用于隐藏值) 也提供给所有页面。
Charts
要创建趋势图表,使用了 Google Charts 和 这个库。到目前为止,只使用了柱状图。未来可能会有更多!
本地运行
首先,您必须安装 Git、Node.js 和 docker。
我们将使用 docker 启动一个 PostgreSQL 数据库实例,并使用 Node 运行项目。
那么
git clone https://github.com/jeromevonk/granola.git
- 选择一个路径供 docker 存储 PostgreSQL 数据库使用的卷,并在 docker-compose.yml 文件中设置它。
- 使用
cd granola/database; docker-compose up
启动容器。 - 如果您想使用示例用户 (user = 'sample', password = '123456')、类别和支出:
- 在 database 文件夹中,创建一个 .env.local 文件,其中包含
PG_CONNECTION_STRING=postgres://postgres:my_postgresql_password@localhost:5432/postgres
。 - 运行
./migrate_and_seed.sh
。
- 在 database 文件夹中,创建一个 .env.local 文件,其中包含
- 转到 backend-frontend 文件夹,创建一个 .env.local 文件,其中包含变量
PG_CONNECTION_STRING=postgres://postgres:my_postgresql_password@localhost:5432/postgres
和JWT_SECRET='your-own-secret'
。 - 运行
npm install
。 - 运行
npm run dev
。
代码质量和安全
作为一个开源项目,此项目利用了 SonarCloud (由 SonarQube 项目提供) 的免费产品。每次构建后,都会测量代码质量,并识别 bug、代码异味、漏洞和安全热点。还提供有关测试覆盖率和代码重复的数据。
您可以在此处查看报告。
历史
- 2022 年 10 月 3 日:初始版本
- 2022 年 12 月 12 日:添加了
SonarQube
部分。