先说个背景。我帮人做了不少“课程设计级”的 Web 项目,发现一个规律:同样是笔记记录分享网站,有人做得像博客后台,点开全是页面堆砌;有人做得像内容社区,注册、发笔记、设公开、打标签、被搜索、被浏览,一整套动作能闭环。差别不在页面多少,而在于数据模型和权限边界有没有认真设计。这篇文章就是基于我用 Java SpringBoot + Vue3 + MyBatis + MySQL 搭建笔记记录分享网站系统源码的全过程来写的,前后端分离,接口式联调,涵盖核心模块设计、数据库建模、登录鉴权、笔记发布与分享、搜素检索,以及部署阶段遇到的各种坑。想从零跑通一套完整 Web 项目,尤其是准备做类似课题、需要自己动手写系统源码的朋友,可以直接照着这份思路落地。
笔记类系统最容易纠结的问题就是:做了一个像是博客的东西,又不太像博客,所以没必要照搬博客的导航、评论、分类那套逻辑;它是一个“用户自己生产内容、按意愿公开分享”的系统。读这篇文章之前,你最好有点 Java 基础、了解 Vue 基本语法,如果都不会,建议先把基础语法过一遍再回来,不然看代码细节会吃力。我下面讲的不是某个固定框架生成的模板代码,而是从业务出发一步步推导出来的设计过程。
1. 从需求到选型:笔记分享站为什么不是个普通博客
1.1 用户到底想要什么
做系统之前最忌讳一上来就建表。我先整理了几个核心使用场景,后面所有设计都围绕这些场景来:
- 用户注册登录后,可以新建一篇笔记,笔记支持富文本内容,也可以只写纯文本。
- 笔记创建时可选“公开”或“私有”,公开后其他登录用户能浏览、搜索到。
- 用户可以对笔记打标签、选分类,方便自己管理和别人按标签找内容。
- 未登录用户可以浏览公开笔记列表和公开笔记详情,但需要登录后才能收藏、点赞。
- 用户需要能通过一个分享链接,把某篇笔记分享到外部场景,对方打开链接直接能看到只读内容。
这些场景决定了系统不能再简单套用“文章 + 评论”的博客模型。博客的数据关系往往是单作者的,笔记系统则要求每一篇笔记都有明确的归属用户,同时还要维护“公开/私有”两种可见性状态。权限判断不再是“管理员/普通用户”这种扁平的二元关系,而变成了“所有人可读 / 仅自己可写 / 仅自己可见”的细粒度控制。
1.2 技术选型为什么是 SpringBoot + Vue3 + MyBatis + MySQL
这套组合在目前的单体业务系统里属于“水桶型”配置,每一项都不是最时髦的,但组合起来非常稳。
后端用 SpringBoot,核心理由有三个:第一,它把 Tomcat 内嵌、依赖管理、自动配置都处理好了,起步快,不需要为了一个接口去折腾容器配置;第二,生态里做鉴权、文件上传、参数校验都有现成方案,课程设计和中小型项目里基本不用重复造轮子;第三,Java 的强类型对团队协作很友好,接口传参错误在编译期就能暴露一部分,后期维护时看代码也能更快定位问题。如果换成 Node 或 PHP,写起来虽然短,但一旦业务逻辑复杂,类型不安全的成本会逐渐显现。
前端选 Vue3,主要是组合式 API 更适合一个功能中等偏上、需要拆组件协作的系统。相比 Vue2 的选项式写法,Vue3 用setup组织响应式变量、计算属性和监听器,逻辑内聚性更强;配合 Vite 的开发服务器,热更新的响应速度也明显比 Webpack 时代舒服。组件化开发放这个项目里不是炫技,笔记编辑器、标签选择器、卡片列表这些都会被多处复用,拆成组件管理比堆在一个页面里好维护太多。
持久层用 MyBatis,是因为它保留 SQL 的灵活性。这个项目里有很多带条件的分页查询、多表关联查询、权限过滤查询,用 MyBatis 写 XML 时可以通过<where>、<if>、<foreach>动态拼筛条件,SQL 优化也直观可见。对比 JPA,MyBatis 的学习成本更低,对 SQL 不熟练的人也更容易理解“我查的到底是哪几张表、哪个字段”。MySQL 则是关系型数据存储的稳定选择,ACID 特性、事务能力、索引支持都够用,部署成本也低。
1.3 整体运行链路
系统采用前后端分离部署,但开发时可先用联调模式跑。前端通过 Axios 请求后端接口,后端暴露http://localhost:8080/api/**的 RESTful 接口;用户请求先打前端页面,页面再去调接口拿数据;接口收到请求后先经拦截器或过滤器校验 Token,再进入控制器层,控制器调用服务层,服务层通过 MyBatis Mapper 访问 MySQL。这个链路里,权限校验不是只在拦截器做一次,而是在涉及笔记归属、私有笔记访问等具体业务里各查一次,宁可多查一次数据库,也不在没有数据支撑的情况下做信任判断。
我把后端和前端分成两个独立目录:backend放 SpringBoot 工程,frontend放 Vue3 工程。这样目录清晰,两个人同时开发也不会互相踩。后面所有模块拆解都按这个结构来讲。
2. 数据模型与表结构设计:从 user_id 开始就把路铺好
2.1 核心表清单
这个系统一期核心表我设计了六张,不算太多,但每张表的关系需要想清楚:用户表、笔记表、分类表、标签表、笔记标签关联表、用户收藏表。如果后面要加点赞、评论,可以再扩展,但项目起步阶段不要贪多,先把主链路跑通。
表结构设计最重要的一条经验是:把“谁的数据”放在每一张业务表的第一个外键位置。用户表用自增id做主键,笔记表必须带user_id,从设计上保证每篇笔记都能追溯到作者。这样后面做权限判断、统计个人笔记数、生成个人主页都很方便。
| 表名 | 作用 | 核心字段 |
|---|---|---|
| users | 用户账号信息 | id、username、password、avatar、nickname、create_time |
| notes | 笔记主体 | id、user_id、category_id、title、content、summary、cover_image、status、share_code、view_count、like_count、create_time、update_time、deleted |
| categories | 笔记分类 | id、name、sort_order |
| tags | 标签字典 | id、name |
| note_tags | 笔记与标签关联 | id、note_id、tag_id |
| favorites | 用户收藏 | id、user_id、note_id、create_time |
2.2 建表语句要点
笔记表是最核心的一张表,我把建表语句要点列出来,字段命名全部用下划线风格,和 Java 实体类里的驼峰命名在 MyBatis 配置里做自动映射。
CREATE TABLE notes ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '笔记ID', user_id BIGINT NOT NULL COMMENT '作者用户ID', category_id BIGINT DEFAULT NULL COMMENT '分类ID', title VARCHAR(200) NOT NULL COMMENT '笔记标题', content LONGTEXT COMMENT '笔记内容,存富文本或Markdown原文', summary VARCHAR(500) DEFAULT NULL COMMENT '摘要,列表页展示用', cover_image VARCHAR(500) DEFAULT NULL COMMENT '封面图URL', status TINYINT NOT NULL DEFAULT 0 COMMENT '0私有 1公开', share_code VARCHAR(32) DEFAULT NULL COMMENT '公开分享码', view_count INT DEFAULT 0 COMMENT '浏览数', like_count INT DEFAULT 0 COMMENT '点赞数', deleted TINYINT DEFAULT 0 COMMENT '软删除标记', create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, KEY idx_user_status (user_id, status), KEY idx_share_code (share_code), KEY idx_create_time (create_time), KEY idx_category (category_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段类型有几点容易踩坑。content用LONGTEXT,不要用VARCHAR,因为笔记内容可能很长,VARCHAR在 MySQL 里最长是 65535 字节,换算成中文字符没多少,很容易被截断;用TEXT也可以,但富文本带图片的话LONGTEXT更保险。status用TINYINT不用布尔值,因为后续可能扩展“草稿、审核、封禁”等多种状态,布尔类型不好扩展。deleted是软删除标记,业务层查询时统一带deleted = 0,物理删除只留给管理员手动处理。
索引设计上,idx_user_status这个复合索引特别关键,因为“某个用户的笔记列表”是这个系统最频繁的查询场景,如果只建单列索引,WHERE 条件同时过滤 user_id 和 status 时性能会下降。愿意更极致一点的话,可以再加一个(user_id, status, create_time)的索引,让排序也走索引,但数据量不大的情况下我不建议堆太多索引,写操作性能会受拖累。
2.3 几个设计决策的说明
第一个决策:分类和标签为什么要分开?分类是单选、层级简单、数量少,适合在左侧导航里做固定入口;标签是多对多、自由创建、数量多,适合给每篇笔记打多个标签。如果只留一个分类,笔记覆盖主题有限;如果只用标签,导航结构就松散。两个一起用,相当于既有“目录”又有“关键词索引”。
第二个决策:为什么笔记表里放summary摘要字段?很多初学项目会直接在列表页干掉content或者用substring截取,这样既不高效也不优雅。更好的做法是用户在保存笔记时自动截取前 200 个纯文本字符存入summary,列表页只查小结字段。这样查询体积小、渲染快,也避免了把大段 HTML 传给前端导致的布局崩坏。
第三个决策:分享码为什么要单独建字段而不是直接用笔记 ID?直接暴露笔记 ID 的分享链接很容易被遍历,别人通过修改 URL 里的数字就能看到你没打算公开的笔记。虽然我们还有权限校验,但从工程习惯上就不该暴露内部自增 ID。我用的方案是生成一个 32 位随机字符串存到share_code,查询时只看分享码,内部 ID 只用于编辑、删除等操作。
3. 后端核心实现:登录鉴权与笔记管理的接口实践
3.1 注册登录与 Token 机制
后端接口设计围绕两个身份状态:未登录的游客和已登录的用户。登录鉴权我选择基于 Token 的方式,没有引入 Spring Security 全家桶,因为这套系统需要的是轻量拦截,不是复杂权限模型。核心流程:注册时密码用 BCrypt 加密后入库;登录时校验密码成功后生成 Token 返回前端;前端后续请求在 Header 携带 Token;后端用拦截器解析 Token 并把当前用户 ID 放入请求上下文。
BCrypt 不是普通哈希,它会自动加盐,每次加密结果都不同,数据库中即便两个用户密码相同,存储的密文也不一样,这能有效抵抗彩虹表攻击。使用 Spring Security 里的BCryptPasswordEncoder可以单独引入,不需要启用整个过滤器链。
Token 我用的是 JWT,结构上分成 Header、Payload、Signature 三段,Payload 中只放userId和expireTime,不放密码等敏感信息。生成 Token 的简化逻辑如下:
public String generateToken(Long userId, long expireMillis) { long now = System.currentTimeMillis(); Date nowDate = new Date(now); Date expireDate = new Date(now + expireMillis); return Jwts.builder() .claim("userId", userId) .setIssuedAt(nowDate) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }为什么用 JWT 而不是传统的 Session?因为前后端分离后,后端接口可能同时服务于 Web、小程序、移动端等多种客户端,Session 依赖 Cookie 和服务端存储,跨端时要处理会话同步问题。JWT 是无状态的,后端只有验签逻辑,天然适合接口场景。但注意 JWT 有个明显的缺点:无法主动失效。用户修改密码或者被封禁后,旧 Token 在过期前仍然有效。对这个项目影响不大,但如果你要上线生产,建议把 Token 版本号也放到数据库里,校验时对比一下。
3.2 笔记列表和详情接口的权限过滤
笔记列表是系统里访问最频繁的接口之一,我没有简单写一个“查询所有笔记”的 SQL,而是根据场景拆成了三个方法:公开笔记列表、我的笔记列表、某个用户的公开笔记列表。这三个方法业务含义不同,但都能复用同一个动态 SQL 模板。
公开笔记列表的查询条件应该是:status = 1 AND deleted = 0,如果要支持分类筛选再加category_id = #{categoryId}。这里最关键的 SQL 技巧是条件判断放在 XML 中动态拼接,不能在 Java 服务层人为判断后再拼字符串,否则容易产生 SQL 注入风险。
<select id="selectPublicNotePage" resultType="NoteVO"> SELECT id, title, summary, cover_image, view_count, like_count, status, create_time, update_time FROM notes <where> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR summary LIKE CONCAT('%', #{keyword}, '%')) </if> AND status = 1 AND deleted = 0 </where> ORDER BY create_time DESC </select>这种设计有几个隐藏好处:列表接口不返回content字段,节省了网络传输;动态 SQL 避免了写死 SQL 导致的多余查询;权限过滤直接写在 SQL 里,任何人即使绕过 Java 层,也很难拿到非公开数据。
详情接口则更严格,我没法只用id去查笔记,因为私有笔记只有作者本人和自己可见,游客或别的用户不应该能访问。我把详情查询做成了带“当前用户 ID”的过程:查询时同时校验status = 1或user_id = #{currentUserId},再过滤deleted = 0。这个权限判断必须在数据库查询里完成,不能在 Java 代码里查出结果后判断,因为查询失败和查到数据后拒绝返回是两种不同的行为,后者会白白暴露数据是否存在。
3.3 编辑、删除与越权防护
编辑接口比详情接口更敏感。前端传noteId和新的标题、内容,后端首先要做“归属校验”:“这篇笔记是否属于当前登录用户”。我写了一个通用的校验服务,传入noteId和userId,返回一个布尔值:
public boolean isNoteOwner(Long noteId, Long userId) { Note note = noteMapper.selectById(noteId); return note != null && note.getUserId().equals(userId); }更新时应该只更新允许修改的字段,比如title、summary、content、category_id、status。万万不能直接把前端传的对象全字段 update,否则用户可以顺带把view_count、like_count、create_time等字段一起改了。我用的方案是定义独立的更新 DTO,只接收需要修改的字段,Mapper 的 XML 里用<if>判断字段是否为空再决定是否更新,这样也解决了“部分字段不想改成空值”的问题。
删除接口我统一做软删除,即把deleted字段置为 1,不真正删行。对内容型系统来说,误删后想要恢复会非常麻烦,而且笔记可能被其他人收藏过,物理删除会导致收藏记录变成脏数据。软删除的好处是保留历史,恢复也容易,缺点是每一条查询都要记得带上deleted = 0条件,否则会把“已删除”的数据查出来。
4. Vue3 前端落地:编辑器、拦截器与笔记状态管理
4.1 前端项目结构与基础配置
前端使用 Vue3 的组合式 API,配合 Vite 工程化搭建。项目结构分成几个清晰的目录:api按资源模块放接口请求函数,views放页面组件,router放路由配置,stores放 Pinia 状态管理,components放公共组件。
路由按功能拆分成三类:不需要登录的公开页面,如登录页、注册页、公开笔记详情页;需要登录的页面,如个人笔记列表、新建笔记页、编辑笔记页;可以公开访问但登录后表现不同的页面,比如首页公开笔记列表。
const routes = [ { path: '/login', component: LoginPage }, { path: '/register', component: RegisterPage }, { path: '/', component: HomePage }, { path: '/note/:id', component: NoteDetailPage }, { path: '/note/create', component: NoteEditPage, meta: { requiresAuth: true } }, { path: '/note/:id/edit', component: NoteEditPage, meta: { requiresAuth: true } }, { path: '/my/notes', component: MyNotesPage, meta: { requiresAuth: true } } ]4.2 编辑器选型与内容处理
笔记系统的核心交互是编辑内容,所以编辑器选型直接影响体验。我将需求拆成两层:普通用户编辑笔记时,希望像写 Word 一样有工具栏,点击加粗、插入图片;开发人员存储时,希望内容是可序列化的 HTML,前端详情页直接用富文本渲染,不需要额外解析。
基于这个需求,我推荐使用所见即所得的富文本编辑器,而不是 Markdown 编辑器。Markdown 对技术人友好,但目标用户如果包含非技术背景的人,学习成本就高了;偏远一点说,如果你的笔记里要贴表格、截图、流程图,富文本的上手难度远远低于 Markdown。
编辑器的接入要注意防 XSS。用户提交的富文本内容可能包含<script>标签或者onerror事件,前端在提交前可以用白名单过滤,后端在接收时最好再做一次校验或转义。更严格的做法是后端使用一个 HTML 清洗工具,只允许保留<p>、<strong>、<img>、<h1>等安全标签,别的都过滤掉。这一步千万不要省,否则用户 A 可以在笔记里写段恶意脚本,用户 B 打开详情页时就会被攻击。
4.3 Axios 拦截器与路由守卫
前后端分离后,前端每个接口请求都要自动携带 Token,后端返回 401 时前端要统一处理跳转登录。我用一个 Axios 实例统一封装请求逻辑:
const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) config.headers.Authorization = 'Bearer ' + token return config }) service.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(error) } )这里的拦截器把“每个请求都加 Token”和“401 自动处理”集中到一层,避免每个页面重复写判断逻辑。路由守卫负责页面级的跳转控制,进入需要登录的页面时,检查是否存了 Token:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) next('/login') else next() })注意,前端路由守卫只是门面,真正的鉴权必须靠后端接口校验。前端防君子不防小人,有时候用户直接手动改 localStorage,守卫形同虚设,但后端接口如果做了 Token 和归属校验,数据安全才能兜住底。
4.4 笔记状态管理
笔记的“公开/私有”切换是一个状态变化,我用 Pinia 保存用户全局信息:登录用户的 ID、昵称、头像。编辑器页面里,笔记的编辑状态是局部的,不需要全局状态管理,用组件内部的ref即可。Pinia 在这里的核心作用是避免登录后刷新页面丢失用户信息:刷新时从本地缓存恢复,并请求一次/api/user/info接口拉取最新用户数据。
开发时踩过一个细节坑:从用户信息接口返回的字段也可能是null,比如新用户还没设置昵称。前端处理时要给默认值,否则模板渲染时会出现空白或者报错。我习惯在 Pinia 的 getter 里做空值兜底,保证任何页面拿到的都是可用字符串。
5. 分享与检索链路:让笔记被看到,也要防止被滥看
5.1 公开状态、分享码与只读页面
分享功能是这个系统区别一般笔记工具的地方。用户在编辑页点“保存并公开”时,后端如果发现笔记当前状态不是公开,就生成一个share_code并写入。分享链接的格式是/share/{shareCode},前端路由里对应一个只读页面,只展示笔记内容,不显示编辑按钮,也不加载编辑器的资源文件,干净轻量。
生成分享码的代码很简单,但要注意唯一性:
public String generateShareCode() { String code = UUID.randomUUID().toString().replace("-", ""); return code.substring(0, 16); }小规模场景用 UUID 截断就够用。上线后如果不放心唯一性,可以查一次表确认没有重复,再插入或更新。分享码不要用纯数字,纯数字的短链接太容易被遍历;混合大小写和数字的 16 位字符串相对安全得多。
只读页面通过GET /api/notes/share/{shareCode}获取笔记,接口内部逻辑是:按share_code查询笔记,再判断status = 1 AND deleted = 0,条件不满足就返回 404,而不是返回 200 然后给空数据。这里语义上要“假装不存在”,避免泄露笔记存在状态。
5.2 标签、分类与关键字搜索的组合查询
分类筛选、标签筛选、关键字搜索这三个功能组合在一起时,很容易写出性能极差的 SQL。我的做法是在列表接口中接收一个条件对象:categoryId、tagId、keyword、page、size。
如果按标签筛选,就不能只查 notes 表,需要关联note_tags和tags表。先查tag_id对应的关联记录,再只返回满足条件的笔记 ID。这个操作在数据量不大时可以放在一条子查询里:
WHERE id IN ( SELECT note_id FROM note_tags WHERE tag_id = #{tagId} )这种写法比JOIN在大表场景下更容易走索引,语义也更直观。关键字搜索我用LIKE CONCAT('%', #{keyword}, '%')先跑通,数据量超过十万条后建议升级为 MySQL 全文索引。全文索引对于中文分词有额外要求,默认分词器效果一般,如果真要考虑中文分词,可能还得引入专门的检索引擎,但那是后话,项目初期不建议为性能过度设计。
5.3 浏览量、点赞数与收藏的实现细节
这三个功能看着简单,实现起来也有坑。浏览量的真实场景是“同一用户短时间内反复刷新不能反复加 1”。我做的方案是前端拿到笔记 ID 后,在详情页加载时调用一个独立的浏览量接口,后端用 Redis 做去重,不过这个项目为了不增加环境依赖,我用简单的方式:同一个会话一次增加一次浏览量,通过一个带时间戳的 Map 做短暂去重,保证同一用户五分钟内不会重复计数。
点赞和收藏则必须依赖用户登录状态。收藏表favorites要建一个唯一索引(user_id, note_id),防止用户重复收藏。点赞我不单独建表,直接对笔记表的like_count做UPDATE notes SET like_count = like_count + 1 WHERE id = #{noteId}。为什么不先查后写?因为并发时先查后写会丢更新,直接走原子更新更稳。如果要支持取消收藏、取消点赞,就需要记录用户行为,单独建表更清晰。收藏记录在个人中心里要能显示笔记列表,删除笔记时收藏记录也要一并清理。
6. 环境配置、联调与部署踩坑记录
6.1 跨域的三种处理方式
前后端分离后,开发环境下前端跑在 Vite 的 5173 端口,后端跑在 8080 端口,跨域问题是跑不掉的。解决方式有三种:后端@CrossOrigin注解、后端全局 CORS 配置、前端 Vite 代理。生产环境则用 Nginx 反向代理。
开发阶段我推荐使用 Vite 代理,因为它在浏览器层表现为同源请求,后端不需要额外处理跨域逻辑:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }后端如果同时也要允许某些测试工具直接调用,可以配置一个全局 CorsFilter,但注意不要把允许来源直接设成*,否则携带 Token 的请求会被浏览器拦掉。使用代理解方案最干净,后端只需要专注业务接口。
6.2 MyBatis 的常见坑位
第一个坑是下划线字段到驼峰属性映射。默认情况下 MyBatis 不会把user_id自动匹配为userId,必须在application.yml里开启:
mybatis: configuration: map-underscore-to-camel-case: true不开这个配置,你会发现查询结果里的userId永远是 null,而user_id又是 Java 实体类里不存在的属性名,纠结半天查不到原因。
第二个坑是更新语句只更新部分字段。如果在 XML 里写死UPDATE notes SET title = #{title}, content = #{content}, summary = #{summary} WHERE id = #{id},那么前端只传了标题时,content 和 summary 就会被置空。必须用<set>加<if>动态拼接:
<update id="updateSelective"> UPDATE notes <set> <if test="title != null">title = #{title},</if> <if test="content != null">content = #{content},</if> <if test="summary != null">summary = #{summary},</if> update_time = now() </set> WHERE id = #{id} AND user_id = #{userId} </update>第三个坑是插入后拿不到主键。默认情况下 MyBatisinsert返回的是影响行数,不是主键。如果需要插入后立刻操作这条数据,要在insert标签里配置useGeneratedKeys="true" keyProperty="id"。笔记打完标签后,需要把标签关联记录插到note_tags表,这个场景就必须拿到新笔记的id。
6.3 数据库时区与连接串配置
数据库连接串一定要显式指定时区,否则不同环境里时间可能差八小时:
jdbc:mysql://localhost:3306/note_share?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true别小看这个配置。我曾经在一台服务器上部署后,所有笔记的create_time都显示比当前时间晚八小时,排查了很久才发现是数据库连接串没指定时区,MySQL 默认按系统时区取,UTC 时间和本地时间差了整整一个作息周期。
6.4 打包部署完整步骤
后端打包:
mvn clean package -DskipTests java -jar target/note-share-server.jar --spring.profiles.active=prod前端打包:
npm run build # 生成 dist 目录生产环境我用 Nginx 托管前端静态文件,并把/api路径反向代理到后端服务:
server { listen 80; server_name your-domain.com; root /var/www/note-share/dist; index index.html; 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; } location / { try_files $uri $uri/ /index.html; } }这里的try_files $uri $uri/ /index.html是必须的。Vue 是单页应用,路由跳转后如果直接刷新页面,Nginx 会去磁盘找/note/1这个路径,找不到就会返回 404;加了try_files回退到index.html,前端路由器接管后就能正确展示页面。
6.5 上线后的三个隐藏问题
第一个是上传图片大小限制。Spring Boot 默认单个文件上传大小只有 1MB,笔记里插入大图很容易直接报错。要在配置里放开限制:
spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=20MB同时 Nginx 里面也检查一下client_max_body_size,默认值是 1m,同样会拦截大文件。
第二个是富文本里的图片路径问题。编辑器上传图片时如果返回的是相对路径,比如/upload/2025/xx.png,本地开发因为代理没问题,但生产环境如果前端和后端不在同一个域名下,图片就会 404。正确处理是返回完整 URL 或者通过 Nginx 把/upload路径也反向代理到后端静态资源目录。
第三个是数据库连接池耗尽。默认 HikariCP 连接池最大连接数是 10,如果接口里有个别慢查询,连接池很快会被占满。上线前把最大连接数调整到 30,同时给数据库加max_connections配置,避免并发稍大时接口全部超时。
最后再分享一个部署脚本的小技巧
我每次发布这个系统时,会写一个简单的部署脚本,把后端打包、前端打包、拷贝静态文件、重启服务串起来执行。脚本本身不复杂,但能省掉大量手工操作:
cd backend && mvn clean package -DskipTests cd ../frontend && npm run build rm -rf /var/www/note-share/dist cp -r dist /var/www/note-share/ systemctl restart noteserver过程中最容易忘的是清理旧的dist目录。如果不删,旧的 JS 文件名和新的文件名混在一起,浏览器缓存会导致线上出现“改了代码但页面没变化”的假象。生产上线前最好再强制刷新一次 CDN 或者浏览器缓存。
很多人在做完之后觉得“这不就是一个增删改查系统吗”,但真正动手做过一遍就知道,难点不在框架用法,而在边界条件的覆盖:私有笔记能不能被搜到、被删除的笔记能不能通过分享链接访问、别人能不能改你的笔记、Token 过期后用户是否还能停留在页面上。把这些边界想清楚,你的笔记记录分享网站才真正可交付。如果这篇文章对你有点帮助,可以按这个结构自己动手复刻一遍,跑通之后再按自己的想法加功能,比如评论、关注、数据导出,系统的扩展空间很大,框架选型也不会白费。