- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
8.7 KiB
8.7 KiB
后端数据结构设计:网站与分组
本文是设计稿。实现时按「侧栏结构和网站绑定到一个文件、删除时同步清理」调整过, 现状见 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]"
}
后端按这个顺序处理:
- 在用户创建的分组里按名字找一级分组
AI 工具;找不到就新建一个 (source = "submission",id 自动生成)。 - 如果带了
subGroup,在AI 工具下面按名字找子分组;找不到就新建。 - 新建网站条目,
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 |