Files
Navius/docs/backend-site-schema.md
CNWei 039ea70edf feat: 实现导航页基础功能
- 添加导航条目和分类数据模型
- 实现导航卡片网格展示
- 支持点击卡片打开链接
- 添加主题样式(渐变色、卡片阴影)
- 区分系统默认和自定义条目
- 添加分类统计信息"
2026-09-19 23:17:36 +08:00

8.7 KiB
Raw Permalink Blame History

后端数据结构设计:网站与分组

本文是设计稿。实现时按「侧栏结构和网站绑定到一个文件、删除时同步清理」调整过, 现状见 backend/README.md。

1. 需求与边界

  • 后端是 Rust,数据用 JSON 文件存储,不引入数据库。
  • 网站分两类:
    • 系统内置:随项目发布,只读,只有项目维护者能改。
    • 可修改:用户提交、或第三方通过接口推送进来的,能改、能删。
  • 不需要审核:提交即上线。
  • 图标沿用当前逻辑:前端取名称首字渲染,后端不存图标。
  • 不统计访问量。
  • 提交的网站不能放进内置分组:提交时必须自带分组名(或分组 + 子分组名), 后端只在「用户创建的分组」里找或建;内置分组只有项目维护者自己维护。

2. 一个网站条目的完整结构

系统内置的一条(index3.html 里的 GitHub):

{
  "id": "github",
  "title": "GitHub",
  "url": "https://github.com",
  "description": "代码托管与开源社区",
  "categoryId": "common",
  "source": "system",
  "order": 10,
  "createdAt": "2026-09-13T10:29:48+08:00",
  "updatedAt": "2026-09-13T10:29:48+08:00",
  "submittedBy": ""
}

用户提交的一条:

{
  "id": "s-20260919-8f3a1c",
  "title": "Hugging Face",
  "url": "https://huggingface.co",
  "description": "开源模型与数据集社区",
  "categoryId": "g-20260919-4d21ab",
  "source": "submission",
  "order": 0,
  "createdAt": "2026-09-19T14:02:11+08:00",
  "updatedAt": "2026-09-19T14:02:11+08:00",
  "submittedBy": "[email protected]"
}

3. 字段说明

字段 类型 必填 说明
id string 是 唯一标识,同时是接口路径里的 id。命名规则见 3.1。
title string 是 显示名称。前端取它的首字当图标。上限 60 字符。
url string 是 跳转地址,必须是可解析的 http / https。
description string 否 一句话描述,卡片副标题。上限 120 字符。
categoryId string 是 所属分组或子分组的 id。必须指向用户创建的分组(见第 5 节)。
source enum 是 system / submission / api。它同时决定这条能不能被修改。
order int 否 同一分组内的排序值,小的在前,默认 0。内置条目靠它固定顺序,提交进来的默认追加到末尾。
createdAt string 是 创建时间,ISO 8601 带时区。
updatedAt string 是 最后修改时间,ISO 8601 带时区。
submittedBy string 否 提交来源标识:用户提交放邮箱或用户名,接口推送放调用方标识,内置为空。

3.1 id 命名规则

来源 规则 例子
系统内置网站 手写,语义化,小写字母 + 数字 + - github、can-i-use
用户提交网站 后端生成:s- + 日期 + 随机后缀 s-20260919-8f3a1c
接口推送网站 后端生成:a- + 日期 + 随机后缀 a-20260919-2b77d0
用户创建分组 后端生成:g- + 日期 + 随机后缀 g-20260919-4d21ab

前缀分开是为了一眼看出这条是谁写的,也从生成规则上杜绝和内置 id 撞车。

3.2 source:唯一决定「能不能改」的字段

pub enum SiteSource {
    System,      // 系统内置,只读
    Submission,  // 用户提交
    Api,         // 接口推送
}
  • 只有 source == "system" 的条目不可修改:修改、删除接口一律返回 403。
  • 不单独存 editable 字段,它由 source 推导。存两份状态迟早不一致, 比如手改 JSON 把 source 改成 system、editable 却还是 true。
  • 访客接口返回时附带派生的 editable 给前端用,不落盘。

4. 提交时为什么必须自带分组

分组也是同样的两套:

