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

实例:图书借阅管理(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_returnWHERE 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、书籍卡片(在馆绿标/借出橙标/逾期红标)、借出与归还的确认流程、新书入库弹窗、借阅历史抽屉(含已归还记录)。敬请期待。

Logo

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

更多推荐