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

实例:快递包裹管理(Parcel Manager)|技术:逻辑外键双表、状态机流转、事务、GROUP BY 聚合

一、业务需求分析

1.1 为什么选择快递包裹管理

在系列前二十个 SQLite 实例中,我们已经覆盖了单表 CRUD(待办、备忘录)、主从表(订单与明细)、时间序列(日记、健康记录)、聚合统计(记账本、预算)等经典场景。快递包裹管理这个选题的价值在于:它把此前分散的技巧集中到一个真实、高频、人人都有体验的业务里

想象一下双十一之后的你:同时有三四个包裹在途,分别属于顺丰、圆通、中通等不同公司,有的刚到转运中心、有的正在派送、有的已经签收。你需要在手机上快速回答三个问题:

  1. 我有哪些包裹,各自处于什么状态?——状态筛选与统计
  2. 某个包裹的完整物流轨迹是什么?——状态时间线
  3. 我在哪个快递公司寄收的包裹最多?——按公司分组统计

这三个问题恰好对应了 SQLite 的三种核心能力:状态字段过滤一对多从表查询GROUP BY 聚合

1.2 核心功能清单

编号 功能 技术要点
1 新增包裹(单号/公司/物品/寄件人/收件人) INSERT,默认状态"待揽收"
2 按状态筛选(全部/待揽收/运输中/派送中/已签收/异常) equalTo + 状态 Tab
3 查看包裹详情与物流时间线 主表查一条 + 从表查多条
4 状态流转(如"运输中"→"已签收") UPDATE 主表 + INSERT 时间线,事务包裹
5 删除包裹及全部物流记录 先删从表再删主表
6 状态统计(五种状态各几件) 一条 SQL 五个 COUNT
7 按公司分组统计 GROUP BY + 条件聚合

1.3 与已有实例的对比定位

实例 表结构 核心技巧 与本实例的差异
09 订单与明细 orders + order_items 一对多主从表 订单明细是静态快照,本实例的时间线是动态追加
05 习惯打卡 单表 + 日期字段 按日期去重统计 本实例需要记录每个状态变更事件
03 个人记账本 单表 SUM/GROUP BY 聚合 本实例聚合的对象是状态与公司维度

一句话概括:订单明细记录"买了什么",物流时间线记录"发生了什么"。前者是业务数据,后者是事件数据——事件数据的核心特征是会随时间不断追加新行,这正是从表设计的关键。

二、字段设计表

在这里插入图片描述

2.1 主表 parcel(包裹)

字段名 类型 约束 说明
id INTEGER PRIMARY KEY AUTOINCREMENT 自增主键
tracking_no TEXT NOT NULL 快递单号(全局唯一,业务上建议加 UNIQUE)
company TEXT NOT NULL 物流公司(顺丰速运/圆通速递…)
goods TEXT DEFAULT ‘’ 物品描述
sender TEXT DEFAULT ‘’ 寄件人
receiver TEXT DEFAULT ‘’ 收件人
status INTEGER NOT NULL DEFAULT 0 状态机:0待揽收 1运输中 2派送中 3已签收 4异常
remark TEXT DEFAULT ‘’ 备注
created_time INTEGER NOT NULL 创建时间戳(毫秒)
signed_time INTEGER DEFAULT 0 签收时间戳(0 表示未签收)

2.2 从表 parcel_trace(物流时间线)

字段名 类型 约束 说明
id INTEGER PRIMARY KEY AUTOINCREMENT 自增主键
parcel_id INTEGER NOT NULL 逻辑外键 → parcel.id
status INTEGER NOT NULL 该节点对应的状态值(冗余存储,便于展示)
note TEXT DEFAULT ‘’ 节点说明(如"已到达转运中心")
trace_time INTEGER NOT NULL 节点发生时间戳(毫秒)

2.3 设计要点详解

要点一:状态机用数字枚举。

包裹的五个状态是有序、单向推进为主的流程:待揽收 → 运输中 → 派送中 → 已签收,另有"异常"分支。用 INTEGER 存储,配合一个常量数组 PARCEL_STATUS: string[] = ['待揽收','运输中','派送中','已签收','异常'],页面里 PARCEL_STATUS[p.status] 就能拿到中文名,statusColor(s) 能拿到徽标颜色。数字枚举的好处是:可以直接 WHERE status=1ORDER BY status,也能参与 SUM(CASE WHEN ...) 聚合,不需要任何字符串转换。

