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

实例:文件资料库(FileLib)|技术:UNIQUE 路径去重、元数据字段、类型统计

一、业务需求分析:文件库的数据形态

文件资料库(管理文档/图片/视频/音频/压缩包)是文件元数据管理应用——数据库存「文件的描述信息」(名称、类型、大小、路径、标签、备注),不存文件本体(真实文件在存储系统里)。

核心业务需求:

  1. 文件记录:名称、类型(文档/图片/视频/音频/压缩包/其他)、大小、路径、标签、备注;
  2. 路径去重:同一路径的文件不能重复导入——UNIQUE 约束
  3. 通配符搜索:按名称或标签模糊搜索——LIKE 通配符
  4. 类型筛选与统计:按类型筛选 + 每类型文件数与总大小——GROUP BY 统计
  5. 批量导入:一次导入多个文件,已存在的路径跳过。

二、字段设计:文件元数据

文件库表 file_lib

字段名 类型 约束 说明
id INTEGER PRIMARY KEY AUTOINCREMENT 自增主键
name TEXT NOT NULL 文件名
file_type TEXT NOT NULL 类型(文档/图片/视频/音频/压缩包/其他)
size INTEGER NOT NULL DEFAULT 0 大小(字节)
path TEXT NOT NULL UNIQUE 唯一路径
tags TEXT DEFAULT ‘’ 逗号分隔标签
note TEXT DEFAULT ‘’ 备注
created_time INTEGER NOT NULL 导入时间戳

设计要点拆解

1. path 用 TEXT + UNIQUE。文件路径(/docs/需求/项目需求文档.docx)是文件在存储系统中的唯一标识——UNIQUE 约束保证同一路径不重复入库。为什么不用 id 去重? id 是自增的(每行不同),文件路径才是业务上的「唯一键」(同一文件导入两次应该是同一份)。业务唯一键用 UNIQUE 约束——这是数据库完整性的核心。

2. size 用 INTEGER 存字节。文件大小以字节为整数存储(120 * 1048576 = 120MB)——存储最小单位,展示时换算(页面 fmtSize 换算 KB/MB/GB)。为什么存字节而不是直接存「120MB」字符串?因为要参与排序与统计(SUM(size) 算总大小)——数值字段才能聚合计算

3. file_type 用文本枚举。六类:文档/图片/视频/音频/压缩包/其他——参与等值筛选(equalTo('file_type', '文档'))与分组统计(GROUP BY file_type),文本直接用(与影音 type 同理)。

4. tags 用逗号分隔字符串'需求,产品'——多标签的简化存储(不建标签关联表)。搜索时 LIKE 匹配LIKE '%需求%')——Demo 级多值方案;真实应用标签多时建「文件-标签」关联表(第 12 实例 CRM 的进阶模式)。

5. 元数据字段(name/type/size/path/tags/note):全部是「描述」——不存文件本体(真实文件在文件系统),数据库只存索引。这是文件管理器类应用的通用架构:DB 存元数据,文件系统存本体

三、建表 SQL

CREATE TABLE IF NOT EXISTS file_lib (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  file_type TEXT NOT NULL,
  size INTEGER NOT NULL DEFAULT 0,
  path TEXT NOT NULL UNIQUE,
  tags TEXT DEFAULT '',
  note TEXT DEFAULT '',
  created_time INTEGER NOT NULL
);

path TEXT NOT NULL UNIQUE——唯一约束的核心体现:插入重复路径 → SQLite 抛约束错误(批量导入的「跳过逻辑」在应用层处理,见 16-3)。UNIQUE 是「路径去重」的数据库级保障——即使应用层忘了判断,数据库也不会产生重复路径。

四、FileLibDao 封装:搜索与统计是亮点

数据层核心 FileLibDao,本实例的独特方法是search(LIKE 通配符搜索)typeStats(类型分组统计)

按类型筛选

static async queryByType(context: common.Context, fileType: string): Promise<FileRecord[]> {
  const store = await FileLibDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(FileLibDao.TABLE);
  if (fileType !== '全部') {
    predicates.equalTo('file_type', fileType);
  }
  predicates.orderByDesc('created_time');
  const result = await store.query(predicates);
  return FileLibDao.collect(result);
}

‘全部’ 哨兵值fileType === '全部' 时不过滤(查全表)——筛选条的「全部」选项在 DAO 层用哨兵值跳过谓词。「全部」不在数据库层处理,在查询层跳过

通配符搜索(LIKE)

static async search(context: common.Context, keyword: string): Promise<FileRecord[]> {
  const store = await FileLibDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(FileLibDao.TABLE);
  predicates.like('name', `%${keyword}%`)
    .or()
    .like('tags', `%${keyword}%`)
    .orderByDesc('created_time');
  const result = await store.query(predicates);
  return FileLibDao.collect(result);
}

生成的 SQL:

SELECT * FROM file_lib
WHERE name LIKE '%需求%' OR tags LIKE '%需求%'
ORDER BY created_time DESC;

