预算与支出的双表仓库:ArkTS 为鸿蒙预算应用设计对照结构


实例:预算管理(Budget)|技术:双表对照、分类聚合、进度计算
一、业务需求分析:预算管理的数据形态
预算管理是**「预算 vs 实际」的对照应用**——每月设定分类预算(餐饮 1500 元),记录每笔支出(奶茶 30 元),对照「花多少 vs 预多少」。数据层核心是双表结构(预算表 + 支出表)与分类聚合(某分类支出总额)。
核心业务需求:
- 月度预算:每月每分类一个预算(餐饮/交通/购物/娱乐/住房/学习);
- 支出流水:每笔支出记录(分类 + 金额 + 备注 + 时间);
- 预算进度:每分类「已花 / 预算」的百分比——支出总额 SUM 聚合;
- 超支预警:已花 > 预算的分类标红——对比判断;
- 总进度:本月总支出 / 总预算——总聚合;
- 分类明细:某分类的支出流水列表。
二、字段设计:双表对照
预算表 budget
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| category | TEXT | NOT NULL | 分类(餐饮/交通/购物…) |
| amount | REAL | NOT NULL | 预算金额(元) |
| month | TEXT | NOT NULL | 月份(‘YYYY-MM’) |
| color | TEXT | DEFAULT ‘#3B82F6’ | 分类色 |
| created_time | INTEGER | NOT NULL | 创建时间戳 |
支出表 expense
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| category | TEXT | NOT NULL | 分类(与预算表一致) |
| amount | REAL | NOT NULL | 支出金额(元) |
| note | TEXT | DEFAULT ‘’ | 备注 |
| spend_time | INTEGER | NOT NULL | 支出时间戳 |
设计要点拆解:
1. 双表结构:预算与支出分离。预算表是「计划」(每月每分类一条),支出表是「流水」(每笔一条)——一计划多流水的对照关系。为什么不分一张表?预算的粒度是「月×分类」,支出的粒度是「每笔」——粒度不同必须分表(合一张表会出现「预算行 + 支出行」混排的坏味道)。
2. 连接键是 category(文本)。两张表用分类名关联(不是外键 id)——spentByCategory(category) 查支出表 SUM。为什么不用外键?预算表主键是 id,但支出表记录「这笔属于哪个分类」比「属于哪条预算」更自然——分类是业务键(预算按月重建时 id 会变,分类名稳定)。**「用业务键关联而非外键」**是财务类应用的务实选择(预算表按月滚动重建,外键会悬空)。
3. month 用 TEXT 存 ‘YYYY-MM’。月度维度——equalTo('month', '2024-06') 按月筛选预算。月份字符串的规范格式(补零)保证字典序即时间序(‘2024-06’ < ‘2024-07’)。
4. amount 用 REAL。金额有小数(28.5 奶茶)——REAL 存元。金额精度讨论:财务严谨应用用「分」(INTEGER 存 2850)避免浮点误差;Demo 教学用元(REAL)够用。**「精度敏感用整数分」**是财务常识,Demo 简化。
5. color 预算色:每分类一个颜色(餐饮橙/交通蓝/购物粉/娱乐紫/住房绿/学习青)——进度条与图标用色区分。
6. 支出表无月份字段:spend_time 时间戳——为什么不像预算表按月?因为流水按时间戳查询(queryExpensesByCategory 按分类 + 时间倒序),明细粒度到「笔」不需要按月存。**「计划按月、流水按刻」**的粒度差异反映在字段设计。
三、建表 SQL 与索引
CREATE TABLE IF NOT EXISTS budget (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
amount REAL NOT NULL,
month TEXT NOT NULL,
color TEXT DEFAULT '#3B82F6',
created_time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS expense (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
amount REAL NOT NULL,
note TEXT DEFAULT '',
spend_time INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_budget_month ON budget (month);
CREATE INDEX IF NOT EXISTS idx_expense_time ON expense (spend_time);
idx_budget_month:按月查询预算(WHERE month = '2024-06')走索引。idx_expense_time:按时间倒序的支出列表(ORDER BY spend_time DESC)走索引。
没有外键约束:expense 表不声明 FOREIGN KEY (category) REFERENCES budget(category)——因为分类是业务键非主键,且预算按月重建。**「业务键关联不建外键约束」**是务实选择(避免重建预算时的约束冲突)。
四、BudgetDao 封装:聚合计算是核心
数据层核心 BudgetDao,最独特的是queryWithSpend(预算 + 实际支出的聚合计算):
/** 预算 + 实际支出聚合:一次算出每个分类的进度与超支状态 */
static async queryWithSpend(context: common.Context, month: string): Promise<BudgetWithSpend[]> {
const store = await BudgetDao.getStore(context);
const budgets = await BudgetDao.queryBudgets(context, month);
const list: BudgetWithSpend[] = [];
for (const b of budgets) {
const spent = await BudgetDao.spentByCategory(context, b.category);
const percent = b.amount > 0 ? Math.min(100, Math.round(spent / b.amount * 100)) : 100;
const item: BudgetWithSpend = {
budget: b,
spent: spent,
percent: percent,
overrun: spent > b.amount,
};
list.push(item);
}
return list;
}
BudgetWithSpend 接口:{ budget, spent, percent, overrun }——预算实体 + 三个派生值(已花/进度/超支)。「实体 + 聚合扩展」的返回结构:一次循环算出每个分类的全部展示数据。
spentByCategory(分类支出 SUM):
static async spentByCategory(context: common.Context, category: string): Promise<number> {
const store = await BudgetDao.getStore(context);
const result = await store.querySql(
`SELECT SUM(amount) AS total FROM ${BudgetDao.EXPENSE_TABLE} WHERE category = '${category}'`
);
let total = 0;
if (result.goToNextRow()) {
total = result.getDouble(result.getColumnIndex('total'));
}
result.close();
return total;
}
WHERE 过滤 + SUM 聚合:某分类的所有支出求和——「分类支出总额」的计算核心。空行兜底:该分类无支出时 SUM 返回 NULL(一行 NULL),goToNextRow() 命中但 total 取到 NULL?——落地用 getDouble 取 NULL 列会得 0(ResultSet 的 getDouble 对 NULL 返回 0)。**「WHERE + SUM 的条件聚合」**是「某维度总额」的通用 SQL。
percent 计算:spent / amount * 100 四舍五入 + Math.min(100, ...) 封顶——进度超过 100% 显示 100(进度条宽度封顶),超支状态由 overrun 单独表达。「进度封顶 + 超支布尔」分离:视觉(进度条)与状态(超支标红)用不同字段。
overrun 判断:spent > b.amount——已花超过预算即超支。页面据此标红(「超支!」标签 + 红色进度条)。
totalForMonth(总预算 vs 总支出):
static async totalForMonth(context: common.Context, month: string): Promise<MonthTotal> {
const store = await BudgetDao.getStore(context);
const budgets = await BudgetDao.queryBudgets(context, month);
let budgetTotal = 0;
for (const b of budgets) {
budgetTotal += b.amount;
}
const result = await store.querySql(
`SELECT SUM(amount) AS total FROM ${BudgetDao.EXPENSE_TABLE}`
);
// spendTotal 同理
const s: MonthTotal = { budgetTotal: budgetTotal, spendTotal: spendTotal };
return s;
}
总预算 = 前端循环累加(预算表逐条求和),总支出 = SQL SUM(支出表全表求和)。两种求和方式对比:总预算没写 SELECT SUM(amount) FROM budget WHERE month=...(可写),循环累加是因为 queryBudgets 已把数据取回——「已取回的数据前端累加,未取回的 SQL 聚合」。
五、技术要点对照表
| 技术点 | 实现方式 | 生产价值 |
|---|---|---|
| 双表对照 | budget + expense 分表 | 计划/流水分离 |
| 业务键关联 | category 文本关联 | 月重建不悬空 |
| 分类聚合 | WHERE + SUM | 分类支出总额 |
| 进度计算 | spent/amount + min 封顶 | 进度百分比 |
| 超支判断 | spent > amount | 预警标红 |
| 总统计 | 循环累加 + SQL SUM | 总预算/总支出 |
六、文章小结
预算管理的数据层核心是**「双表对照 + 分类聚合 + 进度计算」**:预算表(月×分类的粒度)与支出表(每笔流水)分离——一计划多流水的对照结构;用业务键(category 文本)关联而非外键(预算月重建不悬空);spentByCategory 用 WHERE + SUM 算分类支出总额;queryWithSpend 一次循环算齐「已花/进度/超支」三个派生值;总统计是「前端循环累加 + SQL SUM」的组合。这是「预算 vs 实际」类财务应用的通用数据模型。
下一篇(17-2)展示进度面板 UI——深色总预算卡 + 分类进度条 + 支出明细列表。
七、双表对照再深入:粒度差异、业务键与存储选择
1. 粒度差异:一计划对多流水
预算表与支出表最大的区别是记录粒度——「计划」的粒度是月×分类,「流水」的粒度是每笔:
| 维度 | budget 预算表 | expense 支出表 |
|---|---|---|
| 记录粒度 | 月 × 分类(每月 6 条) | 每笔流水(每月 25 条) |
| 数量级 | 少而稳定 | 多而增长 |
| 生命周期 | 每月滚动重建 | 只增不删 |
| 时间维度 | month 字段(‘YYYY-MM’) | spend_time 时间戳 |
粒度差异直接决定表结构:预算表的「一」对应支出表的「多」——本质是一对多关系。若强行合成一张表,同一分类的预算金额会随每笔支出重复出现(6 条预算 × 25 笔支出 = 150 行),既浪费存储,又让「改预算金额」变成要改多行——「一计划多流水必须分表」。
横向对照(对比 18 卡券实例):卡券管理是单表(coupon 每张券一行),因为券的粒度就是「一张」;预算管理是双表,因为计划与流水粒度天然不同——「看业务对象粒度决定表数量」,不是表越多越好。
2. 业务键 category 关联 vs 外键
两张表的关联方式是本实例最值得讨论的设计决策:
| 关联方式 | 实现 | 优点 | 缺点 |
|---|---|---|---|
| 业务键(category 文本) | 支出表存分类名 | 预算重建 id 变化不受影响;语义直白 | 分类改名需同步两表 |
| 外键(category_id) | 支出表存预算表主键 | 强一致性、可级联删除 | 预算按月重建会悬空 |
为什么本项目选业务键:预算表每月滚动重建(新月的预算行 id 全新),若支出表存外键 category_id,上月支出会指向已删除的预算行——外键悬空。而 category 分类名稳定(餐饮永远是「餐饮」),跨月聚合 WHERE category = '餐饮' 天然成立,且 spentByCategory 不依赖预算表是否存在(即使本月还没建预算,历史支出也能汇总)。「业务键稳定性 > 外键规范性」——财务流水表重关联、轻约束的务实权衡。
3. 金额存储:REAL 元 vs 整数分
// 方案 A:REAL 存元(本项目 Demo)
const amount = 28.5; // 奶茶 28.5 元
// 方案 B:INTEGER 存分(生产财务系统)
const amountFen = 2850; // 28.5 元 = 2850 分
const yuan = amountFen / 100; // 仅展示时除 100
| 方案 | 存储示例 | 精度 | 适用场景 |
|---|---|---|---|
| REAL 元 | 28.5 | 二进制浮点:28.5 无法精确表示,累加有误差 | 展示型应用、Demo 教学 |
| INTEGER 分 | 2850 | 整数精确,累加零误差 | 记账、金融、对账系统 |
浮点误差实例:0.1 + 0.2 !== 0.3——REAL 累加多笔后可能出现 1222.9999999 之类结果。本实例金额是展示型(进度条 + 文案 + 明细列表),误差不影响观感;若做对账型应用(金额必须分毫不差)必须用整数分。「展示用元、对账用分」——这是金额存储的第一原则。
4. month 月份字符串的三个设计细节
month 存 ‘YYYY-MM’ 而非时间戳,三个细节值得展开:
- 补零规范:存 ‘2024-06’ 而非 ‘2024-6’——补零保证字典序 = 时间序(‘2024-06’ < ‘2024-07’),排序与范围比较不用转类型;
- 月粒度等值:时间戳(毫秒)无法直接按「月」等值匹配,存月份字符串让
equalTo('month', '2024-06')一步到位,命中 idx_budget_month 索引; - 生成方式:
${y}-${String(m).padStart(2, '0')}——padStart(2, '0')是补零的关键 API,写成 ‘2024-6’ 就破坏了字典序语义。
八、进度计算思路预览
queryWithSpend 的进度计算可拆为三步,后续文章(17-3)逐一深化:
| 步骤 | 计算逻辑 | 技术点 |
|---|---|---|
| 1. 分类支出 | SELECT SUM(amount) FROM expense WHERE category = ? |
WHERE + SUM 条件聚合 |
| 2. 进度百分比 | Math.round(spent / amount * 100) |
除法 + 四舍五入 |
| 3. 封顶与超支 | Math.min(100, percent) + spent > amount |
视觉封顶与状态分离 |
双表联动的本质:先查预算表(计划侧)拿到当月分类列表,再逐分类查支出表(流水侧)SUM,最后在内存中「配对」成 BudgetWithSpend——两次查询 + 内存关联,比 JOIN 更直观。为什么不用 JOIN?预算按月重建、支出跨月累积,两表关联条件(month + category)随时间变化,JOIN 的表结构不稳定。进度计算的复杂度从「SQL 怎么写」转移到「ArkTS 派生值怎么算」——这正是 17-3 的核心。
九、FAQ
Q1:为什么不用 JOIN 一次查出预算和支出?
A:预算按月滚动重建,且每分类只需 SUM 总额——先查预算再逐分类 SUM,代码更直白;spentByCategory 还独立复用(删除支出后单独刷新某分类总额)。JOIN 在「计划月重建」场景下结构不稳定。
Q2:分类改名了怎么办?
A:业务键的代价——需同步两表(UPDATE expense SET category='外卖' WHERE category='餐饮')。生产系统可加分类字典表统一维护,Demo 不做。
Q3:REAL 金额的浮点误差会导致超支误判吗?
A:可能。若 spent 累加成 1222.9999999 而预算恰是 1223,spent > amount 会误判未超支。Demo 金额多为一位小数、误差极小;严谨方案:比较前 Math.round(spent * 100) / 100 归一化,或直接上整数分存储。
Q4:预算表为什么不需要支出表那样的时间索引?
A:索引跟着查询模式走——预算按 month 等值查询(idx_budget_month),支出按 spend_time 倒序列表(idx_expense_time),各建所需,不是每表必建。
更多推荐




所有评论(0)