# 后端数据结构设计:网站与分组 > 本文是设计稿。实现时按「侧栏结构和网站绑定到一个文件、删除时同步清理」调整过, > 现状见 [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": "an@example.com" } ``` ## 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": "an@example.com" } ``` 后端按这个顺序处理: 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` |