待办接口已经能连接 MySQL。如果现在把它放到公网,会发生一件尴尬的事:任何人都能查询、修改和删除任何待办。
数据库只知道"这条记录属于用户 7",HTTP 请求本身却不会自动证明"发送者就是用户 7"。登录、安全、测试和部署看似是四个话题,其实都在回答同一个问题:怎样让系统只在正确的条件下,做出我们预期的事情。
认证与授权:先认人,再决定能做什么
这两个词经常一起出现,却解决不同问题。
认证 Authentication:确认你是谁。
授权 Authorization:确认你能做什么。
输入邮箱和密码完成登录,属于认证;普通用户不能访问管理员接口,属于授权。
前端隐藏"删除用户"按钮只是改善体验,不是授权。用户可以绕过页面直接请求接口,后端必须在每次关键操作时重新判断权限。
#mermaid-svg-sHdUNzVRIErQGs4N{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sHdUNzVRIErQGs4N .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sHdUNzVRIErQGs4N .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sHdUNzVRIErQGs4N .error-icon{fill:#552222;}#mermaid-svg-sHdUNzVRIErQGs4N .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sHdUNzVRIErQGs4N .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sHdUNzVRIErQGs4N .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sHdUNzVRIErQGs4N .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sHdUNzVRIErQGs4N .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sHdUNzVRIErQGs4N .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sHdUNzVRIErQGs4N .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sHdUNzVRIErQGs4N .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sHdUNzVRIErQGs4N .marker.cross{stroke:#333333;}#mermaid-svg-sHdUNzVRIErQGs4N svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sHdUNzVRIErQGs4N p{margin:0;}#mermaid-svg-sHdUNzVRIErQGs4N .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-sHdUNzVRIErQGs4N .cluster-label text{fill:#333;}#mermaid-svg-sHdUNzVRIErQGs4N .cluster-label span{color:#333;}#mermaid-svg-sHdUNzVRIErQGs4N .cluster-label span p{background-color:transparent;}#mermaid-svg-sHdUNzVRIErQGs4N .label text,#mermaid-svg-sHdUNzVRIErQGs4N span{fill:#333;color:#333;}#mermaid-svg-sHdUNzVRIErQGs4N .node rect,#mermaid-svg-sHdUNzVRIErQGs4N .node circle,#mermaid-svg-sHdUNzVRIErQGs4N .node ellipse,#mermaid-svg-sHdUNzVRIErQGs4N .node polygon,#mermaid-svg-sHdUNzVRIErQGs4N .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-sHdUNzVRIErQGs4N .rough-node .label text,#mermaid-svg-sHdUNzVRIErQGs4N .node .label text,#mermaid-svg-sHdUNzVRIErQGs4N .image-shape .label,#mermaid-svg-sHdUNzVRIErQGs4N .icon-shape .label{text-anchor:middle;}#mermaid-svg-sHdUNzVRIErQGs4N .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-sHdUNzVRIErQGs4N .rough-node .label,#mermaid-svg-sHdUNzVRIErQGs4N .node .label,#mermaid-svg-sHdUNzVRIErQGs4N .image-shape .label,#mermaid-svg-sHdUNzVRIErQGs4N .icon-shape .label{text-align:center;}#mermaid-svg-sHdUNzVRIErQGs4N .node.clickable{cursor:pointer;}#mermaid-svg-sHdUNzVRIErQGs4N .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-sHdUNzVRIErQGs4N .arrowheadPath{fill:#333333;}#mermaid-svg-sHdUNzVRIErQGs4N .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-sHdUNzVRIErQGs4N .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-sHdUNzVRIErQGs4N .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sHdUNzVRIErQGs4N .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-sHdUNzVRIErQGs4N .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sHdUNzVRIErQGs4N .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-sHdUNzVRIErQGs4N .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-sHdUNzVRIErQGs4N .cluster text{fill:#333;}#mermaid-svg-sHdUNzVRIErQGs4N .cluster span{color:#333;}#mermaid-svg-sHdUNzVRIErQGs4N div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-sHdUNzVRIErQGs4N .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-sHdUNzVRIErQGs4N rect.text{fill:none;stroke-width:0;}#mermaid-svg-sHdUNzVRIErQGs4N .icon-shape,#mermaid-svg-sHdUNzVRIErQGs4N .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sHdUNzVRIErQGs4N .icon-shape p,#mermaid-svg-sHdUNzVRIErQGs4N .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-sHdUNzVRIErQGs4N .icon-shape .label rect,#mermaid-svg-sHdUNzVRIErQGs4N .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sHdUNzVRIErQGs4N .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-sHdUNzVRIErQGs4N .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-sHdUNzVRIErQGs4N :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
请求到达
凭证有效吗?
401 未认证
有权操作这个资源吗?
403 无权限
执行业务
认证告诉系统"这是用户 7",授权还要继续确认"待办 42 是否属于用户 7"。很多越权漏洞不是没有登录,而是登录后可以修改别人的资源。
密码为什么不能加密后存起来
严格来说,密码通常不是"加密保存",而是"哈希保存"。
加密意味着持有密钥的人可以还原原文;验证密码并不需要知道用户原来的密码,只需要判断本次输入是否匹配。
注册时,后端使用 BCrypt 生成密码摘要:
java
String hash = passwordEncoder.encode(rawPassword);
登录时进行匹配:
java
passwordEncoder.matches(rawPassword, hash);
BCrypt 的设计目标是计算得足够慢,让攻击者难以高速尝试大量密码。MD5、SHA-1 或直接 SHA-256 都太快,不适合直接保存密码。
登录失败时,统一提示"邮箱或密码错误"。如果分别说"邮箱不存在"和"密码错误",攻击者就能批量确认哪些邮箱已经注册。
密码安全也不止数据库字段:密码不能出现在日志、URL、异常上报、分析平台和接口文档示例里。
JWT 是签过名的通行证,不是保险箱
登录成功后,服务端需要给客户端一份能够证明身份的凭证。JWT 是常见选择之一。
一个 JWT 通常包含三部分:头部、载荷和签名。载荷可以放用户 ID、角色和过期时间,签名用来证明内容没有被篡改。
关键点是:JWT 通常只签名,不加密。拿到 Token 的人往往能读取载荷,所以里面不能放密码、身份证号等敏感信息。
MySQL Spring Boot 前端 MySQL Spring Boot 前端 #mermaid-svg-qnXmO1fGY15B7sLu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qnXmO1fGY15B7sLu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qnXmO1fGY15B7sLu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qnXmO1fGY15B7sLu .error-icon{fill:#552222;}#mermaid-svg-qnXmO1fGY15B7sLu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qnXmO1fGY15B7sLu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qnXmO1fGY15B7sLu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qnXmO1fGY15B7sLu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qnXmO1fGY15B7sLu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qnXmO1fGY15B7sLu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qnXmO1fGY15B7sLu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qnXmO1fGY15B7sLu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qnXmO1fGY15B7sLu .marker.cross{stroke:#333333;}#mermaid-svg-qnXmO1fGY15B7sLu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qnXmO1fGY15B7sLu p{margin:0;}#mermaid-svg-qnXmO1fGY15B7sLu .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-qnXmO1fGY15B7sLu text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-qnXmO1fGY15B7sLu .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-qnXmO1fGY15B7sLu .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-qnXmO1fGY15B7sLu .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-qnXmO1fGY15B7sLu .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-qnXmO1fGY15B7sLu #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-qnXmO1fGY15B7sLu .sequenceNumber{fill:white;}#mermaid-svg-qnXmO1fGY15B7sLu #sequencenumber{fill:#333;}#mermaid-svg-qnXmO1fGY15B7sLu #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-qnXmO1fGY15B7sLu .messageText{fill:#333;stroke:none;}#mermaid-svg-qnXmO1fGY15B7sLu .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-qnXmO1fGY15B7sLu .labelText,#mermaid-svg-qnXmO1fGY15B7sLu .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-qnXmO1fGY15B7sLu .loopText,#mermaid-svg-qnXmO1fGY15B7sLu .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-qnXmO1fGY15B7sLu .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-qnXmO1fGY15B7sLu .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-qnXmO1fGY15B7sLu .noteText,#mermaid-svg-qnXmO1fGY15B7sLu .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-qnXmO1fGY15B7sLu .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-qnXmO1fGY15B7sLu .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-qnXmO1fGY15B7sLu .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-qnXmO1fGY15B7sLu .actorPopupMenu{position:absolute;}#mermaid-svg-qnXmO1fGY15B7sLu .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-qnXmO1fGY15B7sLu .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-qnXmO1fGY15B7sLu .actor-man circle,#mermaid-svg-qnXmO1fGY15B7sLu line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-qnXmO1fGY15B7sLu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 邮箱 + 密码查询用户校验密码并签发 JWTAccess TokenBearer Token + API 请求验证签名与过期时间当前用户的数据
JWT 的优势是服务端可以不保存每个 Access Token 的会话状态,多个服务也能验证同一种凭证。代价是签发后的 Token 在过期前不容易立即撤回。
因此 Access Token 通常设置较短有效期,再搭配可撤销、可轮换的 Refresh Token。对于后台管理、强制下线等场景,还需要黑名单、Token 版本或服务端会话等机制。
Spring Security 像一条位于 Controller 前的检查线
Spring Security 并不是在每个 Controller 方法里插入几行 if。它在请求到达 Controller 之前建立一条过滤器链。
一条带 JWT 的请求大致会经历:
- 从
Authorization请求头读取 Bearer Token; - 验证签名、格式和过期时间;
- 提取用户 ID 与角色;
- 把身份放入
SecurityContext; - 根据 URL 或方法权限决定是否放行。
Controller 随后可以直接获得当前用户:
java
@GetMapping
PageResponse<TodoResponse> list(
@AuthenticationPrincipal AuthUser user
) {
return todoService.list(user.id());
}
最重要的原则是:不要相信前端提交的 userId。
如果接口是 /api/todos?userId=7,用户把 7 改成 8 就可能看到别人的数据。当前用户 ID 应该来自已经验证的 Token,查询 SQL 也要把它作为条件。
角色权限与数据权限,是两个维度
ADMIN、USER 这类角色解决的是功能权限:谁能进入管理接口。
"待办 42 属不属于当前用户"解决的是数据权限。一个普通用户当然可以修改待办,但只能修改自己的待办。
只配置:
text
登录用户可以访问 /api/todos/**
并不足够。Service 或查询层还必须带上当前用户身份:
sql
WHERE id = ? AND user_id = ?
直接按 id + user_id 查询通常比先按 id 查出数据、再单独判断归属更稳妥,因为后者容易在某个方法里忘记检查。
权限模型变复杂后,角色只是起点。系统还可能出现部门、项目成员、资源所有者、临时授权等关系。授权应该表达真实业务,而不是无限增加角色名称。
Token 放在哪里,是前端也必须参与的安全设计
最省事的做法是把 Token 放进 localStorage。页面刷新后仍然存在,Axios 也容易读取。但如果页面发生 XSS,恶意脚本同样能读取 Token。
HttpOnly Cookie 不能被 JavaScript 读取,可以降低 Token 被脚本窃取的风险;浏览器却会自动携带 Cookie,因此必须评估 CSRF,并正确设置 Secure、SameSite 和域名范围。
一种常见组合是:
- 短效 Access Token 放内存;
- 长效 Refresh Token 放 HttpOnly Cookie;
- 页面刷新时通过刷新接口获取新 Access Token;
- 刷新失败就清理登录状态。
这不是唯一答案。移动端、桌面端、同域网站和跨域 SaaS 的条件不同。真正的原则是理解每种存储方式在防什么、又引入什么风险。
无论怎么保存,都不应该把 Token 放进 URL。URL 会进入浏览器历史、服务器日志和分析系统。
CORS、CSRF、XSS:三个名字解决三类问题
它们经常被放在一起,却不能互相替代。
CORS 控制某个来源的网页能否读取跨域响应。
CSRF 利用浏览器自动携带 Cookie,让用户在不知情时发出已认证请求。
XSS 让攻击者的脚本进入你的页面,并以页面身份执行。
如果认证完全依赖请求头中的 Bearer Token,且 Token 不由浏览器自动携带,传统 CSRF 风险会降低;但 Token 若存在 localStorage,XSS 风险更值得警惕。
关闭 Spring Security 的 CSRF 保护不能成为习惯动作。只有先明确客户端类型和认证方式,才能判断是否应该关闭。
安全不是勾选配置项,而是理解数据如何流动。
测试不是为了证明"代码能跑"
手动打开页面点一遍,可以证明某条路径此刻看起来正常,却很难稳定覆盖权限、异常和并发边界。
Spring Boot 项目的测试可以分为几层:
单元测试只测试一个业务对象或 Service。它速度快,适合状态变化、计算规则和错误分支。
Web 层测试关注 URL、参数校验、状态码和 JSON,不必启动真实服务器。
集成测试让 Spring、数据库和事务真正协作,验证 SQL、映射和安全配置。
端到端测试从浏览器出发,确认前后端组合后的关键流程。
它们不是互相替代。全部依赖端到端测试会慢且难定位,只有单元测试又发现不了配置和 SQL 问题。
最值得测试的通常不是简单 getter,而是:
- 未登录访问返回 401;
- 登录但无权限返回 403;
- 用户不能读取别人的待办;
- 参数错误返回稳定结构;
- 多步数据库操作失败时回滚;
- 曾经出现过的缺陷不会再次发生。
Testcontainers 可以在测试时启动真正的 MySQL 容器。相比用 H2 模拟,它更接近生产数据库的 SQL 和行为,避免"测试通过,上线语法不兼容"。
配置的本质:同一份程序,不同的运行条件
开发环境连接本机数据库,测试环境使用临时数据库,生产环境连接内网 MySQL。代码应该是同一份,变化的是配置。
Spring Profile 可以组织不同环境的非敏感默认值,真正的密码和密钥由环境变量或 Secret 管理系统注入:
yaml
spring:
datasource:
url: ${DB_URL}
password: ${DB_PASSWORD}
app:
jwt-secret: ${JWT_SECRET}
环境变量不应该被打印到启动日志。生产密钥还需要轮换、权限控制和审计,不能因为"没有提交 Git"就认为已经安全。
一个常见错误是用环境名称写业务分支:if (prod) ... else ...。环境差异应该尽量落在配置和外部依赖上,核心业务行为应保持一致,否则测试环境通过的并不是生产将要运行的逻辑。
JAR 与 Docker:我们到底在交付什么
Maven 会把 Spring Boot 应用打包成可运行 JAR。JAR 中包含业务代码、依赖和启动信息,再由 Java 21 运行。
Docker 则进一步把运行时、JAR 和启动命令封装进镜像。它解决的是环境一致性:开发机、测试平台和生产服务器运行同一个镜像。
#mermaid-svg-zODt8CL9kGthbrHK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zODt8CL9kGthbrHK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zODt8CL9kGthbrHK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zODt8CL9kGthbrHK .error-icon{fill:#552222;}#mermaid-svg-zODt8CL9kGthbrHK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zODt8CL9kGthbrHK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zODt8CL9kGthbrHK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zODt8CL9kGthbrHK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zODt8CL9kGthbrHK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zODt8CL9kGthbrHK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zODt8CL9kGthbrHK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zODt8CL9kGthbrHK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zODt8CL9kGthbrHK .marker.cross{stroke:#333333;}#mermaid-svg-zODt8CL9kGthbrHK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zODt8CL9kGthbrHK p{margin:0;}#mermaid-svg-zODt8CL9kGthbrHK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zODt8CL9kGthbrHK .cluster-label text{fill:#333;}#mermaid-svg-zODt8CL9kGthbrHK .cluster-label span{color:#333;}#mermaid-svg-zODt8CL9kGthbrHK .cluster-label span p{background-color:transparent;}#mermaid-svg-zODt8CL9kGthbrHK .label text,#mermaid-svg-zODt8CL9kGthbrHK span{fill:#333;color:#333;}#mermaid-svg-zODt8CL9kGthbrHK .node rect,#mermaid-svg-zODt8CL9kGthbrHK .node circle,#mermaid-svg-zODt8CL9kGthbrHK .node ellipse,#mermaid-svg-zODt8CL9kGthbrHK .node polygon,#mermaid-svg-zODt8CL9kGthbrHK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zODt8CL9kGthbrHK .rough-node .label text,#mermaid-svg-zODt8CL9kGthbrHK .node .label text,#mermaid-svg-zODt8CL9kGthbrHK .image-shape .label,#mermaid-svg-zODt8CL9kGthbrHK .icon-shape .label{text-anchor:middle;}#mermaid-svg-zODt8CL9kGthbrHK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zODt8CL9kGthbrHK .rough-node .label,#mermaid-svg-zODt8CL9kGthbrHK .node .label,#mermaid-svg-zODt8CL9kGthbrHK .image-shape .label,#mermaid-svg-zODt8CL9kGthbrHK .icon-shape .label{text-align:center;}#mermaid-svg-zODt8CL9kGthbrHK .node.clickable{cursor:pointer;}#mermaid-svg-zODt8CL9kGthbrHK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zODt8CL9kGthbrHK .arrowheadPath{fill:#333333;}#mermaid-svg-zODt8CL9kGthbrHK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zODt8CL9kGthbrHK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zODt8CL9kGthbrHK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zODt8CL9kGthbrHK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zODt8CL9kGthbrHK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zODt8CL9kGthbrHK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zODt8CL9kGthbrHK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zODt8CL9kGthbrHK .cluster text{fill:#333;}#mermaid-svg-zODt8CL9kGthbrHK .cluster span{color:#333;}#mermaid-svg-zODt8CL9kGthbrHK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zODt8CL9kGthbrHK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zODt8CL9kGthbrHK rect.text{fill:none;stroke-width:0;}#mermaid-svg-zODt8CL9kGthbrHK .icon-shape,#mermaid-svg-zODt8CL9kGthbrHK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zODt8CL9kGthbrHK .icon-shape p,#mermaid-svg-zODt8CL9kGthbrHK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zODt8CL9kGthbrHK .icon-shape .label rect,#mermaid-svg-zODt8CL9kGthbrHK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zODt8CL9kGthbrHK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zODt8CL9kGthbrHK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zODt8CL9kGthbrHK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 源代码
Maven 测试与打包
可运行 JAR
Docker 镜像
测试环境
生产环境
容器不是轻量虚拟机,也不会自动让应用安全。镜像仍然应该:
- 使用明确的 Java 版本;
- 以非 root 用户运行;
- 不包含密码和私钥;
- 设置 CPU 与内存限制;
- 接收停止信号并优雅关闭;
- 定期更新基础镜像中的安全补丁。
数据库不应随应用容器随意销毁。生产数据库通常是独立、有备份、有恢复策略的持久服务。
前后端上线后,流量是怎样走的
一种常见结构是:
#mermaid-svg-8sHQjOQObJLU65MN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8sHQjOQObJLU65MN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8sHQjOQObJLU65MN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8sHQjOQObJLU65MN .error-icon{fill:#552222;}#mermaid-svg-8sHQjOQObJLU65MN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8sHQjOQObJLU65MN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8sHQjOQObJLU65MN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8sHQjOQObJLU65MN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8sHQjOQObJLU65MN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8sHQjOQObJLU65MN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8sHQjOQObJLU65MN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8sHQjOQObJLU65MN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8sHQjOQObJLU65MN .marker.cross{stroke:#333333;}#mermaid-svg-8sHQjOQObJLU65MN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8sHQjOQObJLU65MN p{margin:0;}#mermaid-svg-8sHQjOQObJLU65MN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-8sHQjOQObJLU65MN .cluster-label text{fill:#333;}#mermaid-svg-8sHQjOQObJLU65MN .cluster-label span{color:#333;}#mermaid-svg-8sHQjOQObJLU65MN .cluster-label span p{background-color:transparent;}#mermaid-svg-8sHQjOQObJLU65MN .label text,#mermaid-svg-8sHQjOQObJLU65MN span{fill:#333;color:#333;}#mermaid-svg-8sHQjOQObJLU65MN .node rect,#mermaid-svg-8sHQjOQObJLU65MN .node circle,#mermaid-svg-8sHQjOQObJLU65MN .node ellipse,#mermaid-svg-8sHQjOQObJLU65MN .node polygon,#mermaid-svg-8sHQjOQObJLU65MN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8sHQjOQObJLU65MN .rough-node .label text,#mermaid-svg-8sHQjOQObJLU65MN .node .label text,#mermaid-svg-8sHQjOQObJLU65MN .image-shape .label,#mermaid-svg-8sHQjOQObJLU65MN .icon-shape .label{text-anchor:middle;}#mermaid-svg-8sHQjOQObJLU65MN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8sHQjOQObJLU65MN .rough-node .label,#mermaid-svg-8sHQjOQObJLU65MN .node .label,#mermaid-svg-8sHQjOQObJLU65MN .image-shape .label,#mermaid-svg-8sHQjOQObJLU65MN .icon-shape .label{text-align:center;}#mermaid-svg-8sHQjOQObJLU65MN .node.clickable{cursor:pointer;}#mermaid-svg-8sHQjOQObJLU65MN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8sHQjOQObJLU65MN .arrowheadPath{fill:#333333;}#mermaid-svg-8sHQjOQObJLU65MN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8sHQjOQObJLU65MN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8sHQjOQObJLU65MN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8sHQjOQObJLU65MN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8sHQjOQObJLU65MN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8sHQjOQObJLU65MN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8sHQjOQObJLU65MN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8sHQjOQObJLU65MN .cluster text{fill:#333;}#mermaid-svg-8sHQjOQObJLU65MN .cluster span{color:#333;}#mermaid-svg-8sHQjOQObJLU65MN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8sHQjOQObJLU65MN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8sHQjOQObJLU65MN rect.text{fill:none;stroke-width:0;}#mermaid-svg-8sHQjOQObJLU65MN .icon-shape,#mermaid-svg-8sHQjOQObJLU65MN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8sHQjOQObJLU65MN .icon-shape p,#mermaid-svg-8sHQjOQObJLU65MN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8sHQjOQObJLU65MN .icon-shape .label rect,#mermaid-svg-8sHQjOQObJLU65MN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8sHQjOQObJLU65MN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8sHQjOQObJLU65MN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8sHQjOQObJLU65MN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} /
/api
浏览器
Nginx / 网关
前端静态资源
Spring Boot
MySQL
同一个域名下,/ 提供前端文件,/api 转发给 Spring Boot。网关还可以处理 HTTPS、压缩、限流和访问日志。
这时"前端接口地址"不应该写死成某台服务器的 IP,而应通过环境配置或相对路径决定。数据库地址更不可能出现在前端,因为浏览器不应该直接连接数据库。
服务启动成功,不等于服务健康
进程还在,不代表它能正常提供服务。数据库连接可能已经耗尽,磁盘可能已满,外部依赖可能超时。
Spring Boot Actuator 提供健康检查和指标。平台可以根据健康状态决定是否把流量交给某个实例。
可观测性通常包含三类信号:
日志 Logs:发生了哪些离散事件。
指标 Metrics:请求量、错误率、延迟、连接池等数值如何变化。
追踪 Traces:一次请求经过多个服务时,每一段耗时多久。
只有日志而没有指标,往往等到用户反馈才知道系统变慢;只有指标而没有 requestId 和追踪,又很难解释具体一次失败。
发布还需要考虑"新旧版本同时存在"
滚动发布时,新旧两个版本会短暂同时服务。数据库结构和 API 必须允许这种过渡。
例如要把 title 拆成 title 和 description,更稳妥的过程是:
- 先增加新字段,不删除旧字段;
- 部署同时兼容新旧结构的代码;
- 迁移历史数据;
- 确认没有旧版本流量;
- 最后清理废弃字段。
这叫向前兼容的发布思维。直接先删字段,再期待新代码在同一秒全部启动,会把部署变成一次高风险赌博。
回滚也必须提前设计。应用镜像可以回滚,已经执行的数据迁移却不一定容易逆转。涉及删除或重写数据时,更应该先备份、分阶段验证。
到这里,Spring Boot 不再是一堆注解
从前端跨到后端,真正的变化不是换了一门语言,而是开始为系统事实负责:
- Controller 维护 HTTP 边界;
- Service 表达业务规则;
- Mapper 和 MySQL保存并保护数据;
- Spring Security 确认身份和权限;
- 测试固定关键行为;
- 配置让同一程序适应不同环境;
- Docker 交付一致运行条件;
- 日志、指标和追踪告诉我们线上真正发生了什么。
当你能顺着一次请求,从浏览器一路追到 Controller、Service、SQL、权限判断和响应,再从日志和指标中把它找回来时,Spring Boot 就不再神秘。它只是把一个真实后端需要面对的问题,整理成了一套长期可维护的结构。