要点二:时间线为什么要单独建表?

这是本实例最重要的设计决策。两种方案对比:

方案 实现 问题
A:主表加字段 parcel 表加 trace1/trace2/trace3... 状态节点数量不定,字段个数无法预知,严重违反规范化
B:单独从表 parcel_trace 每行一个节点 节点数量无上限,天然支持"追加",查询按时间排序即可

方案 B 就是**事件溯源(Event Sourcing)**思想的简化版:主表保存"当前状态"(最新事实),从表保存"状态历史"(全部事件)。两者通过 parcel_id 关联。

要点三:为什么是逻辑外键而不是 FOREIGN KEY 约束?

HarmonyOS 的 relationalStore 建表 SQL 中可以FOREIGN KEY (parcel_id) REFERENCES parcel(id),但实际工程里我们很少启用。原因有二:

  1. 级联删除(ON DELETE CASCADE)需要额外配置,而我们在 DAO 的 delete() 里显式"先删从表、再删主表",逻辑同样清晰;
  2. 外键约束会增加写入校验开销,且一旦从表有历史数据,删主表会直接报错,业务上"删除包裹连同物流记录"是合理需求,显式两步删除更可控。

所以 parcel_trace.parcel_id 是逻辑外键:语义上指向 parcel.id,实现上不加约束,靠 DAO 方法保证一致性。

要点四:signed_time 冗余字段的价值。

status=3 时同步写 signed_time=Date.now(),这是一个典型的冗余反范式设计。虽然从表时间线里也能查到签收时间,但"查某个包裹是否已签收、何时签收"是高频查询(列表页要展示),直接从主表取一列比子查询快得多。冗余的是结论,而不是过程——结论经常要读,过程偶尔才看,这是取舍的关键。

2.4 索引设计

CREATE INDEX IF NOT EXISTS idx_parcel_status ON parcel (status);
CREATE INDEX IF NOT EXISTS idx_parcel_created ON parcel (created_time);
CREATE INDEX IF NOT EXISTS idx_trace_parcel ON parcel_trace (parcel_id);
  • idx_parcel_status:状态筛选 Tab 的 WHERE status=? 高频命中;
  • idx_parcel_created:列表页 ORDER BY created_time DESC 排序加速;
  • idx_trace_parcel:详情页按 parcel_id 查时间线,这是从表最核心的查询路径,必须加索引。

三、建表 SQL

-- 主表:包裹
CREATE TABLE IF NOT EXISTS parcel (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  tracking_no TEXT NOT NULL,
  company TEXT NOT NULL,
  goods TEXT DEFAULT '',
  sender TEXT DEFAULT '',
  receiver TEXT DEFAULT '',
  status INTEGER NOT NULL DEFAULT 0,
  remark TEXT DEFAULT '',
  created_time INTEGER NOT NULL,
  signed_time INTEGER DEFAULT 0
);

