# 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)

YaoNav 主页截图

--- 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)