{
  "id": "materials",
  "title": "素材资源",
  "parentId": "",
  "source": "system",
  "order": 20,
  "createdAt": "2026-09-13T10:29:48+08:00",
  "updatedAt": "2026-09-13T10:29:48+08:00"
}
{
  "id": "design",
  "title": "UI 设计",
  "parentId": "materials",
  "source": "system",
  "order": 10,
  "createdAt": "2026-09-13T10:29:48+08:00",
  "updatedAt": "2026-09-13T10:29:48+08:00"
}
  • parentId 为空 = 一级分组;不为空 = 子分组,指向它所属的一级分组。
  • 层级上限两级,和侧栏的结构一致(index3.html 就是两级)。
  • source == "system" 的分组只看不改,由项目维护者手写 JSON 维护。
  • source != "system" 的分组只可能由提交产生。

4.1 提交接口的入参

提交方不能指定 categoryId,只能给分组名:

{
  "title": "Hugging Face",
  "url": "https://huggingface.co",
  "description": "开源模型与数据集社区",
  "group": "AI 工具",
  "subGroup": "模型社区",
  "submittedBy": "[email protected]"
}

后端按这个顺序处理:

  1. 在用户创建的分组里按名字找一级分组 AI 工具;找不到就新建一个 (source = "submission",id 自动生成)。
  2. 如果带了 subGroup,在 AI 工具 下面按名字找子分组;找不到就新建。
  3. 新建网站条目,categoryId 指向解析出来的子分组(没带 subGroup 就指向一级分组)。

规则上:

  • 内置分组永远不参与第 1、2 步的查找,所以提交的内容不可能落进内置分组。
  • 同一个父级下同名的用户分组复用,不会每提交一次就多出一个同名分组。 如果希望「每次提交都新建」,把查找那一步去掉即可。
  • 修改接口同样不允许把已有条目挪进内置分组,判定标准和创建时一致。

5. 两种数据如何隔离

backend/data/
└── nav.json                 # 站点信息 + 分组结构(含网站),一份文档
  • 读:启动时加载进内存,之后都从内存读,不再碰磁盘。
  • 写:整份文档一起写回,但改的只会是 source != system 的那部分。
  • 网站直接挂在分组的 sites 里,不再自己记 categoryId—— 归属由位置表达,删掉分组不会留下指向空处的引用。
  • 删除网站时,如果它所在的用户分组空了,会在同一次写入里一起清掉。
  • JSON 用 2 空格缩进、保留中文原文,方便直接看 diff。

5.1 启动时的自检

启动时校验一遍,不通过就直接启动失败(防止手改 JSON 越权):

  • system-*.json 里的条目 source 必须全是 system; user-*.json 里必须全不是。
  • 每个网站的 categoryId 必须存在,并且分组来源要和网站来源一致: 内置网站只能挂在系统分组下,提交的网站只能挂在用户分组下。
  • 分组 parentId 指向的分组必须存在,且不能出现两级以上、不能自己指向自己。

6. 校验规则

写入(用户提交 / 接口推送)时逐条校验,任何一条不过就整体拒绝并返回原因:

规则 说明
id 唯一 与已有网站都不冲突
title 非空、≤ 60 字符 去掉首尾空白后判断
url 合法 可解析,协议必须是 http / https,且不能是 localhost 与内网地址
description ≤ 120 字符 允许为空
group 非空、≤ 30 字符 没有它就没地方放,直接拒绝
subGroup ≤ 30 字符 可选;给了就必须和 group 一起解析成两级
source 只能是 submission / api 外部写入不允许声称自己是 system

7. 和前端现有模型的关系

前端 LinkItem 目前用到 id / title / url / description 四个字段, 本次结构是它的超集,前端不改也能读出来;categoryId、source、editable 等字段等前端要用时再接。

字段名统一用 camelCase,和前端 Dart 的 fromJson 对齐,前端不用做名字转换; Rust 侧用 #[serde(rename_all = "camelCase")] 映射。

新加字段一律带 #[serde(default)],这样以后往结构里加字段,老 JSON 仍然能读, 不需要写迁移脚本。

8. 这次没有放进结构里的东西

按「先保留当前逻辑」处理,以下都不存;以后要用再加字段(加字段不需要迁移):

没放的 原因 以后要加时
icon 前端当前是取 title 首字渲染 加 icon 字段,存 favicon 地址或 emoji
tags 当前侧栏和卡片都没有标签 加 tags: [],提交接口再放开这个入参
visits 不统计访问量 要么由后端跳转接口累加,要么接第三方统计
status 不需要审核,提交即上线 加 status 字段并给默认值 published