-- 从表:物流时间线
CREATE TABLE IF NOT EXISTS parcel_trace (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  parcel_id INTEGER NOT NULL,
  status INTEGER NOT NULL,
  note TEXT DEFAULT '',
  trace_time INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_parcel_status ON parcel (status);
CREATE INDEX IF NOT EXISTS idx_parcel_created ON parcel (created_time);
CREATE INDEX IF NOT EXISTS idx_trace_parcel ON parcel_trace (parcel_id);

建表要点

  • CREATE TABLE IF NOT EXISTS 保证幂等,页面反复进出、甚至重复调用 getStore 都不会报"表已存在";
  • 两张表 + 三个索引放在同一个初始化方法里顺序执行,保证"库一打开,表与索引全部就绪";
  • signed_time DEFAULT 0 与"0 表示未签收"的语义绑定,避免 NULL 的判空麻烦——SQLite 里 NULL 参与比较会得到未知结果,用 0 代替 NULL 是常见工程约定。

四、ParcelDao 数据访问层

4.1 类结构与单例复用

export class ParcelDao {
  private static readonly TABLE = 'parcel';
  private static readonly TRACE_TABLE = 'parcel_trace';
  private static store?: relationalStore.RdbStore;

  static async getStore(context: common.Context): Promise<relationalStore.RdbStore> {
    if (ParcelDao.store) {
      return ParcelDao.store;
    }
    // 打开库 + 建表 + 建索引(略,见上文 SQL)
    return ParcelDao.store;
  }
}

static store 缓存实例:整个应用生命周期内只打开一次数据库文件,后续调用直接复用,避免重复 getRdbStore 的开销。这也是前 20 个实例统一采用的模式。

4.2 实体接口

export const PARCEL_STATUS: string[] = ['待揽收', '运输中', '派送中', '已签收', '异常'];

export interface Parcel {
  id: number;
  trackingNo: string;
  company: string;
  goods: string;
  sender: string;
  receiver: string;
  status: number;
  remark: string;
  createdTime: number;
  signedTime: number;
}

export interface ParcelTrace {
  id: number;
  parcelId: number;
  status: number;
  note: string;
  traceTime: number;
}

export interface CompanyStat {
  company: string;
  count: number;
  signedCount: number;
}

PARCEL_STATUS 数组是整个状态机的唯一事实来源(Single Source of Truth):DAO、页面、文章代码示例都引用它,状态值的中文含义只定义一次,杜绝魔法数字散落。

4.3 行记录 → 实体映射

private static rowToParcel(row: relationalStore.ResultSet): Parcel {
  return {
    id: row.getLong(row.getColumnIndex('id')),
    trackingNo: row.getString(row.getColumnIndex('tracking_no')),
    company: row.getString(row.getColumnIndex('company')),
    goods: row.getString(row.getColumnIndex('goods')) || '',
    sender: row.getString(row.getColumnIndex('sender')) || '',
    receiver: row.getString(row.getColumnIndex('receiver')) || '',
    status: row.getLong(row.getColumnIndex('status')),
    remark: row.getString(row.getColumnIndex('remark')) || '',
    createdTime: row.getLong(row.getColumnIndex('created_time')),
    signedTime: row.getLong(row.getColumnIndex('signed_time')),
  };
}

注意细节:goods/sender/receiver/remark|| '' 兜底,因为表中这些字段可能为空字符串,ResultSet 在列值为 NULL 时可能返回 null,ArkTS 严格模式下必须处理。数据库字段名(snake_case)与实体属性名(camelCase)的映射,全部集中在这个方法里,页面层永远只跟实体打交道。

4.4 新增包裹:INSERT + 初始时间线

static async insert(context: common.Context, p: Parcel): Promise<number> {
  const store = await ParcelDao.getStore(context);
  const values: relationalStore.ValuesBucket = {
    tracking_no: p.trackingNo,
    company: p.company,
    goods: p.goods,
    sender: p.sender,
    receiver: p.receiver,
    status: p.status,
    remark: p.remark,
    created_time: p.createdTime,
    signed_time: p.signedTime,
  };
  const parcelId: number = await store.insert(ParcelDao.TABLE, values);
  await ParcelDao.addTrace(context, parcelId, p.status, p.remark || '包裹创建');
  return parcelId;
}

static async addTrace(context: common.Context, parcelId: number, status: number, note: string): Promise<number> {
  const store = await ParcelDao.getStore(context);
  const values: relationalStore.ValuesBucket = {
    parcel_id: parcelId,
    status: status,
    note: note,
    trace_time: Date.now(),
  };
  return await store.insert(ParcelDao.TRACE_TABLE, values);
}

这里体现了"事件溯源"的落地:新增包裹的瞬间,时间线就有了第一条节点(“包裹创建,状态:待揽收”)。此后每一次状态变更,时间线追加一行,形成完整轨迹。

为什么"新增"和"写初始节点"不包在事务里?因为 insert 返回的自增 id 是第二条语句的前置条件,两步天然串行,且第二步失败概率极低;真正的强一致性场景是下面的状态流转,那里我们用了事务。

4.5 状态流转:事务保证双表一致 ★核心方法

static async updateStatus(context: common.Context, parcelId: number, newStatus: number, note: string): Promise<void> {
  const store = await ParcelDao.getStore(context);
  await store.beginTransaction();
  try {
    const values: relationalStore.ValuesBucket = { status: newStatus };
    if (newStatus === 3) {
      values['signed_time'] = Date.now();
    } else {
      values['signed_time'] = 0;
    }
    const predicates = new relationalStore.RdbPredicates(ParcelDao.TABLE);
    predicates.equalTo('id', parcelId);
    await store.update(values, predicates);
    await ParcelDao.addTrace(context, parcelId, newStatus, note);
    await store.commit();
  } catch (e) {
    await store.rollBack();
    throw new Error(`状态流转失败: ${JSON.stringify(e)}`);
  }
}

这是全篇最值得反复咀嚼的方法,拆解如下:

为什么必须用事务? 状态流转涉及两步写操作:① 更新主表 status;② 向时间线追加节点。如果第①步成功、第②步失败,就会产生"主表说已签收、时间线里却查不到签收记录"的数据不一致。事务保证这两步要么都成功,要么都回滚

beginTransaction / commit / rollBack 的调用时机:

await store.beginTransaction();  // 1. 开启
try {
  // 2. 业务操作(update + insert)
  await store.commit();          // 3. 全部成功 → 提交
} catch (e) {
  await store.rollBack();        // 4. 任一失败 → 回滚
}

注意 rollBack 必须放在 catch 里,commit 放在 try 的成功路径末尾。如果 try 里某一步抛异常,控制流直接跳到 catch,此时事务处于"未提交"状态,必须回滚释放。

为什么签收时间用 if/else 而非直接覆盖? 状态是可能"回流"的(比如误标已签收后又改为运输中),此时要把 signed_time 归零,避免"状态是运输中、却显示已签收时间"的矛盾数据。signed_time 永远与 status=3 保持一致,这是冗余字段的维护纪律。

4.6 删除:先子后父,杜绝孤儿数据

static async delete(context: common.Context, id: number): Promise<void> {
  const store = await ParcelDao.getStore(context);
  const tracePredicates = new relationalStore.RdbPredicates(ParcelDao.TRACE_TABLE);
  tracePredicates.equalTo('parcel_id', id);
  await store.delete(tracePredicates);   // 先删从表
  const parcelPredicates = new relationalStore.RdbPredicates(ParcelDao.TABLE);
  parcelPredicates.equalTo('id', id);
  await store.delete(parcelPredicates);  // 再删主表
}

删除顺序是硬性纪律:先删从表,再删主表。 反过来的话,主表记录删了,从表留下一堆 parcel_id 指向不存在的包裹——这就是"孤儿数据"(orphan records)。虽然查询时通过关联能过滤掉,但数据冗余、统计失真、后续维护都是坑。

4.7 查询家族

/** 全部包裹:创建时间倒序 */
static async queryAll(context: common.Context): Promise<Parcel[]> {
  const store = await ParcelDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(ParcelDao.TABLE);
  predicates.orderByDesc('created_time');
  const result = await store.query(predicates);
  return ParcelDao.collectParcel(result);
}

/** 按状态查询 */
static async queryByStatus(context: common.Context, status: number): Promise<Parcel[]> {
  const store = await ParcelDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(ParcelDao.TABLE);
  predicates.equalTo('status', status).orderByDesc('created_time');
  const result = await store.query(predicates);
  return ParcelDao.collectParcel(result);
}

/** 按单号模糊查询 */
static async queryByTrackingNo(context: common.Context, keyword: string): Promise<Parcel[]> {
  const store = await ParcelDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(ParcelDao.TABLE);
  predicates.like('tracking_no', `%${keyword}%`).orderByDesc('created_time');
  const result = await store.query(predicates);
  return ParcelDao.collectParcel(result);
}

/** 查询单个包裹的时间线:按时间升序 */
static async queryTrace(context: common.Context, parcelId: number): Promise<ParcelTrace[]> {
  const store = await ParcelDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(ParcelDao.TRACE_TABLE);
  predicates.equalTo('parcel_id', parcelId).orderByAsc('trace_time');
  const result = await store.query(predicates);
  return ParcelDao.collectTrace(result);
}

四个查询方法覆盖了列表页的全部需求。注意 queryTraceorderByAsc('trace_time')——时间线展示时最早的事件在最上面、最新事件在最下面(模拟现实物流轨迹从上往下滚动),和列表页的倒序习惯相反,这是由业务语义决定的,不是笔误。

4.8 状态统计:一条 SQL 五个数

static async statusSummary(context: common.Context): Promise<number[]> {
  const store = await ParcelDao.getStore(context);
  const result = await store.querySql(
    `SELECT
       SUM(CASE WHEN status=0 THEN 1 ELSE 0 END) AS s0,
       SUM(CASE WHEN status=1 THEN 1 ELSE 0 END) AS s1,
       SUM(CASE WHEN status=2 THEN 1 ELSE 0 END) AS s2,
       SUM(CASE WHEN status=3 THEN 1 ELSE 0 END) AS s3,
       SUM(CASE WHEN status=4 THEN 1 ELSE 0 END) AS s4
     FROM ${ParcelDao.TABLE}`
  );
  const arr: number[] = [0, 0, 0, 0, 0];
  if (result.goToNextRow()) {
    arr[0] = result.getLong(result.getColumnIndex('s0'));
    arr[1] = result.getLong(result.getColumnIndex('s1'));
    arr[2] = result.getLong(result.getColumnIndex('s2'));
    arr[3] = result.getLong(result.getColumnIndex('s3'));
    arr[4] = result.getLong(result.getColumnIndex('s4'));
  }
  result.close();
  return arr;
}

这是**条件聚合(conditional aggregation)**的经典用法:SUM(CASE WHEN status=n THEN 1 ELSE 0 END) 在一条 SQL 里同时算出五个状态各自的计数。比起五次 SELECT COUNT(*) WHERE status=n,一次全表扫描即可,性能优势随数据量放大。五个结果的顺序与 PARCEL_STATUS 数组下标一一对应,页面直接用 statusCounts[idx] 取数。

4.9 按公司分组统计:GROUP BY + 条件聚合

static async companyStats(context: common.Context): Promise<CompanyStat[]> {
  const store = await ParcelDao.getStore(context);
  const result = await store.querySql(
    `SELECT company,
            COUNT(*) AS cnt,
            SUM(CASE WHEN status=3 THEN 1 ELSE 0 END) AS signed_cnt
     FROM ${ParcelDao.TABLE}
     GROUP BY company
     ORDER BY cnt DESC`
  );
  // 遍历结果集 → CompanyStat[](略)
}

这里把 GROUP BY(按公司分组)和条件聚合(组内统计签收数)结合:COUNT(*) 是每组包裹总数,SUM(CASE WHEN status=3 ...) 是每组已签收数。输出形如:

顺丰速运 2件/签收2 · 圆通速递 2件/签收1 · 中通快递 2件/签收0 ...

这一行直接作为列表页顶部的"公司概览"文案,让用户一屏看到分布。

4.10 种子数据:12 件跨状态包裹

static async initSeedData(context: common.Context): Promise<void> {
  // 表非空则跳过
  const seeds = [
    { no: 'SF1234567890123', company: '顺丰速运', goods: '手机', status: 3, daysAgo: 6, note: '已签收,本人代收' },
    { no: 'YT7890123456789', company: '圆通速递', goods: '冬装外套', status: 3, daysAgo: 4, note: '已签收,放快递柜' },
    // ...共 12 条,覆盖五种状态、六家公司
  ];
  // 每条:插主表 + 插时间线
}

种子数据刻意覆盖全部五种状态多家公司,保证用户第一次打开页面就能看到:状态 Tab 有数字、公司概览有分布、点开详情有时间线。状态机演示最怕"数据全是待揽收",那样时间线无从展示。

五、技术要点对照表

技术点 实现方式 生产价值
双表设计 parcel + parcel_trace,逻辑外键 状态快照与事件历史分离,可无限追加
状态机 数字枚举 + PARCEL_STATUS 常量数组 状态语义唯一来源,防魔法数字
事务 beginTransaction / commit / rollBack 主表与时间线强一致,防半完成数据
条件聚合 SUM(CASE WHEN …) 一条 SQL 出 5 个统计数
分组统计 GROUP BY + COUNT + 条件聚合 公司维度分布
删除顺序 先删从表再删主表 杜绝孤儿数据
冗余字段 signed_time 与 status 联动维护 高频查询免子查询
幂等建表 CREATE TABLE IF NOT EXISTS 重复初始化不报错

六、文章小结

本篇完成了快递包裹管理的数据地基:主表存"当前状态"(最新事实),从表存"状态时间线"(全部事件),状态机用数字枚举统一语义,状态流转用事务保证双表一致,统计用条件聚合与分组聚合一屏呈现。这套设计在真实物流系统里也是基本范式——任何"对象 + 状态变更历史"的业务(工单、订单、任务审批)都可以套用。

下一篇《页面 UI 与操作实现》将基于 ParcelDao 搭建界面:状态筛选 Tab、包裹卡片列表、新增弹窗、详情抽屉与时间线渲染、状态流转交互。


七、建表与数据层深度扩展

1. 逻辑外键 vs 物理外键:工程取舍的完整讨论

HarmonyOS relationalStore 支持在建表 SQL 中声明 FOREIGN KEY:

CREATE TABLE IF NOT EXISTS parcel_trace (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  parcel_id INTEGER NOT NULL,
  status INTEGER NOT NULL,
  note TEXT DEFAULT '',
  trace_time INTEGER NOT NULL,
  FOREIGN KEY (parcel_id) REFERENCES parcel(id) ON DELETE CASCADE
);

物理外键的优点:数据库层面强制引用完整性,删主表时 CASCADE 自动清从表。但真实工程中我们倾向逻辑外键,理由:

对比维度 物理外键 逻辑外键
删除主记录 自动级联或报错 显式两步删,可控
写入开销 每次 INSERT 校验引用 无额外校验
迁移灵活性 改表结构受约束限制 完全自由
查询效率 同(都靠索引)
可读性 建表 SQL 自解释 靠 DAO 注释说明

结论:单机单库的小型应用,逻辑外键 + DAO 纪律(先删子后删父、先写子后写父)足够,还能避免级联删除误伤数据。若未来做跨设备同步或多人协作,再考虑物理约束也不迟。

2. 事务的三种提交方式与嵌套

relationalStore 提供三种事务 API:

API 行为
beginTransaction / commit / rollBack 手动控制,最灵活,本实例采用
beginTrans / commitTrans / rollBackTrans 旧版 API,新代码不建议
自动事务(不显式开启) 每条 SQL 独立提交,非原子

事务的重要特性:未 commit 前,其他连接看不到本事务的修改(隔离性)。单应用单连接场景影响不大,但养成"业务多写操作必包事务"的习惯,是数据工程师的基本素养。本实例 updateStatus 就是教科书式的三件套。

3. SQL 注入防护:predicates 的隐藏价值

queryByTrackingNopredicates.like('tracking_no', \%keyword{keyword}%\`)` 而非 `querySql(\`SELECT ... WHERE tracking_no LIKE '%keyword{keyword}%'`)。前者是参数化查询,keyword 永远被当作**值**而非 SQL 片段;后者若直接拼接用户输入,输入 ’ OR 1=1 --` 会变成恒真条件返回全表。凡是涉及用户输入的条件,一律用 predicates 或 querySql 的占位参数,这是安全底线。

4. 索引选择与 EXPLAIN 思路

三个索引都是"查询入口"列:状态的等值筛选、创建时间的排序、parcel_id 的等值关联。判断索引是否生效的思路:在 SQLite 客户端执行 EXPLAIN QUERY PLAN SELECT ...,看到 SEARCH ... USING INDEX idx_xxx 即命中。小型数据量下索引收益不明显,但这是代码质量习惯——索引设计是表结构的一部分,建表时就该想清楚查询路径,而不是等数据量大到卡顿再补救。

5. FAQ

Q1:为什么时间线节点冗余存 status 字段?从主表取不行吗?
A:从表冗余 status 是为了快照。状态流转后主表 status 会变,但时间线每一行必须保留"当时发生了什么"——如果只存 note 不存 status,历史轨迹就丢失了状态语义。这正是事件溯源的核心理念:事件不可变,只追加不修改

Q2:签收时间为什么不用时间线里最新一条?
A:可以,但列表页只查主表就要展示"是否已签收",从表关联会增加一次查询与代码复杂度。signed_time 冗余在主表,更新成本极低(只在流转时写一次),读取零成本,性价比极高。

Q3:删除包裹真的需要两步吗?一张 SQL 不行吗?
A:relationalStore 没有"跨表级联删除"的一条 SQL(除非启用物理外键 CASCADE)。两步删除在同一个 store 上顺序执行,逻辑清晰。若担心中途失败产生孤儿数据,可以同样用事务包裹——本实例为保持 DAO 简洁未加,读者可自行扩展。

Q4:状态机未来增加"退回件/拒收"状态怎么办?
A:只需在 PARCEL_STATUS 数组追加元素、在 statusSummary 的 CASE WHEN 里加一列。表结构无需变更——这正是数字枚举 + 常量数组设计的前瞻性。

Logo

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

更多推荐