65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (4投票s)

2022 年 10 月 3 日

CPOL

7分钟阅读

viewsIcon

12410

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。这意味着支出发生在某个特定月份,但没有具体的日期。
  • descriptiondetails (如果提供) 必须是 stringdetails 是可选的,可以是 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) 完成的。以下是流程:

数据库

在此项目中,我们使用 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_idnull

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 CASCADEON 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 之间。
    • descriptiondetails 限制为 70 个字符 (description 不能为空,至少需要 2 个字符)。
    • 关于 amount_paidamount_reimbursed
      • numeric(7,2) 表示 7 位有效数字,其中小数部分有 2 位。这意味着允许的最大数字是 99,999.99 (如果您需要更大的数字,请更改此设置!)
      • amount_paid 必须大于或等于 amount_reimbursed
  • 列的顺序很重要,尤其是在磁盘使用方面!更多细节请参阅 这个问题

在数据库中创建约束并不意味着您不必在 API 或前端进行相同的验证 (这样做可以更快地为用户提供反馈),这是防止错误的额外一层。

支出视图

我们存储了 amount_paidamount_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这个库。到目前为止,只使用了柱状图。未来可能会有更多!

本地运行

首先,您必须安装 GitNode.jsdocker

我们将使用 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
  • 转到 backend-frontend 文件夹,创建一个 .env.local 文件,其中包含变量 PG_CONNECTION_STRING=postgres://postgres:my_postgresql_password@localhost:5432/postgresJWT_SECRET='your-own-secret'
  • 运行 npm install
  • 运行 npm run dev

代码质量和安全

作为一个开源项目,此项目利用了 SonarCloud (由 SonarQube 项目提供) 的免费产品。每次构建后,都会测量代码质量,并识别 bug、代码异味、漏洞和安全热点。还提供有关测试覆盖率和代码重复的数据。

您可以在此处查看报告。

历史

  • 2022 年 10 月 3 日:初始版本
  • 2022 年 12 月 12 日:添加了 SonarQube 部分。
© . All rights reserved.