鸿蒙实战:图书借阅管理——借阅状态机与逾期计算(上)



实例:图书借阅管理(Book Library)|技术:书籍/借阅双表、LEFT JOIN 视图查询、逾期天数计算、事务借还
一、业务需求分析
1.1 图书借阅的核心矛盾
图书管理是家庭书房、校园图书馆、社区书吧最常见的数字化需求。业务核心是两件事:书在不在(状态)和书借给了谁、何时还(记录)。这构成了一个比快递包裹更微妙的建模问题:
- 书有"在馆/借出中"两种状态——这是现状快照,但快照本身不回答"谁借的、什么时候该还";
- 借阅有"借出/归还"两类事件——这是历史记录,但历史里最新一条才决定书的当前状态。
所以图书系统天然需要两表:book(书的档案与状态)+ borrow(借阅记录)。这与 26 快递包裹的"主表 + 时间线"同构,但语义更细:借阅记录不止记录状态,还携带借阅人、应还日期两个业务字段。
1.2 功能清单
| 编号 | 功能 | 技术要点 |
|---|---|---|
| 1 | 新书入库(书名/作者/ISBN/位置) | INSERT,状态默认在馆 |
| 2 | 状态筛选(全部/在馆/借出中) | equalTo + Tab |
| 3 | 借出(选书 + 填借阅人) | 事务:更新状态 + 插记录,应还=借出+30天 |
| 4 | 归还 | 事务:更新状态 + 回填 return_time |
| 5 | 逾期提示(借出中且超应还日期) | 时间戳差值计算 |
| 6 | 借阅历史(含书名,LEFT JOIN) | 跨表 JOIN 查询 |
| 7 | 统计(总藏书/在馆/借出/逾期) | 条件聚合 + 子查询 |
| 8 | 删除书籍(借出中禁止删除) | 业务规则校验 |
1.3 与 26 快递包裹的对照
| 维度 | 26 快递包裹 | 27 图书借阅 |
|---|---|---|
| 主表 | parcel(包裹档案+状态) | book(书籍档案+状态) |
| 从表 | parcel_trace(状态时间线) | borrow(借阅记录) |
| 从表特点 | 每行 = 状态节点,纯事件 | 每行 = 一次借还周期,含业务字段 |
| 状态语义 | 5 态流转 | 2 态(在馆/借出) |
| 特色能力 | 状态机事务流转 | 逾期计算 + 借阅历史 JOIN |
理解差异很重要:快递时间线记录状态怎么变,借阅记录记录谁借走、什么时候还。后者是从表承载业务字段、主表只存二值状态的简化模型。
二、字段设计表
2.1 主表 book(书籍档案)
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| title | TEXT | NOT NULL | 书名 |
| author | TEXT | DEFAULT ‘’ | 作者 |
| isbn | TEXT | DEFAULT ‘’ | ISBN 编号 |
| location | TEXT | DEFAULT ‘’ | 摆放位置(如 科幻区 A-1) |
| status | INTEGER | NOT NULL DEFAULT 0 | 0 在馆可借 / 1 借出中 |
| created_at | INTEGER | NOT NULL | 入库时间戳(毫秒) |
2.2 从表 borrow(借阅记录)
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| book_id | INTEGER | NOT NULL | 逻辑外键 → book.id |
| borrower | TEXT | NOT NULL | 借阅人 |
| borrow_time | INTEGER | NOT NULL | 借出时间戳(毫秒) |
| due_time | INTEGER | NOT NULL | 应还时间戳(毫秒) |
| return_time | INTEGER | DEFAULT 0 | 归还时间戳(0 表示未归还) |
2.3 设计要点详解
要点一:status 二值化,从表承载"借给了谁"。
快递包裹的 status 有 5 种状态,需要时间线表记录每次流转;图书只有"在馆/借出中"两态,主表 status 就够了。但"谁借的、何时该还"必须落在 borrow 表——这正是快照与细节分离的体现:主表存最小状态,从表存完整业务。
要点二:due_time 预计算而非查询时计算。
应还时间 = 借出时间 + 30 天。这里选择入库时算好存下来(due_time),而不是查询时 borrow_time + 30d 现算。理由:
- 不同书籍可能有不同借阅周期(小说 15 天、教材 30 天),存字段支持差异化;
- 逾期判断只需
due_time < now,一次比较搞定; - 未来调整规则不影响历史记录(历史按当时规则计算)。
要点三:return_time 用 0 表示未归还。
与 signed_time 的约定一致:0 即"无",避免 NULL 判空。WHERE return_time = 0 即可筛出所有未归还记录,return_time > 0 筛出已归还。SQLite 里 NULL 用 IS NULL 判断,而 0 可以参与任何比较运算,约定俗成。
要点四:删除保护——业务规则放在 DAO。
“借出中的书不能删除"是业务规则。实现位置有两个选择:放页面(弹窗时判断)或放 DAO。本实例放在 DAO 的 deleteBook 里——数据完整性规则属于数据层,页面只负责提示。页面拿返回值 false 就 toast"该书借出中,不能删除”。
三、建表 SQL
CREATE TABLE IF NOT EXISTS book (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT DEFAULT '',
isbn TEXT DEFAULT '',
location TEXT DEFAULT '',
status INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS borrow (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id INTEGER NOT NULL,
borrower TEXT NOT NULL,
borrow_time INTEGER NOT NULL,
due_time INTEGER NOT NULL,
return_time INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_book_status ON book (status);
CREATE INDEX IF NOT EXISTS idx_borrow_book ON borrow (book_id);
CREATE INDEX IF NOT EXISTS idx_borrow_return ON borrow (return_time);
索引分析:
idx_book_status:状态 Tab 的WHERE status=?高频命中;idx_borrow_book:按书查借阅记录(列表页组装视图、归还时查最新记录)的关联入口;idx_borrow_return:WHERE return_time = 0筛未归还(逾期统计、删除保护检查)。
四、BookDao 数据访问层
4.1 实体接口与常量
export interface Book {
id: number;
title: string;
author: string;
isbn: string;
location: string;
status: number; // 0 在馆 / 1 借出中
createdAt: number;
}
export interface BorrowRecord {
id: number;
bookId: number;
borrower: string;
borrowTime: number;
dueTime: number;
returnTime: number; // 0 未归还
}
/** 书籍 + 借阅状态视图(列表页用) */
export interface BookView {
book: Book;
borrower: string;
dueTime: number;
overDays: number; // 逾期天数
}
/** 借阅记录 + 书名视图(历史页用) */
export interface BorrowView {
record: BorrowRecord;
title: string;
author: string;
}
export const BORROW_DAYS = 30;
两个 View 接口是页面与 DAO 的契约:列表页拿 BookView(已含逾期计算),历史页拿 BorrowView(已含书名)。页面不拼 SQL、不查表,只消费 DAO 返回的成品数据结构。
4.2 借出:事务双写 ★核心方法
static async borrow(context: common.Context, bookId: number, borrower: string, days: number): Promise<void> {
const store = await BookDao.getStore(context);
await store.beginTransaction();
try {
// 1. 书籍置为借出中
const bookValues: relationalStore.ValuesBucket = { status: 1 };
const bookPredicates = new relationalStore.RdbPredicates(BookDao.TABLE);
bookPredicates.equalTo('id', bookId);
await store.update(bookValues, bookPredicates);
// 2. 新增借阅记录(应还 = 借出 + 天数)
const now = Date.now();
const borrowValues: relationalStore.ValuesBucket = {
book_id: bookId, borrower: borrower,
borrow_time: now, due_time: now + days * 86400000, return_time: 0,
};
await store.insert(BookDao.BORROW_TABLE, borrowValues);
await store.commit();
} catch (e) {
await store.rollBack();
throw new Error(`借出失败: ${JSON.stringify(e)}`);
}
}
借出的两步写:① book.status 置 1;② borrow 表插入新记录。如果第①步成功、第②步失败,会出现"书标了借出中,但查不到谁借的"——所以必须事务。
due_time 的计算:now + days * 86400000。86400000 是一天的毫秒数,days 参数化支持不同借阅周期,页面传 BORROW_DAYS(30)。
4.3 归还:先查最新记录再回填 ★核心方法
static async returnBook(context: common.Context, bookId: number): Promise<void> {
const store = await BookDao.getStore(context);
await store.beginTransaction();
try {
// 1. 书籍置为在馆
const bookValues: relationalStore.ValuesBucket = { status: 0 };
const bookPredicates = new relationalStore.RdbPredicates(BookDao.TABLE);
bookPredicates.equalTo('id', bookId);
await store.update(bookValues, bookPredicates);
// 2. 回填最新未归还记录的 return_time
const record = await BookDao.latestBorrow(context, bookId);
if (record !== null && record.returnTime === 0) {
const borrowValues: relationalStore.ValuesBucket = { return_time: Date.now() };
const borrowPredicates = new relationalStore.RdbPredicates(BookDao.BORROW_TABLE);
borrowPredicates.equalTo('id', record.id);
await store.update(borrowValues, borrowPredicates);
}
await store.commit();
} catch (e) {
await store.rollBack();
throw new Error(`归还失败: ${JSON.stringify(e)}`);
}
}
归还比借出多一步:要知道是哪条借阅记录在还。latestBorrow 查该书最新一条(按 borrow_time 倒序 LIMIT 1),returnTime === 0 时回填。为什么用"最新一条"而非"任一条未归还"?正常流程下同一本书不会重复借出(借出中不能再次借出),所以最新一条必然就是未归还那条;用最新 + 判空双保险,防御历史脏数据。
4.4 删除保护
static async deleteBook(context: common.Context, id: number): Promise<boolean> {
const store = await BookDao.getStore(context);
// 检查是否有未归还记录
const checkPredicates = new relationalStore.RdbPredicates(BookDao.BORROW_TABLE);
checkPredicates.equalTo('book_id', id).equalTo('return_time', 0);
const checkResult = await store.query(checkPredicates);
const activeCount = checkResult.rowCount;
checkResult.close();
if (activeCount > 0) {
return false; // 借出中,不允许删除
}
const predicates = new relationalStore.RdbPredicates(BookDao.TABLE);
predicates.equalTo('id', id);
await store.delete(predicates);
return true;
}
返回 boolean 而非 void:false 表示"删除被业务规则阻止"。页面据此提示不同文案。注意:这里也顺带保证了删除的书没有未归还记录,不会产生孤儿借阅记录(历史已归还的可以删书,借阅历史里 LEFT JOIN 显示"(已删除书籍)")。
4.5 列表视图组装与逾期计算 ★查询核心
static async queryBookViews(context: common.Context, status: number): Promise<BookView[]> {
const books = status === -1
? await BookDao.queryBooks(context)
: await BookDao.queryBooksByStatus(context, status);
const views: BookView[] = [];
const now = Date.now();
for (const b of books) {
let borrower = '';
let dueTime = 0;
let overDays = 0;
if (b.status === 1) {
const record = await BookDao.latestBorrow(context, b.id);
if (record !== null) {
borrower = record.borrower;
dueTime = record.dueTime;
if (now > record.dueTime) {
overDays = Math.floor((now - record.dueTime) / 86400000);
}
}
}
const v: BookView = { book: b, borrower: borrower, dueTime: dueTime, overDays: overDays };
views.push(v);
}
return views;
}
逾期天数的计算:Math.floor((now - dueTime) / 86400000)。时间戳都是毫秒,相减得毫秒差,除以一天的毫秒数向下取整得到"已逾期多少整天"。比如 overdue 5 小时 → floor(0.2) = 0 天(未满一天不标红,合理),overdue 25 小时 → 1 天。
N+1 查询问题:这里对每本借出中的书都调用一次 latestBorrow,形成了"1 次查书 + N 次查借阅"的循环。数据量小(几十本)完全可接受;数据量大时可改用一条 LEFT JOIN SQL 一次取回(见深度扩展)。先正确后优化。
4.6 借阅历史:LEFT JOIN 跨表查询 ★JOIN 示范
static async queryBorrowHistory(context: common.Context): Promise<BorrowView[]> {
const store = await BookDao.getStore(context);
const result = await store.querySql(
`SELECT b.id AS bid, b.book_id, b.borrower, b.borrow_time, b.due_time, b.return_time,
k.title AS title, k.author AS author
FROM ${BookDao.BORROW_TABLE} b
LEFT JOIN ${BookDao.TABLE} k ON b.book_id = k.id
ORDER BY b.borrow_time DESC`
);
// 遍历组装 BorrowView(略)
}
LEFT JOIN 的价值:
- 从表 borrow 为主表,LEFT JOIN book 补书名作者;
LEFT保证 borrow 全保留:即使书已删除(k 侧无匹配),借阅历史依然显示,title 用|| '(已删除书籍)'兜底;- 别名
b/k让 SQL 简洁,列名前缀区分来源。
对比 26 快递包裹:时间线查询用"先查主表、逐条查从表"(N+1 但简单);本实例历史查询直接用 JOIN 一条 SQL 搞定。两条路都对,取舍标准是查询形态:详情页只查一本书的从表(关联条件单一,N+1 简单直观),历史页要全部记录+书名(跨表全量,JOIN 一次到位)。
4.7 统计:条件聚合 + 时间条件子查询
static async summary(context: common.Context): Promise<{...}> {
// 第一条 SQL:总数 / 在馆 / 借出中
const result = await store.querySql(
`SELECT COUNT(*) AS total,
SUM(CASE WHEN status=0 THEN 1 ELSE 0 END) AS available,
SUM(CASE WHEN status=1 THEN 1 ELSE 0 END) AS borrowed
FROM ${BookDao.TABLE}`
);
// 第二条 SQL:逾期数(未归还且已过应还时间)
const overdueResult = await store.querySql(
`SELECT COUNT(*) AS c FROM ${BookDao.BORROW_TABLE}
WHERE return_time = 0 AND due_time < ${Date.now()}`
);
return { total, available, borrowed, overdue };
}
两条 SQL 各司其职:第一条是主表快照统计(条件聚合,一条出三个数);第二条是"当前时间点的逾期快照"(借出中且过期)。due_time < now 在调用瞬间求值,是即时计算——逾期不是一个静态字段,而是随当前时间动态变化的判定,所以不能在表里存"是否逾期",只能查询时算。
五、技术要点对照表
| 技术点 | 实现方式 | 生产价值 |
|---|---|---|
| 双表建模 | book + borrow,逻辑外键 | 档案与借阅分离,各自演进 |
| 借出/归还 | 事务双写 | 状态与记录强一致 |
| 应还日期 | due_time 预计算存储 | 支持差异化周期,历史不变 |
| 逾期计算 | 时间戳差值 floor | 动态判定,无需存储 |
| LEFT JOIN | 历史查询拼书名 | 已删书籍仍可查历史 |
| 删除保护 | DAO 层检查未归还记录 | 业务规则在数据层执行 |
| 视图接口 | BookView/BorrowView | 页面消费成品,不碰 SQL |
| 返回值语义 | deleteBook 返回 boolean | 业务结果驱动 UI 提示 |
六、文章小结
本篇完成了图书借阅的数据层:book 存档案与二值状态,borrow 存借阅业务(谁借的、何时还),借出/归还用事务保证双表一致,逾期通过时间戳差值即时计算,历史查询用 LEFT JOIN 一次取回含书名,删除由 DAO 业务规则保护。这套模型是"库存/资产 + 流转记录"类系统的标准范式——不只是书,任何可借出的物品(工具、设备、雨伞)都适用。
下一篇《页面 UI 与操作实现》将基于 BookDao 搭建界面:状态 Tab、书籍卡片(逾期红标)、借出/归还弹窗、借阅历史抽屉。
七、数据层深度扩展
1. N+1 查询 vs JOIN:何时用哪个
queryBookViews 用 N+1(先查书、逐本查借阅),queryBorrowHistory 用 JOIN。两者对比:
| 方案 | 查询次数 | 代码复杂度 | 适用场景 |
|---|---|---|---|
| N+1 | 1 + N 次 | 低,逐条组装直观 | 主表条数少、从表只取单条 |
| LEFT JOIN | 1 次 | 中,SQL 较长 | 全量跨表取数、要排序分页 |
N+1 并非坏味道,当 N 小(几十)、且从表只取"每主表一条最新"时,N+1 更直白。若书上百本,可优化为一条 SQL:
SELECT k.*, b.borrower, b.due_time
FROM book k
LEFT JOIN borrow b ON b.book_id = k.id
AND b.return_time = 0
AND b.id = (SELECT MAX(id) FROM borrow WHERE book_id = k.id AND return_time = 0)
WHERE k.status = 1
子查询取"每本书最新未归还记录",一次 JOIN 到位。判断标准:先测 N+1 是否可感知,再决定是否优化。
2. 时间戳计算的时间边界
Math.floor((now - dueTime) / 86400000) 是"粗略整天数"。若要求"满 24 小时才算逾期 1 天",这是对的;若要求"跨过自然日 0 点算一天"(银行逾期口径),需要按日历日对齐:
const due = new Date(dueTime);
const today = new Date();
due.setHours(0, 0, 0, 0);
today.setHours(0, 0, 0, 0);
const overDays = Math.floor((today.getTime() - due.getTime()) / 86400000);
两种口径结果可能差 1 天。业务口径要提前定死,本实例采用简单的毫秒差值(演示为主),真实系统按需求选择。
3. 事务嵌套问题
returnBook 在事务内调用 latestBorrow(内部 getStore 返回同一单例),安全。但要注意:不要在事务里调用会开启新事务的方法。relationalStore 不支持嵌套事务,重复 beginTransaction 会异常。纪律:事务方法内部只做 update/insert/query,不调用其他事务方法。
4. FAQ
Q1:为什么借出时要传 days,而不是写死 30?
A:DAO 的可复用性。传参让调用方(页面)决定周期,未来支持"短借 7 天""长借 60 天"无需改 DAO。页面默认传 BORROW_DAYS 常量。
Q2:归还时如果 latestBorrow 返回的是已归还记录怎么办?
A:正常流程不可能(借出中状态保证最新一条未归还)。但防御性判 record.returnTime === 0 才回填,避免脏数据被二次覆盖。这行代码成本极低,收益是数据安全。
Q3:删除书籍为什么只查未归还记录,不查全部借阅记录?
A:已归还的历史记录不影响删除——书删了,历史仍是历史(LEFT JOIN 里显示"(已删除书籍)“)。只有未归还的记录才与书"强绑定”,书删了会导致"不知道谁借了不存在的书"。所以只检查未归还。
Q4:逾期统计的 due_time < now 每次查询都变,会不会不准?
A:这正是"即时计算"的意义——逾期是相对当前时刻的判定,本来就该每次查。若想缓存,可加定时任务刷新"逾期标记"字段,但中小型应用无必要。
Q5:book 表需要 unique 约束吗(同一本书多本副本)?
A:本实例简化为一书一条。真实图书馆同书多册,需加 copy_no(副本号)字段,主键改 (id, copy_no) 或单独 copy 表。字段设计在深度扩展中讨论,教学实例保持简单。
八、下篇预告
下一篇《页面 UI 与操作实现》将完成:标题栏统计文案、三个状态 Tab、书籍卡片(在馆绿标/借出橙标/逾期红标)、借出与归还的确认流程、新书入库弹窗、借阅历史抽屉(含已归还记录)。敬请期待。
更多推荐




所有评论(0)