在这里插入图片描述
在这里插入图片描述

实例:预算管理(Budget)|技术:双表对照、分类聚合、进度计算

一、业务需求分析:预算管理的数据形态

预算管理是**「预算 vs 实际」的对照应用**——每月设定分类预算(餐饮 1500 元),记录每笔支出(奶茶 30 元),对照「花多少 vs 预多少」。数据层核心是双表结构(预算表 + 支出表)与分类聚合(某分类支出总额)。

核心业务需求:

  1. 月度预算:每月每分类一个预算(餐饮/交通/购物/娱乐/住房/学习);
  2. 支出流水:每笔支出记录(分类 + 金额 + 备注 + 时间);
  3. 预算进度:每分类「已花 / 预算」的百分比——支出总额 SUM 聚合
  4. 超支预警:已花 > 预算的分类标红——对比判断
  5. 总进度:本月总支出 / 总预算——总聚合
  6. 分类明细:某分类的支出流水列表。

二、字段设计:双表对照

预算表 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’ 而非时间戳,三个细节值得展开:

  1. 补零规范:存 ‘2024-06’ 而非 ‘2024-6’——补零保证字典序 = 时间序(‘2024-06’ < ‘2024-07’),排序与范围比较不用转类型;
  2. 月粒度等值:时间戳(毫秒)无法直接按「月」等值匹配,存月份字符串让 equalTo('month', '2024-06') 一步到位,命中 idx_budget_month 索引;
  3. 生成方式${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),各建所需,不是每表必建。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