“pfg”这三个字母,我最初拿到的时候也琢磨了一下。它既不是某个开源库的简称,也不是什么框架的缩写,但在我实际折腾完这个项目之后,我倾向于把它定义为Personal Finance Graph(个人财务图谱)的缩写。简单说,就是用 PostgreSQL 存数据,用 GraphQL 暴露接口,把散落在各个渠道的账单、交易记录、账户余额全部归拢到一个模型里,最终形成一个可以按时间、分类、账户维度下钻的财务关系网。
这个项目适合谁?我觉得至少有三类人值得看一下:一是被各种 Excel 台账搞得心烦、想用一个正经数据库管理家庭财务的人;二是想练手 PostgreSQL 和 GraphQL 配合的人;三是对“图关系建模”有好奇心,想看看财务数据怎么从二维表变成可查询的网状结构的人。这个项目不复杂,但涉及的核心点非常密:表结构设计、数据清洗、接口层 schema 定义、聚合查询优化,每一环都有真实场景的坑。
1. 项目整体设计与思路拆解
1.1 为什么是个人财务图谱,而不是一个简单的记账本
记账软件遍地都是,随手下一个 App 就能用,自己用 PostgreSQL 和 GraphQL 折腾一个,看上去确实是重复造轮子。但我个人认为,两者的逻辑出发点完全不同。传统记账软件是“记录每一笔收支”,而“财务图谱”是“构建实体之间的关联网络”。差别在哪?举一个实际场景:某个月你刷了一笔餐饮消费,记账本里它就是一行字符串;但在图谱模型里,这一笔消费是账户节点、商户节点、分类节点之间的边,它天然携带时间、金额、账户类型、消费类目四个维度的信息。当你拥有六个月以上的交易数据以后,图谱化的模型能回答的就不是“我上月花多少”,而是“餐饮消费和哪个账户类型的活跃度相关性更强”“信用卡账单日和日常支出的关系如何”这类更有价值的问题。
这就决定了技术选型的方向:核心是关系型数据库加灵活的查询接口。PostgreSQL 负责存储和复杂聚合,GraphQL 负责把数据以“图”的方式暴露给前端或分析脚本。我并没有在这个项目里引入真正的图数据库,因为财务数据本质依然是结构化表格,关系型数据库加上外键和索引完全能够支撑,而且 PostgreSQL 在聚合统计上的能力远超一般图数据库,没必要为了“图”而强行上 Neo4j。
1.2 为什么选 PostgreSQL 而不选 MySQL 或 SQLite
这是我在搭建初期反复纠结的问题。SQLite 零配置,单文件跑起来特别快,但它的并发写入能力和数据完整性约束都比较弱。MySQL 社区版功能稳定,但在处理 JSON 字段、递归查询这类现代应用需求时,开发体验明显不如 PostgreSQL。个人财务图谱有几个特殊需求:多账户同时导入数据、涉及递归的树形分类结构(比如“餐饮-咖啡-便利店”三级分类)、需要存储一些半结构化的原始账单 JSON。PostgreSQL 的一整套能力几乎是为这些场景准备的:WITH RECURSIVE处理树形分类很顺手,jsonb能原样保留银行账单里的附加字段,再加上GENERATED ALWAYS AS这种生成列,可以直接把原始字段加工成可查询的派生字段。
另一个决定性因素是 PG 的物化视图。在财务图谱场景中,一个高频指标是“每月的账户余额快照”,由几千条交易聚合而来。如果没有物化视图,每次 Dashboard 请求都会触发全量聚合,数据量到几万条以后明显卡顿。PG 的物化视图可以像普通表一样被索引,月底刷一次,查询速度能控制在几十毫秒级别。
1.3 GraphQL 在这个项目里的角色定位
为什么接口层选 GraphQL 而不是传统 REST?财务图谱天然是一个“从某个节点出发,沿关系遍历”的查询场景。REST 接口的写法是固定端点,比如/api/transactions?account=xxx,一个页面需要多个字段组就得多调几次,或者专门为前端写一堆视图接口。GraphQL 则允许前端按需声明数据形状:一个查询里就能带上账户信息、近半年的交易列表、分类统计,后端只需要维护一套 schema 和对应的 resolver,改动接口字段不需要频繁发版本。
我实际使用下来的体会是:GraphQL 最大的收益不是减少请求数量,而是“查询语义和前端数据结构完全对齐”。写 Dashboard 的同学不用再去 API 文档里翻字段,直接看 schema 就能知道能查什么。这个项目里我用了最朴素的express-graphql,没有上 Apollo Federation 那套重东西,因为单服务、单数据库的场景根本不需要那么多分布式能力。
2. 核心细节解析与实操要点
2.1 账户、交易、分类三张核心表的设计
任何一个财务系统都绕不开这三张核心表:账户表、交易表、分类表。我在这个项目里把这套结构做了比较细致的拆分。
账户表(accounts)是最上层的数据边界,它的作用是告诉系统“数据从哪来”。我设计了这几个关键字段:
CREATE TABLE accounts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, type TEXT NOT NULL CHECK (type IN ('bank_card', 'credit_card', 'cash', 'investment')), initial_balance NUMERIC(15, 2) NOT NULL DEFAULT 0, opened_at DATE, closed_at DATE, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );这里有两个在实操过程中很有价值的细节。第一个是type字段加了CHECK约束,而不是用普通的枚举,这样未来要扩展账户类型时,只需要修改约束条件,数据库迁移的成本比新增枚举值低。第二个是initial_balance,很多人设计账户表会忽略这个字段,但假如你2023年开户时有3000元现金,如果不记录这个值,所有历史账户余额都会算错,必须有一笔真实余额起点。
交易表(transactions)是全系统的核心,我保留了原始数据里大量字段,但真正参与查询和计算的其实只有几个:
CREATE TABLE transactions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, category_id UUID REFERENCES categories(id), trans_date DATE NOT NULL, amount NUMERIC(15, 2) NOT NULL, description TEXT, raw_data JSONB, tx_hash TEXT UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );amount字段的温度决定了交易的方向约束。我在应用层约定:收入为正数、支出为负数,而不是设计一个direction字段。原因是财务统计中大量场景需要SUM(amount)直接得出净流出还是净流入,如果拆成类型再加负号,查询语句会变得啰嗦且容易出错。
tx_hash这个字段是很多人在项目初期容易忽略的。银行账单里一般都有交易流水号,我把“账户 ID + 交易时间 + 金额 + 流水号”拼接后做哈希写入这个字段。它的作用在第三节“账单导入”里非常关键,直接决定你导数据会不会出现重复账单。
分类表(categories)采用了树形设计:
CREATE TABLE categories ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), parent_id UUID REFERENCES categories(id), name TEXT NOT NULL, level INTEGER NOT NULL DEFAULT 1 );二级分类树就够了,比如“餐饮”下面挂“咖啡”“外卖”“正餐”。在 PG 里用递归查询可以把任意一级分类的 ID 扩展成全部后代 ID,这个写法我后面单独讲。
2.2 生成列和视图,让统计数据整洁可查
PostgreSQL 12 之后对生成列(Generated Columns)的支持非常实用。我在交易表上添加了一个“净金额”的衍生字段,把不同类型账户的手续费规则统一换算成实际入账金额。用传统做法,这个逻辑得写在应用代码里,不同的导入模块各写一套,很容易出现同一笔交易在不同地方算出的结果不一致。用生成列解决:
ALTER TABLE transactions ADD COLUMN net_amount NUMERIC(15,2) GENERATED ALWAYS AS ( CASE WHEN amount > 0 THEN amount - COALESCE(fee_amount, 0) ELSE amount END ) STORED;这个字段在查询时不需要关心费用规则如何变化,因为数据库已经保证它永远和基础数据一致。同理,月度账户余额快照这类高频查询,我建了物化视图:
CREATE MATERIALIZED VIEW account_balance_snapshots AS SELECT account_id, date_trunc('month', trans_date) AS month, SUM(initial_balance + COALESCE(net_amount, 0)) AS balance FROM transaction_log GROUP BY account_id, date_trunc('month', trans_date);每月月底刷新一次,Dashboard 上展示的余额曲线基本就是秒开。
2.3 树形分类的递归查询技巧
分类表是树形结构,查询“餐饮大类下的所有消费”时,需要把餐饮这个 ID 扩展成“餐饮、外卖、咖啡、正餐、食堂”等等子节点集合。PostgreSQL 处理这个非常顺手:
WITH RECURSIVE category_tree AS ( SELECT id FROM categories WHERE name = '餐饮' UNION ALL SELECT c.id FROM categories c INNER JOIN category_tree ct ON c.parent_id = ct.id ) SELECT * FROM transactions WHERE category_id IN (SELECT id FROM category_tree);实际使用中,这个查询在几万条交易数据上表现极好,毫秒级返回。这个能力在 MySQL 8.0 里也有,但在 SQLite 中写起来就很别扭,这也是我最终坚定选择 PG 的原因之一。
3. 实操过程与核心环节实现
3.1 环境的初始化,几个被忽略的参数
我在一台普通的 Linux 开发机上安装 PostgreSQL 16 和 Node.js 20。初始化时有两个细节值得注意:
第一个是数据库的timezone。如果你直接使用默认时区,账单日期和统计日期很容易偏移一天。我统一设置了Asia/Shanghai,这样所有符合 ISO 标准的日期字符串都会被正确解析为本地时间,不会出现“28号的账单被算到27号”的乌龙。
第二个是default_transaction_read_only和连接池的大小。个人项目不需要搞太复杂,但建议至少把max_connections调成 200 左右,因为 GraphQL 的 resolver 并发请求会消耗多个连接,默认的 100 也够用,但测试时容易撞到“FATAL: sorry, too many clients already”这种报错。
3.2 用 GraphQL Schema 表达图谱节点
接口层的设计是整个项目里最有意思的部分。我的 GraphQL Schema 核心是这样定义的:
type Account { id: ID! name: String! type: String! initialBalance: Float! transactions(startDate: Date, endDate: Date): [Transaction!]! totalIn(dateRange: DateRangeInput): Float! totalOut(dateRange: DateRangeInput): Float! } type Transaction { id: ID! date: Date! amount: Float! description: String category: Category account: Account! } type Category { id: ID! name: String! parent: Category children: [Category!]! totalSpent(dateRange: DateRangeInput): Float! } type Query { accounts: [Account!]! transactions( dateRange: DateRangeInput accountIds: [ID!] categoryIds: [ID!] keyword: String ): [Transaction!]! categories: [Category!]! }这里第三行transactions(startDate, endDate): [Transaction!]!是我最看重的部分。财务图谱的价值不在于存储,而在于从任意节点出发都能顺藤摸瓜。比如我点一个“信用卡”账户,就能直接看到这个账户下某个时间段内的所有交易;点一个“餐饮”分类,就能看到它的所有子分类的花销。REST API 要实现同样的效果,列表接口和详情接口往往要换两套 url 去请求,而 GraphQL 一个 Query 就搞定了。
3.3 账单导入与去重:数据质量的生死线
账单导入是个人财务项目里最容易被低估的部分。银行导出的 CSV 格式五花八门,有的用 UTF-8,有的用 GBK,有的表头还带合并单元格,直接照搬官方文档大概率会踩坑。
我的做法是统一封装了一个导入脚本,核心步骤是:
- 把原始文件读进内存,编码强制转换成 UTF-8,反正数据量不大,一次性读入即可。
- 根据不同的银行模板提取交易日期、金额、描述、交易流水号,映射到标准交易模型。
- 利用
tx_hash保证幂等:每次导入前先查一次tx_hash,如果已经存在直接跳过。 - 对金额做格式校验,避免“1,000.00”被错误解析成 1.0。
实际过程中,去重逻辑是我花时间最多的部分。部分银行 CSV 的流水号格式不同,甚至有的没有流水号字段。这时就得拼接“账户ID + 日期 + 金额 + 描述”做哈希,但描述里可能带前缀或空格,要先清洗掉。踩过这个坑之后,我总结出一条经验:永远不要把唯一性寄托在原始文件上,要在你的系统里生成一个不可变标识符。
下面这段代码是把一行 CSV 数据转换成tx_hash的逻辑,用 Python 写会更简单,但如果你希望全链路用 JS,也可以参考:
const crypto = require('crypto'); function buildTxHash({ accountId, date, amount, description, rawId }) { const normalized = [ accountId, date, Number(amount).toFixed(2), String(description || '').trim().replace(/\s+/g, ' '), String(rawId || '').trim() ].join('|'); return crypto.createHash('sha256').update(normalized).digest('hex'); }3.4 聚合查询与常见统计指标
数据进库之后,最重要的事情就是把它变成可以被消费的信息。我在项目初期常用的三个查询是:月度收支总览、分类支出排名、账户余额变化趋势。
月度收支总览的 SQL 很直接:
SELECT to_char(date_trunc('month', trans_date), 'YYYY-MM') AS month, SUM(CASE WHEN net_amount >= 0 THEN net_amount ELSE 0 END) AS income, SUM(CASE WHEN net_amount < 0 THEN ABS(net_amount) ELSE 0 END) AS expense FROM transactions WHERE trans_date >= '2023-01-01' AND trans_date < '2024-01-01' GROUP BY date_trunc('month', trans_date) ORDER BY month;这个查询能跑得很快,主要靠trans_date上的索引。但注意一点:如果你用date_trunc('month', trans_date)做分组,PG 的普通索引不生效。解决方式有两种:一是把分组字段加进物化视图,二是给交易表建一个表达式索引:
CREATE INDEX idx_txn_month ON transactions ((date_trunc('month', trans_date)));这也是个人项目中常见索引设计误区。没有索引表几千条无所谓,到了几万条,查询就是秒级和十秒级的差别了。
分类支出排名的递归查询在 2.3 节已经展示过,这里补充一个细节:使用IN子查询时,PG 优化器可能选择索引扫描,也可能选择临时表聚合,数据量大时最好改成JOIN形式,能让分析引擎更聪明地决定执行计划:
WITH RECURSIVE category_tree AS ( SELECT id FROM categories WHERE id = $1 UNION ALL SELECT c.id FROM categories c INNER JOIN category_tree ct ON c.parent_id = ct.id ) SELECT c.name, SUM(t.net_amount) AS total FROM transactions t JOIN category_tree ct ON t.category_id = ct.id JOIN categories c ON c.id = ct.id GROUP BY c.name ORDER BY total DESC;4. 常见问题与排查技巧实录
4.1 时区导致的日期错位,问题比想象中隐蔽
第一次导入账单时,我发现所有日期都比银行 APP 里的少一天。排查了半天,问题不在导入脚本,而在数据库时区设置。开发机上 PG 默认时区是UTC,会导致TIMESTAMPTZ类型的展示结果是 UTC 时间,而本地时间其实是第二天凌晨。这里给新手一个建议:除非你有明确的多时区分析需求,否则把 PG 时区统一到本地时区是最稳妥的。修改方法很简单:
ALTER DATABASE your_db SET timezone TO 'Asia/Shanghai';顺带一提,如果你在 Python 或 Node 端用了new Date('2024-03-01')这类写法,它解析结果是 UTC 零点,在显示时又被本地化,也会导致日期偏移。规范做法是:存储侧统一用TIMESTAMPTZ,查询时再按会话时区转换,避免应用层二次处理日期字符串。
4.2 GraphQL N+1 查询:必须解决的问题
GraphQL 的 resolver 模型天生容易产生 N+1 查询。比如一次请求accounts列表,每个账户又要求它最近三笔交易,如果你的 resolver 里直接写for each account -> query transactions,在 100 个账户下就会产生 101 次数据库查询。这个问题百发百中,我在第一次用 GraphQL 就撞上了。
解决方案不少,最常用的是 DataLoader 批处理。我在这里的简化做法是:在Account.transactionsresolver 里先收集所有账户 ID,再批量查询交易,最后按账户 ID 分组返回。代码大概是这样:
const accounts = await db.query('SELECT * FROM accounts'); const accountIds = accounts.map(a => a.id); const rows = await db.query( 'SELECT * FROM transactions WHERE account_id = ANY($1) AND trans_date >= $2', [accountIds, startDate] ); const grouped = rows.reduce((acc, row) => { (acc[row.account_id] = acc[row.account_id] || []).push(row); return acc; }, {}); return accounts.map(acc => grouped[acc.id] || []);实际效果是:查询次数从 N+1 次变成了 2 次固定查询,Dashboard 的响应时间肉眼可见地降下来了。这个坑是 GraphQL 从入门到进阶的必考题目,越早意识到越好。
4.3 账单数据中“金额带空格/逗号”的预处理
不同银行导出金额的格式五花八门:有的带千分位逗号,有的金额前后有空格,有的甚至用全角数字符号。导入时如果直接parseFloat,那小数点错位的问题绝对会死死咬住你不放。
我的方案是先做正则清洗,只提取数字、小数点和负号:
function normalizeAmount(input) { return parseFloat( String(input) .replace(/,/g, '') .replace(/\s+/g, '') .replace(/[^\d.\-]/g, '') ); }然后和银行侧的原始数据做交叉验证,比如金额不能超过预设的单笔上限、支出金额不能是正数等。这类规则校验虽然朴素,但在多次导入不同银行账单之后,能省掉大量人工核对时间。
4.4 分类映射:按关键词自动匹配,兼顾人工修正
账单里的描述“某某餐厅”“某某咖啡店”“某某超市”,要自动归类到“餐饮”“购物”等等,不能完全靠人工一条条填。我做了一个简单但有效的关键词映射表,配置项大致是这样:
{ "餐饮": ["咖啡", "面馆", "烧烤", "餐厅", "外卖", "茶馆"], "购物": ["超市", "便利店", "天猫", "京东", "拼多多"], "交通": ["公交", "地铁", "加油", "停车", "打车"] }导入脚本扫描描述字段,按数组顺序做子串匹配,命中即写入分类 ID。注意匹配顺序有讲究:把更具体的词放在前面,比如“外卖”应排在“餐厅”前面,因为某连锁餐厅的外卖订单描述里可能同时包含“餐厅”和“外卖”。一条规则跑不通时,宁可匹配不到也不要误分类,因为误分类在统计里比缺失更难发现。对于匹配不到的交易,我专门整理了一个待处理列表,定期人工补录。
5. 工具选型与性能调优的心得
5.1 为什么用 Drizzle ORM 而不是 TypeORM
Node 生态里操作 PG 的 ORM 有不少选择,Prisma 太笨重,TypeORM 的文档和类型支持一言难尽。在这个项目中我选了 Drizzle,它是轻量的、类型安全的、完全基于 SQL 的 ORM。对于财务图表这种需要精确控制 SQL 执行计划的场景,Drizzle 不会像 Prisma 那样在中间层生成很多“魔力 SQL”。它更像是一个“类型安全的 SQL 模板库”,你写什么都清楚,执行什么也清楚。
Drizzle 查询示例,比如获取某时间段内各账户的净流入:
import { desc } from 'drizzle-orm'; const result = await db.select({ accountName: accounts.name, totalIn: sql<number>`SUM(CASE WHEN ${transactions.netAmount} >= 0 THEN ${transactions.netAmount} ELSE 0 END)` }) .from(transactions) .leftJoin(accounts, eq(transactions.accountId, accounts.id)) .where(and( gte(transactions.date, startDate), lt(transactions.date, endDate) )) .groupBy(accounts.name) .orderBy(desc(sql`total_in`));Drizzle 最大的优点是:你看得懂它生成的原生 SQL,也方便排查性能问题。这在个人项目中非常重要,因为没有人帮你兜底,所有异常都得自己看懂底层数据到底发生了什么。
5.2 索引到底该怎么加,加多少
给交易表加索引,我总结出一个经验:不要试图索引所有字段。在个人财务数据量级别(十万条以下),最有效的三个索引是:
(account_id, trans_date)复合索引:支撑账户维度的时间范围查询。(category_id, trans_date)复合索引:支撑分类维度的支出分析。(trans_date)单列索引:支撑全局时间统计。
同时,每个索引都会拖慢写入速度。在批量导入场景下,索引过多会导致大量随机写,导入一晚的账单数据可能要等好几秒。导入期间我会临时禁用不必要的索引,导入完成再重建:
ALTER TABLE transactions DISABLE TRIGGER ALL; -- 批量导入 ALTER TABLE transactions ENABLE TRIGGER ALL;注意DISABLE TRIGGER ALL在高权限账号下可用,实际项目里建议只对具体的索引做DROP和重建,避免影响外键约束。导入完成后重建索引:
CREATE INDEX CONCURRENTLY idx_txn_account_date ON transactions (account_id, trans_date);5.3 用物化视图做月度快照,刷新策略怎么定
前面提过account_balance_snapshots物化视图。但物化视图有个特性:它是静态数据,需要手动刷新。我的做法是在月底最后一天跑一个定时任务触发刷新:
0 2 1 * * psql -d mydb -c "REFRESH MATERIALIZED VIEW CONCURRENTLY account_balance_snapshots;"刷新语句里的CONCURRENTLY很关键。如果不用这项,刷新视图时整个表会被锁住,Dashboard 此时访问到的是“卡住”的视图,体验非常差。而CONCURRENTLY允许旧数据可读,新数据重建完成后自动切换。代价是刷新慢一点,但在个人项目的数据量下完全无感。
5.4 给前端的日期范围参数设计,尽量简单
GraphQL schema 里我用DateRangeInput表示时间范围参数,这个选择很简单但很统一:
input DateRangeInput { start: Date! end: Date! }前端调用时,后端统一做左闭右开区间处理(即包含开始日期,不包含结束日期),并将end自动加一天。这样从根源上杜绝了“月末数据算到哪一天”的歧义。这是一个看起来无关紧要、实际影响所有统计口径的设计决策。
6. 常见问题速查与避坑清单
为了方便参考,我把个人项目中遇到的典型问题整理成一张速查表:
| 问题现象 | 排查思路 | 解决方案 |
|---|---|---|
| 某月统计比银行少若干笔 | 检查trans_date时区偏移 | 设置数据库时区为本地时区 |
| 导入重复交易 | 缺少唯一标识或哈希逻辑不包含关键字段 | 用tx_hash做幂等校验 |
| GraphQL 列表接口响应慢 | 触发 N+1 查询 | 批量查询后按外键分组返回 |
| 分类树查询不出来 | 递归 CTE 终止条件写错 | 检查parent_id外键是否指向自身 |
| 物化视图数据不更新 | 忘记刷新或刷新时锁表 | 使用 CONCURRENTLY 并配置定时任务 |
| 日期范围数据不准 | 应用层和数据库层时区不一致 | 统一在数据库层做时区转换 |
有一件事需要特别提醒:任何线上的财务数据,动手之前先备份。我折腾这个项目的过程中,有好几次试错都依赖备份才避免重头再来。另外,账单导入过程可能在排错中反复执行多次,设计好幂等逻辑后再写批量导入流程,不要急着一口气导入全量数据。
我个人在实际操作中还有一个体会:这类项目最容易崩溃的环节不是技术而是数据清洗。GraphQL 写得再漂亮、数据库再快,如果账单数据源鱼龙混杂,最终统计结果还是会出问题。所以建议从第一天就建立一个人工复核流程,例如每月底把系统统计和银行 APP 的总额对比一次,发现差异及时定位,等数据积累多了再回头找问题,排查成本会大幅上升。
7. 数据可视化与日常使用建议
7.1 图表层不必放在 GraphQL 里,保持简洁转化
这个项目做到后期,我用 Grafana 做了可视化。为什么不直接用前端框架内置图表组件?因为个人项目没必要为几个柱状图引入一整套前端工程。Grafana 直接连接 PostgreSQL,把数据源配置好,面板里写 SQL 即可。它内置丰富的可视化组件,支持时间序列、饼图、表格,而且自带权限控制,手机上也能随手查看,很适合日常使用。
连接 Grafana 和 PG 时要注意:不要把写入数据库账号交给 Grafana,单独建一个只读账号最稳妥。这样即使面板配置出错,也不会误改财务数据。示例:
CREATE ROLE grafana_reader WITH LOGIN PASSWORD 'another_strong_password'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO grafana_reader;之后在 Grafana 数据源里填写这个账号,Dashboard 只读跑数。我用这套方式已经稳定运行了很久,从未出现过误操作写坏生产数据的问题。
7.2 月度复盘,我自己的习惯做法
数据落库之后,我每个月做一次实际用途的复盘。做法是跑几个固定 SQL:
- 月度总支出和总收入,半年趋势一眼看全。
- 按分类汇总“大头支出”,看看哪几类失控占比最大。
- 按账户类型汇总“资金净流向”,判断信用卡和储蓄卡之间的资金调动。
这三个查询下来基本能掌握家庭财务全貌。背后的数据模型如果不提前建好,光靠 Excel 做这一步,大概要花费一个上午的时间。现在用这套系统,一小时内基本可以完成一次复盘。
7.3 延伸:把“图谱”扩展到更多数据源
财务图谱这个名字一开始只围绕银行账单,但实操后我发现,这套模型可以轻松扩展到其他个人数据源。比如把基金持仓数据接入账户表,把周期性固定支出(房租、水电、订阅服务)打上标签,甚至把现金流水和数字钱包交易一起汇总。架构上不需要大改动,还是那三张表加 GraphQL 的 schema 扩充,但可以覆盖的生活场景一下子就丰富起来了。
不过有一点要清楚:不要贪大。先把一套账单从导入、清洗、查询、展示跑到顺,再考虑增加数据源。否则多个数据源同时出问题,排查成本会成倍上升。我最初打算一周内把信用卡、储蓄卡、支付宝、微信四个渠道全部接进来,后来发现真要同时维护四个导入脚本和清洗规则,工作量远超过预期。最后改成先跑通两个渠道,稳定后再逐批增加,反而整体推进得更顺利。
8. 写在最后的实操体会
回头来看,这个 PFG 项目对我个人的价值并不仅仅是造了一个“记账系统”。它让我把 PostgreSQL 的递归查询、物化视图、生成列、时区处理这些散碎知识点揉进了一个真实场景里,也让 GraphQL 的 schema 设计和 resolver 拆解不再停留在 Demo 阶段。做这类个人项目最有意思的地方在于:数据都是真实发生的,需求的复杂度是从实际账单里长出来的,而不是某教程虚拟出来的标准场景。
如果还只是在看文档、没上手写,我强烈建议先去银行导出几个月的历史账单,把数据导进去跑一遍。过程中碰到的各种非预期格式、时间偏移、分类映射问题,比看十篇教程都管用。这套项目的完整代码和使用说明,我已经整理好放在文档库里,如果您有兴趣,可以从 PostgreSQL 初始化脚本和导入模块开始看,循序渐进跑通全流程。