- 添加导航条目和分类数据模型 - 实现导航卡片网格展示 - 支持点击卡片打开链接 - 添加主题样式(渐变色、卡片阴影) - 区分系统默认和自定义条目 - 添加分类统计信息"
220 lines
8.8 KiB
Markdown
220 lines
8.8 KiB
Markdown
# 后端数据结构设计:网站与分组
|
||
|
||
> 本文是设计稿,已按此实现。代码在 `backend/`,接口与运行方式见
|
||
> [backend/README.md](../backend/README.md)。
|
||
|
||
## 1. 需求与边界
|
||
|
||
- 后端是 Rust,**数据用 JSON 文件存储**,不引入数据库。
|
||
- 网站分两类:
|
||
- **系统内置**:随项目发布,只读,只有项目维护者能改。
|
||
- **可修改**:用户提交、或第三方通过接口推送进来的,能改、能删。
|
||
- **不需要审核**:提交即上线。
|
||
- **图标沿用当前逻辑**:前端取名称首字渲染,后端不存图标。
|
||
- **不统计访问量**。
|
||
- **提交的网站不能放进内置分组**:提交时必须自带分组名(或分组 + 子分组名),
|
||
后端只在「用户创建的分组」里找或建;内置分组只有项目维护者自己维护。
|
||
|
||
## 2. 一个网站条目的完整结构
|
||
|
||
系统内置的一条(`index3.html` 里的 GitHub):
|
||
|
||
```json
|
||
{
|
||
"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": ""
|
||
}
|
||
```
|
||
|
||
用户提交的一条:
|
||
|
||
```json
|
||
{
|
||
"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:唯一决定「能不能改」的字段
|
||
|
||
```rust
|
||
pub enum SiteSource {
|
||
System, // 系统内置,只读
|
||
Submission, // 用户提交
|
||
Api, // 接口推送
|
||
}
|
||
```
|
||
|
||
- 只有 `source == "system"` 的条目不可修改:修改、删除接口一律返回 403。
|
||
- 不单独存 `editable` 字段,它由 `source` 推导。存两份状态迟早不一致,
|
||
比如手改 JSON 把 `source` 改成 `system`、`editable` 却还是 `true`。
|
||
- 访客接口返回时附带派生的 `editable` 给前端用,不落盘。
|
||
|
||
## 4. 提交时为什么必须自带分组
|
||
|
||
分组也是同样的两套:
|
||
|
||
```json
|
||
{
|
||
"id": "materials",
|
||
"title": "素材资源",
|
||
"parentId": "",
|
||
"source": "system",
|
||
"order": 20,
|
||
"createdAt": "2026-09-13T10:29:48+08:00",
|
||
"updatedAt": "2026-09-13T10:29:48+08:00"
|
||
}
|
||
```
|
||
|
||
```json
|
||
{
|
||
"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`**,只能给分组名:
|
||
|
||
```json
|
||
{
|
||
"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/
|
||
├── site.json # 站点名与搜索框提示,只读
|
||
├── system-categories.json # 内置分组,只读,项目维护者手写
|
||
├── system-sites.json # 内置网站,只读
|
||
├── user-categories.json # 提交时创建的分组,可写
|
||
└── user-sites.json # 提交 / 推送的网站,可写
|
||
```
|
||
|
||
- 读:启动时全量加载进内存,之后都从内存读,不再碰磁盘。
|
||
- 写:只写 `user-*.json` 两个文件。**代码里不存在写 `system-*.json` 的路径**,
|
||
「不可修改」靠结构保证,而不是靠每处调用都记得判断。
|
||
- 写盘用「先写临时文件再改名」,避免写到一半断电把文件写坏。
|
||
- 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` |
|