LIKE 通配符%关键字% 匹配「包含关键字」——%需求% 匹配「项目需求文档.docx」与 tags 含「需求」的文件。% 是任意字符通配符(0 个或多个),_ 是单字符通配符。LIKE 的 OR 组合:名称或标签任一命中即返回——搜索的「多字段命中」。

类型统计(GROUP BY + COUNT + SUM)

static async typeStats(context: common.Context): Promise<TypeStat[]> {
  const store = await FileLibDao.getStore(context);
  const result = await store.querySql(
    `SELECT file_type, COUNT(*) AS cnt, SUM(size) AS total_size
     FROM ${FileLibDao.TABLE}
     GROUP BY file_type
     ORDER BY cnt DESC`
  );
  const list: TypeStat[] = [];
  while (result.goToNextRow()) {
    const item: TypeStat = {
      fileType: result.getString(result.getColumnIndex('file_type')),
      count: result.getLong(result.getColumnIndex('cnt')),
      totalSize: result.getLong(result.getColumnIndex('total_size')),
    };
    list.push(item);
  }
  result.close();
  return list;
}

GROUP BY + COUNT + SUM 三合一:每个类型一行——类型名 + 文件数(COUNT)+ 总大小(SUM)。ORDER BY cnt DESC:文件数多的类型排前(文档通常最多)。TypeStat 接口{ fileType, count, totalSize }——统计结果的结构化返回。

总统计

static async totalStats(context: common.Context): Promise<TotalStat> {
  // SELECT COUNT(*) AS cnt, SUM(size) AS total_size FROM file_lib
}

标题栏「18 个文件 · 共 1.7GB」——COUNT + SUM 无分组(全表统计)。TotalStat 接口{ count, totalSize }

五、技术要点对照表

技术点 实现方式 生产价值
路径去重 path UNIQUE 约束 防重复导入
模糊搜索 LIKE ‘%kw%’ OR LIKE 名称/标签命中
类型统计 GROUP BY + COUNT + SUM 每类文件数/大小
全表统计 COUNT + SUM 无分组 标题栏数字
批量导入 路径先查后插 去重导入
大小单位 字节存储 + fmtSize 换算 可聚合可展示

六、文章小结

文件资料库的数据层核心是**「UNIQUE 路径去重 + LIKE 搜索 + GROUP BY 统计」**:path UNIQUE 约束从数据库层保证文件路径唯一(防重复导入),LIKE 通配符实现名称/标签的多字段模糊搜索,GROUP BY + COUNT + SUM 三合一给出每类型文件数与总大小(类型筛选条的动态来源)。字节整数存储让大小可聚合(SUM)可换算(展示)。这是「文件元数据管理类应用」的通用数据模型。

下一篇(16-2)展示文件浏览器 UI——搜索框 + 动态类型筛选条 + 图标行列表。

七、文件元数据字段设计详解

文件库 7 个字段各司其职,其中 path、size、file_type、tags 四个字段的设计决策最值得展开——它们分别对应「去重、聚合、筛选、搜索」四类核心能力。

7.1 path UNIQUE:路径是业务唯一键

对比项 id 主键 path UNIQUE
生成方式 自增(数据库自动) 文件系统决定(业务产生)
语义 行标识,与业务无关 文件的真实身份(位置)
重复导入 id 不同,无法拦截 约束报错,天然拦截
结论 仅作内部主键 业务唯一键必须 UNIQUE

关键认知:UNIQUE 约束是数据库级的最后防线。批量导入时应用层「先查再插」只是第一道防线(省一次插入开销);即使应用层判断逻辑写错,path UNIQUE 也会让 SQLite 抛约束错误,杜绝脏数据。约束在数据库,防御分两层

// 应用层:先查重(第一道防线)
const exist = await FileLibDao.queryByPath(context, path);
if (exist) { return; } // 已存在则跳过
// 数据库层:UNIQUE 兜底(第二道防线)
// 若上面判断漏了,INSERT 会抛 SQLITE_CONSTRAINT_UNIQUE

7.2 size 字节存储:整数才能聚合

  • 存字节(INTEGER):2.3MB = 2.3 * 1048576 的整数——可 SUM、可排序、可比较;
  • 存「2.3MB」字符串:SUM 无意义、排序错乱(‘120MB’ 按字典序排在 ‘25MB’ 前面);
  • 展示换算(fmtSize):读取时按 1024 分级换算 KB/MB/GB——存储与展示分离

7.3 file_type 文本枚举

六类文本枚举(文档/图片/视频/音频/压缩包/其他)直接参与等值筛选equalTo('file_type', '图片'))与分组统计GROUP BY file_type)。文本在此场景与数值效率无差异(六类基数极小),且可读性最好——SQL 结果直接显示,无需再映射成中文。

7.4 tags 逗号分隔

