# yao-nav
**Repository Path**: yao-coder/yao-nav
## Basic Information
- **Project Name**: yao-nav
- **Description**: 一个简单好用的网站导航 —— 集**默认空间公共导航 + 个人空间私有书签管理 + 多源 AI 自动采集 + 公开分享**于一体的全栈项目。
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: main
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2026-05-01
- **Last Updated**: 2026-08-17
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# YAO-NAV
> 一个简单好用的网站导航 —— 集**默认空间公共导航 + 个人空间私有书签管理 + 多源 AI 自动采集 + 公开分享**于一体的全栈项目。
### 🚀 项目地址:[https://yao-nav.com](https://yao-nav.com)
---
YAO-NAV 由三个独立部署、协同运行的子项目组成:
| 子项目 | 技术栈 | 端口 | 说明 |
| ---------- | --------------------------------------- | ------ | ------------------------------------------ |
| `backend/` | Spring Boot 3.4.4 / Java 21 / MyBatis-Plus / Sa-Token / MySQL 8 / Redis 7 | 8080 | REST API(context-path `/api`) |
| `frontend/`| Next.js 16 / React 19 / Tailwind v4 / Zustand | 3000 | 用户站(首页、个人空间、分享、消息) |
| `admin/` | React 19 / Vite 8 / React Router 7 / shadcn-ui | 3001 | 管理后台(部署在 `/admin/` 子路径) |
子模块各自有独立的 README,本文档讲**整体架构、约定、数据库、初始化、部署**。
---
## 目录
- [快速开始](#快速开始)
- [核心概念](#核心概念)
- [整体架构](#整体架构)
- [技术栈](#技术栈)
- [项目目录结构](#项目目录结构)
- [数据库设计](#数据库设计)
- [API 设计](#api-设计)
- [Redis 缓存设计](#redis-缓存设计)
- [核心流程](#核心流程)
- [部署](#部署)
- [环境变量清单](#环境变量清单)
- [开发约定](#开发约定)
- [故障排查](#故障排查)
---
## 快速开始
### 1. 前置依赖
| 软件 | 最低版本 | 说明 |
| ----------- | -------- | ----------------------------------------------------- |
| JDK | 21 | 后端必需 |
| Maven | 3.9 | 后端构建 |
| Node.js | 20 | 前端 / 管理后台,建议 LTS |
| pnpm | 8 | 前端包管理(`npm i -g pnpm`) |
| MySQL | 8.0.29+ | 8.0.29 起支持 `ALTER TABLE ... ADD INDEX IF NOT EXISTS` 与 ngram FULLTEXT |
| Redis | 7.x | 会话 / 系统设置 / 缓存 |
### 2. 初始化数据库(一次性)
```bash
mysql -u root -p < backend/src/main/resources/init.sql
```
`init.sql` 会一次性完成:
- 创建数据库 `yao_nav`(utf8mb4 / utf8mb4_unicode_ci)
- 创建应用账号 `yao_nav`(密码占位为 `CHANGE_ME_BEFORE_RUN`,执行前请改成你自己的强密码并同步到 `application.yml` 的 `${YAONAV_DB_PASSWORD}`)并授权
- 创建全部 **21 张表**(含 OAuth 绑定表,已合并历次 migration 的字段与索引)
- 写入 **默认管理员**、**10 个默认分类**、**88 条推荐网站**、**~110 条域名分类规则**、**~40 条关键词分类规则**
> **默认管理员**(部署前请先用 `BCryptPasswordEncoder` 生成一个新哈希替换 `init.sql` 里的占位 hash,部署后用密码重置流程修改):
> 用户名 `yao` · 邮箱 `admin@example.com`
### 3. 启动 Redis
任意方式跑一个本地 Redis:
```bash
redis-server
# 或 docker
docker run --rm -p 6379:6379 redis:7-alpine
```
应用默认连 `localhost:6379` 的 **database 1**,无密码。
### 4. 设置 AI Key(推荐,不设也能跑)
```bash
# Linux/macOS
export YAONAV_AI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
export YAONAV_GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # 可选:GitHub Trending 采集
# Windows PowerShell
$env:YAONAV_AI_API_KEY="sk-xxxx"
$env:YAONAV_GITHUB_TOKEN="ghp_xxxx"
```
未设置时书签 AI 分类、采集 AI 评分会自动降级(走域名规则 + 关键词兜底),不影响主功能。
### 5. 启动三端
```bash
# 后端
cd backend && mvn spring-boot:run
# 用户站
cd frontend && pnpm install && pnpm dev
# 管理后台
cd admin && pnpm install && pnpm dev
```
| 入口 | URL |
| ---------- | ---------------------------------- |
| 用户站 | http://localhost:3000 |
| 管理后台 | http://localhost:3001/admin/ |
| 后端 API | http://localhost:8080/api/... |
> 本地 dev 时,frontend 通过 `NEXT_PUBLIC_API_URL` 直连后端;admin 通过 Vite 的 `/api` 代理到后端。
---
## 核心概念
理解这几个术语对读代码至关重要:
| 术语 | 含义 |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **默认空间** | 即"公共导航"。未登录用户访问首页看到的内容。数据来源 = `sys_default_category`(10 个分类)+ `sys_recommended_website`(88+ 推荐网站) |
| **个人空间** | 登录用户的私有导航。数据按 `user_id` 隔离,存在 `nav_category` + `nav_website`。新用户注册时自动用默认分类初始化空间 |
| **公开分享页** | 个人空间的快照视图。用户启用分享后获得一个 `share_code`,访客通过 `/share/{code}` SSR 访问,仅显示用户标记为 `is_public=1` 的内容 |
| **推荐网站** | 官方维护的、跨用户共享的高质量网站列表。展示在默认空间首页。管理员可直接添加,普通用户/游客可通过"推荐网站"功能提交(走审核) |
| **采集候选池** | 后端定时任务从 GitHub Trending / Hacker News / DEV / V2EX / 少数派 / AI 多源采集,AI 评分 ≥ 阈值(默认 70)直接入推荐表,< 阈值进候选池待审 |
| **域名规则 / 关键词规则** | 书签自动分类的两级匹配策略;先按域名(含子域、通配符),命中则走域名规则;否则按关键词在 name/url 中匹配;都未命中走 AI 兜底 |
| **系统设置** | 站点名、Logo、是否允许注册、采集自动通过/拒绝阈值等。**存储在 Redis** 而不是 MySQL;未设置时启动有代码默认值 |
---
## 整体架构
```
┌────────────────────────┐ ┌────────────────────────┐
│ 访客 / 登录用户 │ │ 系统管理员 │
│ 浏览器 │ │ 浏览器 │
└──────────┬─────────────┘ └──────────┬─────────────┘
│ │
│ HTTP │ HTTP
▼ ▼
┌────────────────────────┐ ┌────────────────────────┐
│ frontend (Next.js) │ │ admin (Vite + React) │
│ 用户站 / 分享页 SSR │ │ /admin/ SPA │
│ :3000 │ │ :3001 │
└──────────┬─────────────┘ └──────────┬─────────────┘
│ │
│ /api/* (Bearer token) │ /api/* (Bearer token)
└──────────────┬───────────────┘
▼
┌─────────────────────────────────┐
│ backend (Spring Boot) │
│ context-path /api :8080 │
│ ┌───────────────────────────┐ │
│ │ Sa-Token 鉴权过滤器 │ │
│ │ Controller (Public/Auth) │ │
│ │ Service (业务) │ │
│ │ Mapper (MyBatis-Plus) │ │
│ └───────────────────────────┘ │
│ + 启动 Hooks: │
│ - DefaultDataInitializer │
│ - WebsiteSchemaMigrator │
│ - OAuthBindingSchemaMigrator │
│ + 定时任务: │
│ - DailyHotRank (00:05) │
│ - WebsiteCollector (01:00) │
│ - DeadLinkCheck / FaviconFetch│
└────────┬────────────┬────────────┘
│ │
┌──────┘ └──────┐
▼ ▼
┌────────────────┐ ┌────────────────┐
│ MySQL 8 │ │ Redis 7 │
│ yao_nav │ │ database=1 │
│ 20 张表 │ │ 会话/系统设置/│
│ │ │ 缓存/计数 │
└────────────────┘ └────────────────┘
┌────────────────────────────────────────┐
│ 外部 API(按需) │
│ - 阿里云百炼 (compatible-mode/v1) │
│ - GitHub Trending / HN / DEV │
│ - V2EX / 少数派 │
│ - 各站 favicon / metadata │
└────────────────────────────────────────┘
```
---
## 技术栈
### 后端
| 层级 | 选型 | 版本 |
| ---------- | ----------------------------------------------- | ------------ |
| 语言 | Java | 21 |
| 框架 | Spring Boot | 3.4.4 |
| ORM | MyBatis-Plus | 3.5.10.1 |
| 鉴权 | Sa-Token + sa-token-redis-jackson | 1.39.0 |
| 数据库 | MySQL | 8.0 |
| 缓存 | Redis | 7.x |
| 工具集 | Hutool(含 BCrypt) | 5.8.35 |
| HTML 解析 | Jsoup | 1.18.3 |
| 域名工具 | Guava InternetDomainName(Public Suffix List) | 33.4.0 |
| 校验 | spring-boot-starter-validation | - |
| AOP | spring-boot-starter-aop(用于 `@LogOperation`) | - |
### 用户站
Next.js 16.2.3 + React 19.2.4 + Tailwind v4 + Zustand 5 + axios + framer-motion + @dnd-kit + react-markdown + sonner + solarlunar。
### 管理后台
React 19 + Vite 8 + React Router 7 + shadcn/ui (base-nova) + Tailwind v4 + Zustand 5 + lucide-react + recharts + @uiw/react-md-editor + sonner。
### 包管理
前端 / 管理后台统一 **pnpm**;后端用 **Maven**。
---
## 项目目录结构
```
yao-nav/
├── README.md 本文档(整体)
├── architecture.md 早期架构设计(保留参考;以代码与本 README 为准)
├── bookmark-import-design.md 书签导入功能设计稿
├── notes.md / task_plan.md 个人笔记(不影响运行)
├── backend/ Spring Boot
│ ├── pom.xml
│ ├── start.sh 生产启动(统一 JVM 参数)
│ ├── src/main/java/com/yaonav/
│ │ ├── YaoNavApplication.java
│ │ ├── admin/ 管理后台模块
│ │ ├── announcement/ 公告模块
│ │ ├── auth/ 认证模块(Sa-Token)
│ │ ├── bookmark/ 书签导入 + 自动分类
│ │ ├── category/ 分类模块
│ │ ├── collector/ 网站采集(多源 + AI 评分)
│ │ ├── common/ 通用:config / utils / exception / dto
│ │ ├── ranking/ 每日热度排序任务
│ │ ├── recommendation/ 用户推荐网站审核
│ │ ├── share/ 个人分享
│ │ ├── stats/ 统计
│ │ ├── task/ 定时任务(FaviconFetch / DeadLinkCheck)
│ │ ├── user/ 用户模块
│ │ └── website/ 网站模块
│ └── src/main/resources/
│ ├── application.yml
│ ├── init.sql ★ 唯一的初始化脚本(建库+建表+默认数据+默认管理员+分类规则)
│ └── mapper/ MyBatis XML
├── frontend/ Next.js 用户站
│ ├── README.md
│ └── src/ 见 frontend/README.md
└── admin/ React + Vite 管理后台
├── README.md
└── src/ 见 admin/README.md
```
---
## 数据库设计
`yao_nav` 数据库共 **20 张表**。下表按逻辑域分类:
### 用户与认证
| 表 | 关键字段 | 说明 |
| --------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `sys_user` | username / email / password (BCrypt) / role(USER/ADMIN) / status / oauth_provider / card_info_prefs / welcome_search_engine / welcome_date_calendar / last_welcome_date | 用户表 |
### 个人空间(按 user_id 隔离)
| 表 | 关键字段 | 说明 |
| ----------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------- |
| `nav_category` | user_id / name / icon / sort_order / is_default / is_public | 用户的导航分类 |
| `nav_website` | user_id / category_id / name / url / icon / custom_icon / sort_order / flat_order / flat_order_backup / status / dead_link_checked_at / icon_fetch_ignored / click_count / is_pinned / is_public | 用户的网站。索引含 FULLTEXT(ngram) |
| `nav_click_log` | user_id / website_id / ip / user_agent / created_at | 点击日志 |
| `nav_share_config`| user_id / share_code / is_enabled / title / description / view_count | 分享配置 |
| `nav_import_task` | user_id / status / total_count / valid_count / duplicate_count / merged_count / imported_count / detail_json | 书签导入任务记录 |
### 默认空间 / 公共数据(无 user_id)
| 表 | 关键字段 | 说明 |
| ------------------------------- | --------------------------------------------------------------------- | ----------------------------- |
| `sys_default_category` | name / icon / sort_order | 默认分类(init.sql 写 10 条) |
| `sys_recommended_website` | category_id / name / url / icon / sort_order / auto_sort_order / last_heat_score / status | 推荐网站(init.sql 写 88 条) |
| `sys_user_website_recommendation`| user_id / guest_key / url / url_normalized / status(0待审/1过/2拒) / reviewed_by / reject_reason | 用户推荐审核 |
| `sys_friend_link` | name / url / icon / status | 友情链接 |
| `sys_announcement` | title / content(MD) / type / priority / is_pinned / publish_at / expire_at / link_url / link_target / target_user_id / target_guest_key / is_active | 公告(含全站/私信/定时) |
| `sys_announcement_read` | user_id / announcement_id | 公告已读记录 |
### 自动分类规则
| 表 | 关键字段 | 说明 |
| --------------------------- | ----------------------------------------------------- | --------------------------------- |
| `sys_domain_category_rule` | domain / match_type(精确/通配) / category_name / priority | 域名分类规则(init.sql 写 ~110 条) |
| `sys_keyword_category_rule` | keyword / match_field(name/url/both) / category_name / priority | 关键词分类规则(init.sql 写 ~40 条) |
| `sys_ai_classify_cache` | cache_key / cache_type(0 DOMAIN/1 URL) / category_name / model / hit_count | AI 分类结果缓存(跨用户复用) |
### 后台运营
| 表 | 关键字段 | 说明 |
| ------------------------------- | ------------------------------------------------------------------------- | ----------------------------------- |
| `sys_search_log` | keyword / user_id / created_at | 搜索日志(用于热门搜索) |
| `sys_operation_log` | user_id / module / action / description / request_method / request_url / ip | 管理端操作日志 |
| `sys_scheduled_task_log` | task_name / execute_date / strategy_level / status / cost_ms / ai_call_count / ai_fail_count / input_summary / output_summary / triggered_by | 定时任务执行日志(每日热度排序等) |
| `sys_website_collect_candidate` | source / url / url_normalized / ai_score / ai_reason / status / log_id | 采集候选池(AI 评分 < 阈值留这里) |
### ER 关系
```
sys_user 1───N nav_category
sys_user 1───N nav_website
sys_user 1───1 nav_share_config
sys_user 1───N nav_click_log
nav_category 1───N nav_website
nav_website 1───N nav_click_log
sys_default_category 1───N sys_recommended_website
sys_announcement 1───N sys_announcement_read
sys_scheduled_task_log 1───N sys_website_collect_candidate (log_id)
```
> **注意**:项目代码层不依赖外键约束。所有"关联"靠应用层保证。这样跨库迁移、清表测试都更轻量。
---
## API 设计
所有接口前缀 `/api/`,统一响应:
```json
{ "code": 200, "message": "success", "data": { ... } }
// 分页:
{ "code": 200, "message": "success", "data": { "records": [...], "total": 100, "current": 1, "size": 20 } }
```
### 认证 `/api/auth`
| 方法 | 路径 | 说明 |
| ---- | ----------------------------- | -------------------------- |
| POST | `/api/auth/register` | 邮箱注册(受系统设置控制) |
| POST | `/api/auth/login` | 邮箱+密码登录 |
| POST | `/api/auth/logout` | 退出(Sa-Token) |
| GET | `/api/auth/check` | 检查登录状态 |
### 用户 `/api/user`
| 方法 | 路径 | 说明 |
| ---- | --------------------- | ----------------- |
| GET | `/api/user/profile` | 获取个人信息 |
| PUT | `/api/user/profile` | 更新个人信息 |
| PUT | `/api/user/password` | 修改密码 |
| POST | `/api/user/avatar` | 上传头像 |
### 分类 `/api/categories`
| 方法 | 路径 | 说明 |
| ------ | ----------------------------- | ----------------- |
| GET | `/api/categories` | 我的分类列表 |
| POST | `/api/categories` | 新增 |
| PUT | `/api/categories/{id}` | 更新 |
| DELETE | `/api/categories/{id}` | 删除(需空) |
| PUT | `/api/categories/sort` | 批量排序 |
### 网站 `/api/websites`
| 方法 | 路径 | 说明 |
| ------ | --------------------------------- | ---------------------------- |
| GET | `/api/websites` | 列表(categoryId / keyword) |
| GET | `/api/websites/{id}` | 详情 |
| POST | `/api/websites` | 新增 |
| PUT | `/api/websites/{id}` | 更新 |
| DELETE | `/api/websites/{id}` | 删除 |
| PUT | `/api/websites/sort` | 批量排序(含 flat_order) |
| PUT | `/api/websites/{id}/pin` | 置顶/取消 |
| POST | `/api/websites/{id}/click` | 记录点击 |
| POST | `/api/websites/batch-delete` | 批量删除 |
| POST | `/api/websites/batch-move` | 批量改分类 |
| POST | `/api/websites/fetch-info` | 抓取 metadata |
| POST | `/api/websites/{id}/refresh-icon` | 重新抓 favicon |
| POST | `/api/websites/reset-flat-order` | 按热度重置 flat_order(带快照恢复)|
### 书签 `/api/bookmarks`
| 方法 | 路径 | 说明 |
| ---- | -------------------------- | --------------------------------- |
| POST | `/api/bookmarks/preview` | 上传 + 解析 + 预览(不入库) |
| POST | `/api/bookmarks/confirm` | 确认导入(异步抓 favicon) |
| GET | `/api/bookmarks/export` | 导出 bookmarks.html |
### 统计 `/api/stats`
| 方法 | 路径 | 说明 |
| ---- | -------------------------- | -------------------------- |
| GET | `/api/stats/overview` | 个人统计概览 |
| GET | `/api/stats/hot-websites` | 热门网站排行 |
| GET | `/api/stats/recent-visits` | 最近访问 |
| GET | `/api/stats/click-trend` | 点击趋势(按天/周/月) |
### 分享 `/api/share`
| 方法 | 路径 | 说明 |
| ---- | -------------------------- | ---------------------- |
| GET | `/api/share/config` | 我的分享配置 |
| PUT | `/api/share/config` | 更新 |
| GET | `/api/share/{code}` | 公开访问分享页(SSR) |
### 公共 `/api/public`
| 方法 | 路径 | 说明 |
| ---- | ------------------------------------------ | --------------------------------------------- |
| GET | `/api/public/site-info` | 站点信息(来自系统设置) |
| GET | `/api/public/default-space` | 默认空间数据(分类 + 推荐网站) |
| GET | `/api/public/recommended/{id}/icon` | 推荐网站懒加载图标 |
| GET | `/api/public/announcements` | 公开生效中的公告 |
| GET | `/api/public/hot-search` | 热门搜索词 |
| GET | `/api/public/friend-links` | 友情链接 |
| POST | `/api/public/website-recommendations` | 用户/游客推荐网站 |
| GET | `/api/public/messages` | 游客消息(用 guestKey) |
### 管理 `/api/admin`(需 ADMIN 角色)
| 方法 | 路径 | 说明 |
| ---- | ---------------------------------------------- | ------------------------------- |
| GET | `/api/admin/dashboard` | 大屏数据 |
| GET | `/api/admin/users` / PUT `users/{id}/status` / PUT `users/{id}/role` | 用户管理 |
| GET | `/api/admin/websites` | 全站网站列表 |
| GET | `/api/admin/default-categories` 等 | 默认分类 CRUD |
| GET | `/api/admin/recommended` 等 | 推荐网站 CRUD + 批量刷新图标 |
| GET | `/api/admin/announcements` 等 | 公告 CRUD(含定时/私信) |
| GET | `/api/admin/friend-links` 等 | 友链 CRUD |
| GET | `/api/admin/user-recommendations` / POST `approve/reject` | 用户推荐审核 |
| GET | `/api/admin/collector/candidates` 等 | 采集候选池审核 |
| GET | `/api/admin/ranking/logs` / POST `rerun` | 排序任务日志 + 手工重跑 |
| GET | `/api/admin/logs` | 操作日志 |
| GET | `/api/admin/settings` / PUT | 系统设置(读写 Redis) |
### 错误码段
| 码段 | 含义 |
| ---------- | ------------------ |
| 200 | 成功 |
| 400 | 参数错误 |
| 401 | 未认证 |
| 403 | 无权限 |
| 404 | 资源不存在 |
| 409 | 数据冲突(重复) |
| 500 | 内部错误 |
| 1001-1099 | 认证业务错 |
| 1101-1199 | 用户业务错 |
| 1201-1299 | 网站业务错 |
| 1301-1399 | 导入业务错 |
---
## Redis 缓存设计
| Key 模式 | 类型 | 用途 | TTL |
| ------------------------------------------- | -------------- | ------------------------------------------ | ------------ |
| `satoken:login:token:{token}` | String | Sa-Token 会话 | 7 天(配置) |
| `satoken:login:session:{userId}` | Hash | 用户 Session | 7 天 |
| `system:settings` | String(JSON) | 系统设置(站点名/允许注册/采集阈值) | 永久 |
| `nav:default-space:v1` | String(JSON) | 默认空间整页数据 | 10 min |
| `nav:recommended:icon:{id}` | String | 推荐网站懒加载图标 | 1 h |
| `nav:announcements:active` | String(JSON) | 当前生效公告列表 | 5 min |
| `nav:hot-search:topk` | ZSet | 热搜话题 TopK | 1 h |
| `nav:hot-source:{source}:cache` | String(JSON) | 各热搜源响应缓存(兜底用) | 1 d |
| `task:lock:{taskName}` | String | 多实例互斥锁(每日热度 / 采集任务) | 30 min |
| `task:ai-score:{urlNormalized}` | String | 采集 AI 评分缓存 | 7 d(配置) |
| `nav:ai-classify:cache:{normalizedKey}` | String | 书签 AI 分类缓存(也落 DB) | 90 d(配置) |
> 系统设置不在 MySQL,是为了**热更新友好**:管理员在设置页改完点保存即生效,不用刷新缓存。
---
## 核心流程
### 注册与个人空间初始化
```
邮箱注册
├─ 检查系统设置 allowRegister(false 时拒绝)
├─ 邮箱 / 用户名 唯一性校验
├─ BCrypt.hashpw 存密码
├─ INSERT sys_user
├─ CategoryService.initDefaultCategories(userId)
│ └─ 把 sys_default_category 全部复制到 nav_category(user_id=新用户)
└─ Sa-Token 自动登录 → 返回 token + user
```
### 书签导入(三步式)
```
[Step 1] 上传 bookmarks.html
└─ Jsoup 解析嵌套 结构 → 拍平
[Step 2] 预览(不入库)
└─ 自动分类管线:
Lvl 1: 域名规则(sys_domain_category_rule,含子域 / 通配符)
Lvl 2: 关键词规则(sys_keyword_category_rule,name / url / both 三模式)
Lvl 3: AI 分类(阿里云百炼 qwen3.6-plus,分批并行;命中写 sys_ai_classify_cache)
[Step 3] 确认入库 → INSERT 到 nav_website + 异步 FaviconFetchTask 抓图标
└─ 持久化 nav_import_task(统计明细 + 失败原因)
```
### 网站点击 → 热度统计
```
前端 onClick → POST /api/websites/{id}/click
→ 后端 INCR Redis stats:click:{websiteId}
→ 写 nav_click_log
→ 同步更新 nav_website.click_count + last_visit_at(轻量 UPDATE)
```
### 推荐网站每日热度排序
```
Cron 0 5 0 * * * (Asia/Shanghai)
├─ 多实例互斥锁 task:lock:daily-hot-rank
├─ Lvl 1: 拉今日 TopK 热搜 → AI 给每个推荐网站打"话题相关度分"
├─ Lvl 2: AI 失败时降级走关键词匹配
├─ Lvl 3: 全失败时走纯热度兜底(time-decay clickScore)
├─ composite = α·normalize(clickScore) + β·relevanceScore(默认 α=0.6 β=0.4)
└─ 排好序写到 sys_recommended_website.auto_sort_order;
前端 ORDER BY COALESCE(auto_sort_order, sort_order)
```
### 网站采集(多源并行)
```
Cron 0 0 1 * * * (Asia/Shanghai)
├─ 多实例互斥锁
├─ 并行 fetch: GitHub Trending / Hacker News / DEV / V2EX / 少数派 / AI 推荐
│ (所有源失败时回退昨日 Redis 缓存;再失败放弃)
├─ 用 UrlNormalizer 去重
├─ WebsiteFetchService 补全 metadata + favicon
├─ AI 评分 0-100(缓存 7d)
└─ ≥ approveThreshold(默认 70) → 入 sys_recommended_website 上线
≥ rejectThreshold(默认 40) → 入 sys_website_collect_candidate 待审
< rejectThreshold → 自动拒绝
```
### 分享页 SSR
```
访客访问 /share/{code}
├─ Next.js getServerSideProps(在 frontend)
├─ 调 /api/share/{code}
│ └─ 后端先查 Redis nav:share:{code} → 未命中查 DB → 回填 Redis
├─ 返回公开(is_public=1)的分类 + 网站
└─ Next.js 渲染完整 HTML(利于 SEO + 首屏加载快)
```
---
## 部署
### 推荐拓扑
```
┌──────────────────────┐
80/443 ───→ │ Nginx │
│ / → :3000 │ Next.js
│ /admin/ → :3001 │ Vite (dist/ 静态)
│ /api/ → :8080 │ Spring Boot
└──────────────────────┘
│
▼
┌──────────────────────────────┐
│ MySQL :3306 + Redis :6379 │
└──────────────────────────────┘
```
### Nginx 片段
```nginx
server {
listen 80;
server_name yao-nav.example.com;
# 用户站
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 管理后台(dist 静态产物 + SPA 兜底)
location /admin/ {
alias /opt/yao-nav/admin/dist/;
try_files $uri $uri/ /admin/index.html;
}
# 后端 API
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 10M;
}
}
```
### 后端打包与启动
```bash
cd backend
mvn -DskipTests clean package
SPRING_PROFILES_ACTIVE=prod \
YAONAV_AI_API_KEY=sk-xxx \
bash start.sh
# 后台运行:
nohup bash start.sh > /dev/null 2>&1 &
```
### 前端构建
```bash
# 用户站
cd frontend
NEXT_PUBLIC_API_URL=https://yao-nav.example.com/api pnpm build
pnpm start # 监听 3000
# 管理后台
cd admin
pnpm build # 产物在 dist/,部署到 /opt/yao-nav/admin/dist/
```
---
## 环境变量清单
| 变量名 | 作用域 | 默认值 | 说明 |
| --------------------------------------- | -------- | --------------------------------------------------------------- | ----------------------------------------------------- |
| `YAONAV_AI_API_KEY` | 后端 | 空 | 阿里云百炼 API Key;不设则书签 AI 分类 / 采集 AI 评分降级 |
| `YAONAV_GITHUB_TOKEN` | 后端 | 空 | GitHub PAT;填后采集限流 60→5000 次/小时 |
| `SPRING_PROFILES_ACTIVE` | 后端 | `prod`(start.sh) | 切环境配置 |
| `NEXT_PUBLIC_API_URL` | 用户站 | `http://localhost:8080/api` | 前端调用后端的基础地址(构建期注入) |
| `YAONAV_OAUTH_ENABLED` | 后端 | `false` | OAuth 总开关。`true` 才会加载 GitHub / Google 登录 |
| `YAONAV_OAUTH_FRONTEND_URL` | 后端 | `http://localhost:3000` | 用户站地址,用于 OAuth 完成后回跳 |
| `YAONAV_OAUTH_ADMIN_URL` | 后端 | `http://localhost:3001/admin` | 管理后台地址(**含 /admin 子路径**),用于 OAuth 回跳 |
| `YAONAV_OAUTH_GITHUB_ENABLED` | 后端 | `false` | 启用 GitHub 登录 |
| `YAONAV_OAUTH_GITHUB_CLIENT_ID` | 后端 | 空 | GitHub OAuth App 的 Client ID |
| `YAONAV_OAUTH_GITHUB_CLIENT_SECRET` | 后端 | 空 | GitHub OAuth App 的 Client Secret |
| `YAONAV_OAUTH_GITHUB_REDIRECT_URI` | 后端 | `http://localhost:8080/api/auth/oauth/github/callback` | 必须和 GitHub 后台填的回调地址完全一致 |
| `YAONAV_OAUTH_GOOGLE_ENABLED` | 后端 | `false` | 启用 Google 登录 |
| `YAONAV_OAUTH_GOOGLE_CLIENT_ID` | 后端 | 空 | Google OAuth Client ID |
| `YAONAV_OAUTH_GOOGLE_CLIENT_SECRET` | 后端 | 空 | Google OAuth Client Secret |
| `YAONAV_OAUTH_GOOGLE_REDIRECT_URI` | 后端 | `http://localhost:8080/api/auth/oauth/google/callback` | 必须和 Google Console 填的回调地址完全一致 |
| `YAONAV_OAUTH_GITEE_ENABLED` | 后端 | `false` | 启用 Gitee 登录 |
| `YAONAV_OAUTH_GITEE_CLIENT_ID` | 后端 | 空 | Gitee OAuth Client ID |
| `YAONAV_OAUTH_GITEE_CLIENT_SECRET` | 后端 | 空 | Gitee OAuth Client Secret |
| `YAONAV_OAUTH_GITEE_REDIRECT_URI` | 后端 | `http://localhost:8080/api/auth/oauth/gitee/callback` | 必须和 Gitee 应用后台填的回调地址完全一致 |
> 后端的 MySQL 用户名/密码当前是写在 `application.yml` 里。生产环境建议也提到环境变量:把 `spring.datasource.username/password` 改成 `${DB_USER}` / `${DB_PASS}`。本仓库暂未做此改造。
---
## OAuth 第三方登录配置(GitHub / Google)
OAuth 默认**关闭**,需要在 OAuth 提供商后台创建应用 + 设置环境变量后才会启用。开启后用户站登录弹窗、管理后台登录页都会自动出现对应按钮(前端通过 `/api/auth/oauth/providers` 动态拉取)。
### 用户匹配规则(登录时)
| 场景 | 行为 |
| --------------------------------- | ------------------------------------------------------------------------------------- |
| `sys_user_oauth_binding` 已有该 (provider, providerId) | 老用户,直接登录 |
| 邮箱已被密码注册账号占用 | **自动绑定**:在 binding 表写入记录,下次密码或 OAuth 都能登录 |
| 都没有 → 创建新用户 | 受系统设置 `allowRegister` 控制;关闭注册时 OAuth 也无法创建 |
| OAuth 创建的新用户 | 角色 `USER`、status=1。要进 admin 后台,需要先用现有 ADMIN 账号在用户管理里改角色 |
### 用户在设置中心主动绑定 / 解绑
已登录用户进 **个人中心 → 个人设置 → 第三方账号绑定**(settings 页最下方面板):
- **绑定**:点 "立即绑定" → 跳到 GitHub/Google 授权 → 授权后跳回 settings 页并 toast "已绑定"
- **解绑**:点 "解绑" → 弹 confirm → 后端校验后清掉 `sys_user_oauth_binding` 记录
- 一个用户可以**同时绑定** GitHub 和 Google
- 一个 GitHub/Google 账号同一时间只能绑到一个用户上(绑别人时会报"该账号已被其他用户绑定")
#### 解绑边界
后端会拒绝以下解绑:
- 用户密码是 OAuth 注册时的占位 hash(无法用密码登录) **且** 没有其他 OAuth 绑定 → 拒绝并提示 "解绑后将无法登录,请先设置密码或绑定其他第三方账号"
> 当前还没有"为 OAuth 用户设置密码"的入口,下个版本可以在个人中心补一个(区别于"修改密码",不需要旧密码)。在那之前,OAuth 注册的用户解绑前必须先绑另一个 OAuth。
### 相关 API
| 方法 | 路径 | 鉴权 | 说明 |
| ------ | --------------------------------------------- | -------- | ---------------------------------------- |
| GET | `/api/auth/oauth/providers` | 公开 | 列出已启用 provider(前端按钮用) |
| GET | `/api/auth/oauth/{provider}?from=...` | 公开 | 发起登录授权(302 到 GitHub/Google) |
| GET | `/api/auth/oauth/{provider}/callback` | 公开 | 提供商回调入口(302 回前端) |
| GET | `/api/user/oauth/bindings` | 需登录 | 我已绑定的 provider 列表 |
| POST | `/api/user/oauth/{provider}/bind-url` | 需登录 | 获取绑定授权 URL(前端拿到后整页跳转) |
| DELETE | `/api/user/oauth/{provider}` | 需登录 | 解绑 |
### GitHub OAuth App 创建步骤
1. 打开 https://github.com/settings/developers
2. 点 **New OAuth App**(注意是 OAuth Apps,不是 GitHub Apps)
3. 填写:
| 字段 | 值 |
| ---------------------------- | ------------------------------------------------------------------- |
| Application name | `YAO-NAV`(任意) |
| Homepage URL | 本地 `http://localhost:3000`,线上填用户站域名 |
| Authorization callback URL | 本地 `http://localhost:8080/api/auth/oauth/github/callback` |
4. 创建后页面顶部显示 **Client ID**(明文);点 **Generate a new client secret** 生成 Client Secret(只显示一次,立刻复制)
### Gitee OAuth 应用创建步骤
1. 登录 https://gitee.com → 头像 → **设置** → **第三方应用** → 直接打开 https://gitee.com/oauth/applications
2. 点 **创建应用**
3. 填写:
| 字段 | 值 |
| ---------------- | ----------------------------------------------------------------- |
| 应用名称 | `YAO-NAV`(任意) |
| 应用主页 | 本地 `http://localhost:3000`,线上填用户站域名 |
| 应用回调地址 | 本地 `http://localhost:8080/api/auth/oauth/gitee/callback` |
| 权限范围 | 勾选 `user_info` 和 `emails` |
4. 创建后在应用列表点进去就能看到 **Client ID** 和 **Client Secret**
### Google OAuth Client 创建步骤
1. 打开 https://console.cloud.google.com/,选/建一个项目
2. **APIs & Services → OAuth consent screen** → 配置同意屏(External 类型可对所有 Google 账号开放)
- User Type: External
- 应用名 / 用户支持邮箱 / 开发者邮箱
- Scopes 加 `openid`、`.../auth/userinfo.email`、`.../auth/userinfo.profile`
- Test users(如果还在 Testing 状态):加上你自己的 Gmail 才能登录测试
3. **APIs & Services → Credentials → CREATE CREDENTIALS → OAuth client ID**
- Application type: **Web application**
- Name: YAO-NAV
- **Authorized JavaScript origins**:
- `http://localhost:3000`(用户站 dev)
- `http://localhost:3001`(admin dev)
- 线上:用户站和管理后台域名
- **Authorized redirect URIs**:
- `http://localhost:8080/api/auth/oauth/google/callback`
- 线上:`https://你的后端域名/api/auth/oauth/google/callback`
4. 创建后弹窗显示 Client ID 和 Client Secret
### 设置环境变量并重启后端
```bash
# 总开关
export YAONAV_OAUTH_ENABLED=true
# GitHub
export YAONAV_OAUTH_GITHUB_ENABLED=true
export YAONAV_OAUTH_GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxxxxxx
export YAONAV_OAUTH_GITHUB_CLIENT_SECRET=ghps_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Google
export YAONAV_OAUTH_GOOGLE_ENABLED=true
export YAONAV_OAUTH_GOOGLE_CLIENT_ID=xxxxxxxxx-yyyyyy.apps.googleusercontent.com
export YAONAV_OAUTH_GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxx
# Gitee
export YAONAV_OAUTH_GITEE_ENABLED=true
export YAONAV_OAUTH_GITEE_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
export YAONAV_OAUTH_GITEE_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# 线上部署还需要改回跳基址(和 OAuth 后台的 redirect_uri 配套)
# export YAONAV_OAUTH_FRONTEND_URL=https://你的用户站
# export YAONAV_OAUTH_ADMIN_URL=https://你的管理后台域名/admin
# export YAONAV_OAUTH_GITHUB_REDIRECT_URI=https://你的后端域名/api/auth/oauth/github/callback
# export YAONAV_OAUTH_GOOGLE_REDIRECT_URI=https://你的后端域名/api/auth/oauth/google/callback
# 然后重启 Spring Boot 进程
mvn spring-boot:run
```
只配 GitHub 不配 Google 也可以——前端只显示已启用的按钮。
### OAuth 流程速查
```
[用户站 / admin] 点按钮
→ 浏览器 GET /api/auth/oauth/{provider}?from=frontend|admin
→ 后端生成 state(Redis 存 5min)→ 302 到 GitHub/Google 授权页
→ 用户授权
→ GitHub/Google 302 回 /api/auth/oauth/{provider}/callback?code=...&state=...
→ 后端校验 state、用 code 换 access_token、拉用户信息
→ 找/建用户 → Sa-Token 颁发 token
→ 302 回 ${frontend|admin-base-url}/oauth-callback?token=...&provider=...&role=...
→ 前端 callback 页写 localStorage、调 /auth/check、跳首页
```
### 安全要点
- **state 参数**:每次随机生成 UUID,存 Redis 5 分钟一次性消费,CSRF 防护。
- **secret 永远不入代码库**:所有敏感值走环境变量,application.yml 里只有 `${YAONAV_OAUTH_*:}` 占位符。
- **GitHub 邮箱 private**:自动调 `/user/emails` 取 `primary && verified` 那一封。都没有则拒绝登录。
- **Google 必须 email_verified**:未验证邮箱拒绝登录。
- **admin 端登录后会校验 `role==='ADMIN'`**:OAuth 新用户进不了后台,必须先由 ADMIN 在管理后台改角色。
### 故障排查
| 现象 | 排查 |
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| 登录弹窗里看不到 GitHub / Google 按钮 | `YAONAV_OAUTH_ENABLED=true` 没设,或对应 provider 的 ENABLED / CLIENT_ID / CLIENT_SECRET 缺失;后端启动时不会加载该 provider |
| GitHub 跳转后报 `redirect_uri_mismatch` | OAuth App 后台填的回调地址和 `YAONAV_OAUTH_GITHUB_REDIRECT_URI` 不一致(差 https/端口/路径任一字符都不行) |
| Google 跳回时报 `Error 400: redirect_uri_mismatch` | 同上,且 Google 还要求所有 origins 都在 Authorized JavaScript origins 列表里 |
| 跳回后报"OAuth 状态已过期或非法" | state 在 Redis 5min 内被消费过,或同一 state 被回放;重新点登录按钮再来一次即可 |
| 报"无法从 GitHub 获取已验证邮箱" | GitHub 账号的 emails 都是 private 且没有 verified primary;让用户去 https://github.com/settings/emails 设置 |
| OAuth 用户登录后想用密码登录 | 当前 OAuth 用户的密码是占位 hash,无法用密码登录;需要在个人中心增加"设置密码"功能(待补充) |
## 开发约定
- **沟通语言**:中文。改完代码后**列出文件清单**并提示是否需要重启服务。
- **Git**:单 monorepo(admin + backend + frontend)。仓库根目录的 `.gitignore` 已统一管理。
- **改动后是否需要重启**:
- **后端**:改 `*.java` / `application.yml` / `pom.xml` / 环境变量 → 必须重启
- **frontend**:改 `next.config.ts` / `tsconfig.json` / `postcss.config.mjs` / `package.json` / env → 必须重启;改 `*.tsx` / `*.css` → HMR
- **admin**:改 `vite.config.ts` / `tsconfig*.json` / `components.json` / `package.json` → 必须重启;改 `*.tsx` / `*.css` → HMR
- **依赖**:前端统一 `pnpm`;后端统一 `mvn`。
- **新增表 / 改字段**:
1. 直接改 `init.sql`(完整覆盖;重置库时一把执行即可)
2. 必要时加 `common/config/*SchemaMigrator.java`(或 `auth/config/`)做启动自愈,让老库无侵入升级
---
## 故障排查
| 症状 | 排查方向 |
| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| 启动报 `Data truncation: Data too long for column 'icon'` | 老库的 `nav_website.icon` 还是 VARCHAR(2048);启动时 `WebsiteSchemaMigrator` 会自动扩到 MEDIUMTEXT,新部署 init.sql 已含 |
| FULLTEXT 索引创建失败 | MySQL 版本需 ≥ 8.0.29(init.sql 用了 `IF NOT EXISTS` 语法) |
| 阿里云百炼调用 401 | `YAONAV_AI_API_KEY` 未注入,或 Key 写错了,或欠费 |
| 推荐网站每日 0:05 没排序 / 没刷新 | 看 `sys_scheduled_task_log` 最新一条的 `error_msg` / `ai_error_details`,并确认机器时区是 Asia/Shanghai |
| 用户改了"系统设置"但没生效 | 系统设置存 Redis,确认 Redis 实例没换;后端读取 key `system:settings` |
| 个人中心刷新看不到欢迎弹窗 | `last_welcome_date` 已记今天;改其他用户或 `UPDATE sys_user SET last_welcome_date=NULL WHERE id=...` |
| 默认空间首页推荐网站列表少 | 检查 `sys_recommended_website` 数量;启动日志里 `DefaultDataInitializer` 应有 88 条幂等补齐 |
| 频繁 `hs_err_pid*.log` 在工作目录 | JVM native OOM;查 `./heapdump/*` 转储;当前已配 `-Xmx2g` 防止失控 |
| 浏览器 401 跳回首页 | Token 过期(默认 7 天),或 Sa-Token 在 Redis 里被清 |
| `pnpm dev` 起不来端口冲突 | frontend 默认 3000,admin 默认 3001;用 `pnpm dev --port xxx` 或改 vite/next 配置 |
---
## 参考与延伸
- 后端架构与依赖细节:[backend/README.md](backend/README.md)
- 用户站细节:[frontend/README.md](frontend/README.md)
- 管理后台细节:[admin/README.md](admin/README.md)
- 早期设计稿:[architecture.md](architecture.md)
- 书签导入设计:[bookmark-import-design.md](bookmark-import-design.md)