'需求,产品'多值属性的简化建模——一个字段存多个标签。代价:无法做「按标签精确统计」,搜索只能 LIKE 模糊命中。Demo 级取舍:文件数少(18 个)时 LIKE 扫描全表毫无压力;标签多、要按标签统计时,升级为「文件-标签」关联表(CRM 实例的进阶模式)。

八、元数据与文件本体的分离架构

┌───────────────┐      path       ┌──────────────────┐
│   file_lib 表  │  ────────────►  │   文件系统/相册    │
│  存「描述」      │                 │   存「本体」        │
│ name/size/tags │                 │ /docs/需求/xx.docx │
└───────────────┘                 └──────────────────┘
       DB 索引                          真实数据

为什么分离?

  1. DB 不存大对象:文件本体可能几百 MB(本实例种子最大 800MB),入库会让数据库膨胀、备份变慢;
  2. 文件系统已是最好的存储:读写、流式播放、权限管理都是系统级能力;
  3. 元数据可高速查询:文件数统计、类型聚合、模糊搜索都在 DB 索引层面完成,不用打开文件;
  4. 删除语义清晰:删记录 ≠ 删文件(只清索引);「彻底删除」才联动文件系统——两个动作由应用层编排。

鸿蒙侧实现:元数据在 file_lib 表,本体在沙箱路径指向的真实文件。UI 点击行时用 path 打开/分享文件,DB 只管描述——path 是连接两层的桥梁字段

interface FileRecord {
  id: number;
  name: string;      // 展示用
  path: string;      // 定位本体用(桥梁)
  size: number;      // 统计用(字节)
  fileType: string;  // 筛选/分组用
  tags: string;      // 搜索用
  note: string;
  createdTime: number;
}

九、字节存储的可聚合性

类型统计的核心 SQL 依赖 size 是数值:

SELECT file_type, COUNT(*) AS cnt, SUM(size) AS total_size
FROM file_lib GROUP BY file_type ORDER BY cnt DESC;
存储类型 COUNT SUM AVG 排序
INTEGER 字节 ✅ 数值序
TEXT “120MB” ❌ 字典序

一个字段的存储类型决定它的能力边界——选 INTEGER 存字节,等于同时解锁「总大小(SUM)」「每类平均大小(AVG)」「按大小排序(ORDER BY size DESC)」三个能力。展示侧 fmtSize 是纯函数:

function fmtSize(size: number): string {
  if (size >= 1024 * 1024 * 1024) return (size / (1024 ** 3)).toFixed(1) + 'GB';
  if (size >= 1024 * 1024) return (size / (1024 ** 2)).toFixed(1) + 'MB';
  if (size >= 1024) return (size / 1024).toFixed(0) + 'KB';
  return size + 'B';
}

存储不做加工,展示才换算——数据库永远存原始字节,单位换算只发生在渲染层。

十、LIKE 搜索思路预览

后续文章(16-2/16-3)将展开完整搜索实现,这里先给出建表层就要想好的搜索设计

-- 名称或标签命中关键字
SELECT * FROM file_lib
WHERE name LIKE '%需求%' OR tags LIKE '%需求%'
ORDER BY created_time DESC;

三个提前设计:

  1. name 与 tags 都是 TEXT——LIKE 可直接作用,无需转换;
  2. tags 的逗号分隔格式决定了搜索是「包含匹配」而非「等值匹配」——建表时就想清楚了;
  3. 中文 LIKE 无需转义,英文如需忽略大小写可配合 COLLATE NOCASE

十一、FAQ

Q1:path 用 UNIQUE,重复导入时会发生什么?
A:SQLite 抛 SQLITE_CONSTRAINT_UNIQUE 约束错误。应用层两条路:插入前先查重(省开销),或 try/catch 捕获约束错误后跳过该条——查重省开销,UNIQUE 兜底

Q2:size 为什么不直接存「2.3MB」这种人类可读字符串?
A:字符串无法 SUM/排序/比较(‘120MB’ 字典序小于 ‘25MB’)。字节整数存储后由展示层按 1024 换算——存储与展示解耦,统计能力完整。

Q3:tags 用逗号分隔,会不会有歧义?
A:约定标签本身不含逗号(导入时清洗/替换)即可。这是「一列多值」的简化方案,查询用 LIKE 包含匹配;需要精确按标签筛选时可用 LIKE '%,需求,%' 加逗号边界。

Q4:UNIQUE 和 PRIMARY KEY 都是唯一,为什么还要 path UNIQUE?
A:PRIMARY KEY 约束主键(id,自增),语义是「行标识」;UNIQUE 约束业务键(path),语义是「业务上不能重复」——主键管内部,UNIQUE 管业务,两者各司其职。

Q5:文件删除了,数据库记录还在怎么办?
A:文件库只管理元数据,删记录只清 DB 索引;若要求同步清理本体,应用层在删除时用 path 调文件系统删除接口——删记录与删文件两个动作要在同一事务语义里编排(先删 DB 成功再删文件,或反之)。

Logo

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

更多推荐