news 2026/10/21 22:06:01

SpringBoot + Vue + Redis + Docker全栈项目实战:从注册登录到支付排行榜

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot + Vue + Redis + Docker全栈项目实战:从注册登录到支付排行榜

我最近把一个前后端分离的完整闭环项目从头到尾盘了一遍,技术栈正好就是标题里那套:SpringBoot + Vue-Cli + Redis + Docker,业务上串了邮箱注册登录、短信注册登录、Alipay支付、用户积分排行榜。这个项目编号叫 Project1_06,属于那种“单看每个模块都不难,但合在一起很容易翻车”的典型全栈练习项目。我把它跑通之后最大的感受是:这种项目真正的难点不在某个框架的API,而在Redis到底该存什么、支付回调怎么防守、积分和订单之间怎么保证一致、Docker部署时哪些细节不配好会折腾你一晚上。

这篇文章我会按当时动手的顺序,把这个项目的整体设计、双通道注册登录、排行榜ZSet设计、支付链路、Docker部署逐个拆开讲。你如果正准备做类似的全栈项目,或者想看看一个真实项目里这些技术点是怎么组合的,可以直接照着下面的思路抄作业。

1. 项目全貌:从业务链路到技术栈选型

1.1 这个项目到底在做什么

Project1_06 看起来是个以用户体系为中心的电商或社区类服务项目。它最核心的业务链路其实只有一条:用户通过邮箱或手机号注册登录 → 选择商品或服务下单 → 通过支付宝完成支付 → 支付成功后获得积分 → 积分写入排行榜 → 用户能看到自己的名次和Top名次。

这条链路涵盖了当前绝大多数互联网产品的通用玩法:注册登录负责“拉人”,支付负责“成交”,积分排行榜负责“留存和促活”。单独看每一个环节都不算复杂,但它们之间有强依赖关系——支付成功才能加积分,加积分才能改变排名,排名变化又会反过来刺激用户继续消费。这种业务闭环正是这类项目最大的价值。

1.2 技术选型:为什么偏偏是这四个组件

我之前也见过不少同学问,为什么这个项目选SpringBoot、Vue-Cli、Redis、Docker,而不是别的。我的理解是,这四个东西刚好把项目从开发到部署的每个阶段都覆盖了,而且各自都承担着不可替代的角色。

  • SpringBoot:后端主框架。Java生态最成熟,社区资料极多,无论接支付宝SDK、短信服务商还是自己封装验证码工具类,都能很快找到参考。另外SpringBoot的自动装配和starter机制让项目初始化成本非常低。
  • Vue-Cli:前端工程化脚手架。Vue-Cli带路由、状态管理、开发服务器、构建工具链,配合Element Plus这类组件库,做表单、弹窗、列表页非常快。和SpringBoot通过RESTful API通信,各管各的。
  • Redis:这个项目里Redis不是可有可无的缓存,而是核心基础设施。验证码需要临时存储和自动过期,登录态需要共享并可以主动踢人,排行榜需要有序集合和实时分数更新,这三件事用MySQL去做都很别扭,用Redis做则刚刚好。
  • Docker:项目跑完总要交付,Docker可以保证本地开发和服务器运行环境一致。数据库、Redis、后端Jar包、前端静态资源,全部编排进容器,一条命令拉起整个环境,省去一堆“在我电脑上明明可以跑”的争论。
组件在这个项目里承担的角色如果缺了会怎样
SpringBoot后端业务逻辑、API、第三方SDK集成开发工作量剧增,生态支持变弱
Vue-Cli前端页面、路由、状态管理、API对接前端没法工程化,团队协作混乱
Redis验证码、登录态、排行榜、限流验证码过期和排行榜实时性很难实现
Docker统一线上环境,简化部署运维部署易出环境差异,维护成本高

1.3 数据库设计与模块划分

虽然是标题里的四件套,但持久化还是离不开MySQL。我当时为了这个项目设计了一套最小化表结构,不复杂,但刚好能支撑整条业务链路:

  • user表:用户主表,字段包含id、用户名、邮箱、手机号、密码(BCrypt加密)、头像、创建时间。
  • user_points_log表:积分流水表,字段包含id、用户id、积分变动值、变动原因、关联订单号、创建时间。这张表很重要,后面讲数据一致性时还要提到。
  • order_master表:订单主表,字段包含订单号、用户id、商品id、金额(以分为单位)、订单状态、创建时间。
  • payment_info表:支付流水表,字段包含支付流水号、订单号、支付方式、支付宝交易号、支付金额、回调状态。

模块倒不需要拆得太碎,我当时是按controller/service层面分成六大块:认证模块、用户模块、商品模块、订单模块、支付模块、排行模块。重点是前后端分离的目录规范,后端一个project-api工程,前端一套Vue-Cli工程,互不污染。项目做到后面你会发现,边界清晰比技术花哨重要得多。

2. 双通道注册登录:验证码体系与登录态的Redis化改造

2.1 验证码这东西为什么要存Redis

邮箱注册登录和短信注册登录,本质上都是“身份凭证 + 验证码核验”的组合。手机号和邮箱只能证明“这个号码/这个地址能接收到消息”,要确认这个号码/地址属于当前用户,必须通过一次性验证码。

验证码有几个天然的需求:它必须在几分钟内失效;它必须能防重放;它必须能防爆破。这三个需求全部指向同一个方案——把验证码存进Redis,并设置合理的过期时间。Redis的EXPIRE天然支持“几分钟后自动删除”,比对数据库一个个字段去判断“验证码是否过期”要省事得多。

我当时设计了这么一组Redis Key:

  • 发送邮箱验证码:auth:code:email:{email},TTL 5分钟
  • 发送短信验证码:auth:code:sms:{phone},TTL 5分钟
  • 邮箱发送频率限制:auth:limit:email:{email},TTL 60秒
  • 短信发送频率限制:auth:limit:sms:{phone},TTL 60秒

为什么邮箱和短信要分成两套前缀?因为邮箱验证码和短信验证码的发送渠道、发送成本、通道故障都不完全相同,分开存储方便做单独控制。比如短信有成本和第三方通道额度限制,需要更严格的频控,邮箱则相对宽松。

2.2 验证码服务的统一抽象与代码落地

邮箱和短信虽然发送渠道不同,但核心流程高度一致:生成验证码 → 设置Redis过期时间 → 发送给用户 → 用户提交后校验。所以我先写了一个统一的验证码Service,把逻辑抽到一起。

@Service public class VerifyCodeService { @Autowired private StringRedisTemplate stringRedisTemplate; // 发送验证码,isEmail通道走邮箱,否则走短信 public void sendCode(String target, boolean isEmail) { String limitKey = "auth:limit:" + (isEmail ? "email:" : "sms:") + target; Boolean canSend = stringRedisTemplate.hasKey(limitKey); if (Boolean.TRUE.equals(canSend)) { throw new RuntimeException("发送频率过快,请稍后再试"); } String code = String.valueOf(ThreadLocalRandom.current().nextInt(100000, 999999)); String codeKey = "auth:code:" + (isEmail ? "email:" : "sms:") + target; stringRedisTemplate.opsForValue().set(codeKey, code, 5, TimeUnit.MINUTES); stringRedisTemplate.opsForValue().set(limitKey, "1", 60, TimeUnit.SECONDS); if (isEmail) { mailSender.sendCodeMail(target, code); } else { smsSender.sendCodeSms(target, code); } } // 校验验证码,成功则立即删除,防止重放 public boolean verifyCode(String target, String code, boolean isEmail) { String codeKey = "auth:code:" + (isEmail ? "email:" : "sms:") + target; String cached = stringRedisTemplate.opsForValue().get(codeKey); if (cached != null && cached.equals(code)) { stringRedisTemplate.delete(codeKey); return true; } return false; } }

这里有个细节我刚做项目时经常漏掉:验证码校验成功后一定要立刻删除Redis Key。如果不删,同一串验证码在TTL内可以反复使用,攻击者拦截到一次验证码之后可以无限利用,这属于很致命的逻辑漏洞。所以校验接口必须设计成“一次性核验”,用完之后直接删Key。

短信发送部分我当时没有直接写死某一家服务商的SDK,而是先定义一个发送接口,然后做一个Aliyun实现和一个Mock实现。本地开发和测试环境用Mock,直接把验证码打回前端Console,线上切到真实短信服务。这样做的坏处是多写一个实现类,好处是项目不需要依赖真实短信额度就能跑通整个流程。

public interface SmsSender { void send(String phone, String code); } @Service @ConditionalOnProperty(name = "app.sms.mock", havingValue = "true") public class MockSmsSender implements SmsSender { @Override public void send(String phone, String code) { log.info("[mock sms] send code {} to phone {}", code, phone); } }

2.3 登录态为什么不用Session而是用Redis Token

注册登录做完了,紧接着的问题是:用户登录成功后,后端怎么识别这个用户?很多人第一反应是HttpSession。但前后端分离项目里,前端用Vue-Cli跑开发服务器,跨域请求天然存在,Session要处理跨域Cookie、CSRF等问题,非常麻烦。而且一旦后端要横向扩展成多个实例,Session默认存在单机内存里,集群之间就串了。

所以我选了“Redis Token”方案。登录成功后生成一段随机Token,把它作为Key存进Redis,Value是userId、昵称等信息,TTL设为7天。前端把Token放进请求头Authorization里,后端用一个拦截器统一解析。

@Component public class AuthInterceptor implements HandlerInterceptor { @Autowired private StringRedisTemplate stringRedisTemplate; private static final List<String> WHITE_LIST = Arrays.asList("/api/auth/login", "/api/auth/register", "/api/auth/sendCode"); @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (WHITE_LIST.contains(request.getRequestURI())) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { String tokenKey = "auth:token:" + token.substring(7); String userInfo = stringRedisTemplate.opsForValue().get(tokenKey); if (StringUtils.hasText(userInfo)) { stringRedisTemplate.expire(tokenKey, 7, TimeUnit.DAYS); // 滑动续期 request.setAttribute("userId", userInfo); return true; } } response.setStatus(401); return false; } }

这里有一个我后来觉得特别值回票价的细节:只要登录态在Redis里,后端随时可以把某个用户踢下线,只要删除对应的Token Key就行。如果当时图省事儿用了无状态的JWT,想踢人都要等Token自然过期,根本做不到主动控制。这个项目的“活跃用户管理”“后台封号”等扩展功能,都是依赖Redis Token才能实现的。

2.4 双通道登录的边界情况与坑

这一节我把自己踩过和见过别人踩的坑集中列一下。

  • 验证码长度:短信验证码一般6位数字,邮箱验证码可以6位也可以更多。位数太短容易被暴力猜,位数太长用户容易输错,6位是体验和安全的平衡点。
  • 频控一定要覆盖发送接口:只对“校验接口”做限制是防不住恶意刷短信的。攻击者会直接循环请求发送接口,把你的短信额度刷光,所以发送前必须查频控Key,有未过期的限流Key就拒绝发送。
  • 图形验证码防机器人:真要是公网项目,短信和邮件接口还应该加一层图形验证码或滑条验证码,防止脚本直接调发送接口,这一点可以放在Vue页面里做。
  • 邮箱格式和手机号格式校验:前端做一遍轻校验,后端必须再做一遍重校验。后端如果只依赖前端传参,很容易被绕过,导致垃圾数据入库。

我当时就吃过这个亏:本地调试时短信验证码发送接口没有做频控,一个循环脚本把短信服务商的测试额度几分钟内全打完了。后来老老实实把频控逻辑补上,重新写了一版限流工具类,才安心继续往后做。

3. 用户积分排行榜:Redis ZSet的数据结构与排行榜取舍

3.1 为什么不做SQL排行榜而选ZSet

排行榜功能第一个想到的方案肯定是SQL,那到底要不要用Redis ZSet?我是先把SQL方案算了一笔账才做的决定。

用MySQL做排行榜,通常是一张积分流水表,跑SELECT user_id, SUM(points) FROM user_points_log GROUP BY user_id ORDER BY SUM(points) DESC LIMIT 50。听起来很直接,但用户量和流水量一上来,这个聚合查询会遍历大量记录,而且每产生一笔积分都要实时重算Top50,数据库压力非常大。另外这个排行查询会被频繁点击,缓存和实时性也很难两全。

Redis里的ZSet是有序集合,每个成员自带一个分数,底层是跳表加哈希表结构,插入、更新、范围查询的时间复杂度都是对数级别,专门为这类“按分数排序的成员集合”场景设计。映射到我们的业务就是:member存userId,score存用户总积分,ZINCRBY一条命令就能给某个用户加分,ZREVRANGE一条命令就能拿Top50。实时性、性能、实现成本全都比SQL方案好。

3.2 积分从哪来:支付成功事件驱动的加分机制

积分怎么进排行榜?这个项目里积分来源很明确——支付成功。所以我在支付模块收到支付宝异步通知、把订单状态更新为已支付之后,紧接着发布一个支付成功事件,积分模块监听这个事件去加积分。用Spring的事件机制,可以避免把积分逻辑塞进支付回调里造成代码耦合。

核心的加积分伪代码如下:

@Transactional public void addPoints(PaymentSuccessEvent event) { UserPointsLog log = new UserPointsLog(); log.setUserId(event.getUserId()); log.setChangePoints(event.getPayAmountInYuan().intValue()); // 比如1元=1积分 log.setOrderNo(event.getOrderNo()); pointsLogMapper.insert(log); // Redis排行榜加积分 String rankKey = "rank:user:score"; stringRedisTemplate.opsForZSet().incrementScore(rankKey, event.getUserId().toString(), event.getPayAmountInYuan()); }

注意这里有两个写入动作,一个是MySQL积分流水,一个是Redis排行榜加分。后面我会专门讲这两个的一致性处理,这里先强调一点:必须先把积分流水落库,再更新Redis排行榜。流水是主数据,排行榜是可重建的缓存,顺序反了可能导致Redis加了分但流水丢了,对账的时候很难排查。

3.3 一个ZSet还是多个ZSet:日榜、周榜、总榜的设计

排行榜如果只做总榜,一个ZSet就够。但真实项目通常还要日榜、周榜这类玩法。我在这个项目里做了总榜和当日榜两种,设计方式非常直白:

  • 总榜:rank:user:score
  • 当日榜:rank:user:score:20250601

日榜Key带日期后缀,每天零点之后新产生一个Key,昨天的Key在业务低峰期让它自然过期。为了保证日榜的分数是当天数据,加积分时必须根据当前时间拼出对应的日榜Key,同时更新总榜Key。

周榜用类似思路可以做:要么每天凌晨用定时任务把过去7天的日榜合并成周榜,要么实时用ZUNIONSTORE把多个日榜Key合并到一个临时周榜Key。我更推荐后者,因为定时任务一多就很容易出调度问题,ZUNIONSTORE是Redis原生命令,一次性把7个日榜按SUM聚合,代码也很简单。

public void buildWeeklyRank() { String weekKey = "rank:user:score:weekly"; String[] sourceKeys = getLast7DaysKeys(); // 生成最近7天的key数组 DefaultTypedTuple<Object>[] empty = new DefaultTypedTuple[0]; stringRedisTemplate.opsForZSet().unionAndStore( sourceKeys[0], Arrays.asList(Arrays.copyOfRange(sourceKeys, 1, sourceKeys.length)), weekKey ); stringRedisTemplate.expire(weekKey, 7, TimeUnit.DAYS); }

这个方案唯一的注意点是:日榜Key如果已经过期删除了,合并出来的周榜就会缺数据。所以日榜TTL我一般设置成7天以上,比周榜生命周期长,给周榜合并留足缓冲时间。

3.4 TopN查询、我的排名与分页

ZSet的查询接口非常贴合排行榜需求,我这里把三个常用操作对应一下:

  • 榜单TopN:ZREVRANGE rank:user:score 0 9 WITHSCORES
  • 用户自己的名次:ZREVRANK rank:user:score {userId}
  • 带分页的榜单纯数据:ZREVRANGE rank:user:score (page-1)*size page*size-1 WITHSCORES

在Java里用StringRedisTemplate实现TopN很简单:

public List<RankItem> getTopRank(int limit) { Set<ZSetOperations.TypedTuple<String>> result = stringRedisTemplate.opsForZSet() .reverseRangeWithScores("rank:user:score", 0, limit - 1); List<RankItem> items = new ArrayList<>(); int rank = 1; for (ZSetOperations.TypedTuple<String> tuple : result) { items.add(new RankItem(Long.valueOf(tuple.getValue()), tuple.getScore(), rank++)); } return items; }

这里有两个需要说明的点:一是不同用户积分相同时,Redis按member的字典序排序,名次会显得“不自然”,产品上一般接受这种并发排名规则,不必做得太复杂;二是reverseRangeWithScores返回的Score是Double,如果你积分值特别大,要注意精度问题,不过普通积分场景完全够用。

3.5 排行榜数据的持久化与Redis宕机恢复

排行榜这种高实时性数据存在Redis里,但它也是核心业务数据,直接丢了用户会闹。我在项目里的做法是分层处理:

  1. 来源数据在MySQL:每一笔积分流水都落库,Redis排行榜顶多算“按流水聚合出来的缓存视图”。
  2. Redis开AOF持久化:appendonly yes,appendfsync everysec,用每秒一次的频率刷盘。万一进程挂了,最多丢1秒左右的数据,可接受。
  3. 定时对账任务:每天凌晨跑一个任务,扫描MySQL积分流水,把总积分和Redis里的ZSet分数做比对,不一致就按MySQL数据重刷排行榜。

如果Redis彻底宕机,重启后排行榜空了怎么办?我补了一个管理接口,从MySQL重新聚合所有用户的积分总和并重建ZSet,相当于“缓存预热”。因为已经有了积分流水这个主数据,排行榜的恢复成本其实很低,这也是这种设计最大的底气。

4. 支付链路:订单状态机、回调验签与应用幂等

4.1 支付模块梳理:下单、提交支付、同步跳转和异步通知

支付是整条链路里最不能出错的一环。Alipay支付接入的时候,先要理清整个流程有几个节点:用户下单后创建订单(状态待支付),后端调用支付宝的支付下单接口,拿到一段自动提交的表单HTML返回给前端,前端渲染之后用户扫码或跳转收银台完成付款。付款完成后支付宝会做两件事:一是把用户在浏览器里重定向回return_url,给用户看支付结果;二是给后端接口notify_url发一个HTTP异步通知,告诉后端“这单已经支付了”。

我在这类项目里见过最多的误区,是把return_url当成业务终结信号。其实同步跳转完全不可靠,用户可能付完款直接关掉浏览器,根本不会触发return_url;就算触发了,也完全可能是伪造的。真正可以相信并用来更新订单状态的,只有异步通知。所以前端跳转回来只能用于“刷新页面给用户一个反馈”,后端所有业务逻辑必须挂在异步通知里。

4.2 回调处理的核心防守:验签、验金额、验状态

拿到异步通知后,第一件事不是直接改订单状态,而是做三重校验,每一重漏掉都会出事故。

第一重:验签

支付宝的通知参数里带一个sign字段,是用商户私钥对请求参数签名生成的。后端必须用支付宝公钥对请求内容做验签,确认这条通知确实来自支付宝服务器。验签通常是SDK的一行代码,但这一行代码保证了“不可能伪造回调”。

boolean check = AlipaySignature.rsaCheckV1(requestParams, alipayConfig.getPublicKey(), AlipayConfig.CHARSET_UTF8, AlipayConfig.SIGN_TYPE_RSA2); if (!check) { log.warn("支付宝异步通知验签失败"); return "failure"; }

第二重:验金额

不能直接用订单表里的“订单应付金额”和回调里的金额做比较,而是要读取回调参数里的total_amount等字段,和订单库里记录的实际订单金额比对。为什么?因为一个金额参数在传输过程中如果被篡改,验签已经能挡住;但这里还必须防的是接口调用方自己记错金额、或者订单本身发生过改价等场景。金额以支付宝回调为准,同时必须严格相等,哪怕是0.01元的差异都不能放行。

第三重:验订单状态

订单状态必须是“待支付”才允许更新。如果订单已经处于“已支付”状态,说明这条通知可能重复到达,此时应当直接返回成功应答,不能再次走一遍加积分逻辑。

4.3 幂等设计与数据库并发下的积分重复问题

支付回调天然存在重复通知机制。支付宝在没有收到明确“success”答复时,会间隔一段时间主动重发通知,最多重发大约8次,时间跨度和每次都不同。如果后端一时处理慢、或者网络闪断,同一个支付结果会被传两遍。

解决办法就是在处理逻辑里加幂等控制。我当时的实现分两层:

第一层:在数据库订单表里加一个唯一的支付流水号字段,收到回调时先尝试UPDATE order_master SET status='PAID' WHERE order_no=? AND status='UNPAID',受影响行数只有1才继续执行;不为1说明已经处理过,直接返回“success”。

第二层:给积分发放加一个去重标志,按支付宝交易号做唯一索引。我建了一张支付单流水表,trade_no唯一索引,回调处理时先插入流水记录,插入返回冲突说明这条交易已经处理过,直接放弃积分发放。

这两层加下来,重复通知、并发线程同时到达的情况都能兜住。我实际测试过把回调接口并发打10次,最终订单只被更新一次,积分只发放一次,排行榜分数也稳定不变,没有出现多倍积分的情况。

4.4 沙箱环境和公网回调调试的几点经验

支付宝开放平台提供沙箱环境,申请之后会拿到一套沙箱AppID、应用私钥、支付宝公钥,所有接口调用都走沙箱网关,支付体验和真实环境一致。做这个项目时强烈建议先沙箱调试,不要一上来就用真实密钥,出问题追究起来很麻烦,而且沙箱里各种金额都可以随便测,不用担心造成真实资金损失。

调试过程中有几个让人头疼的常见问题,我提前说一下:

  • 回调地址必须是外网可访问的。支付宝服务器向notify_url发通知时,它不可能访问到你的localhost。本地调试时需要让内网开发环境暴露到公网,相关工具使用时注意不要乱传代码和明文密钥即可。
  • 服务端回调接口要容忍乱序。支付回调到达的时间和下单时间没有严格的先后关系,不能假设“回调一定在下单之后到达”,所以下单接口和回调接口之间不要依赖时序。
  • 返回字符串必须严格是“success”。很多新手写回调时返回了JSON,支付宝识别不了,会一直重发通知,导致同一单被反复处理。虽然幂等能挡住重复处理,但日志会非常难看,还是要规范返回。

支付这块做完,我对“金额敏感型业务要保守”这句话有了特别深的理解。开发时可以追求效率,但涉及钱、积分、订单状态,宁可多写一层校验,也别为了赶进度省略掉。

5. Docker化部署:一套Compose把后端、前端、Redis、MySQL全部收编

5.1 容器化拆解:四个容器怎么分工

项目开发完了,准备部署,我用Docker把整个环境容器化。这台部署机的环境是全新的,我一开始也犹豫过要不要直接在宿主机上装MySQL、Redis、Java、Node、Nginx,想想还是放弃了。宿主机部署最大的问题是:环境依赖一堆、迁移成本高、改配置反复折腾,而且万一哪天要换服务器,等于重来一遍。用Docker之后,整个项目变成一堆镜像和一份编排文件,换机器只要把镜像导入、把Compose文件拉起来,五分钟就能跑起来。

这个项目的容器拆法很简单:

容器镜像暴露端口作用
mysqlmysql:8.03306持久化业务数据
redisredis:7-alpine6379验证码、登录态、排行榜
backend自建后端镜像8080SpringBoot应用
frontend自建前端镜像80Nginx托管Vue构建产物

四个容器放在同一个Docker网络里,互相通过容器名访问,不需要把MySQL、Redis的端口暴露到公网,安全性和隔离性都更好。

5.2 后端与前端Dockerfile:多阶段构建是基础操作

后端是Java项目,打包方式是Maven构建出Jar包,然后让一个仅带Java运行时的镜像来跑。最简单的Dockerfile是直接装一个Maven和JDK构建镜像,再把Jar打进去,但这样镜像体积巨大,部署也慢。所以我用了多阶段构建,第一阶段负责编译,第二阶段只保留运行产物。

# 构建阶段 FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:resolve COPY src ./src RUN mvn package -DskipTests # 运行阶段 FROM eclipse-temurin:17-jre-alpine WORKDIR /app RUN apk add --no-cache tzdata ENV TZ=Asia/Shanghai COPY --from=builder /app/target/project1-06.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]

这段Dockerfile踩过一个小坑值得说:基础镜像如果用默认的时区,容器里是UTC时间,日志时间会比北京时间少8个小时,而且LocalDate.now()这类取当天日期的代码会莫名其妙地“跑到昨天”。所以我直接拉了一个时区设置,环境时间统一成北京时间。数据库连接串参数serverTimezone=Asia/Shanghai也一并配上,整条链路的时间就对齐了。

前端Dockerfile更典型:用Node镜像把Vue项目构建成dist静态文件,再交给Nginx镜像跑。构建完的静态文件用COPY --from拷贝到Nginx的站点目录下面即可。

# 构建阶段 FROM node:16-alpine AS builder WORKDIR /app COPY package.json ./ RUN npm install COPY . . RUN npm run build # 运行阶段 FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80

Nginx配置需要特别注意一个点:前端单页面应用配合Vue Router使用history模式时,路由路径不是真实文件路径,刷新一个/dashboard页面会直接404。必须在Nginx里配try_files $uri $uri/ /index.html;,把匹配不到文件的路由全部回退到入口HTML。不配这一行,部署完刷新就白屏,排查半天还以为是静态资源路径错了。

5.3 用docker-compose编排整条链路

我习惯把后端、前端、Redis、MySQL都写进同一个docker-compose.yml里,这样一键就能拉起整个项目。Compose文件核心关注点是环境变量注入、容器依赖关系、数据卷挂载和网络互通。

version: "3.8" services: mysql: image: mysql:8.0 container_name: project-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: project1_06 ports: - "3306:3306" volumes: - mysql-data:/var/lib/mysql - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"] interval: 5s retries: 10 redis: image: redis:7-alpine container_name: project-redis ports: - "6379:6379" command: redis-server --appendonly yes volumes: - redis-data:/data backend: build: ./backend container_name: project-backend environment: SPRING_PROFILES_ACTIVE: prod DB_HOST: mysql DB_PORT: 3306 DB_NAME: project1_06 DB_USER: root DB_PASSWORD: root123 REDIS_HOST: redis REDIS_PORT: 6379 ALIPAY_GATEWAY_URL: https://openapi-sandbox.dl.alipaydev.com/gateway.do ports: - "8080:8080" depends_on: - mysql - redis frontend: build: ./frontend container_name: project-frontend ports: - "80:80" depends_on: - backend volumes: mysql-data: redis-data:

写这段Compose的时候有一个特别常见的误区:depends_on只是控制启动顺序,它不等同于“依赖的服务已经就绪”。MySQL容器启动了不代表3306端口已经能接受连接,SpringBoot启动时连不上MySQL依然会报错。为了解决这个问题,我做了两手准备:MySQL加了healthcheck健康检查;SpringBoot的连接池配置了connection-timeout和自动重连,让后端在依赖延迟几秒时不会直接崩溃退出。

5.4 配置外置与环境变量注入

前后端和数据库的连接配置全部通过环境变量注入,而不是写死在代码配置文件里。SpringBoot的application-prod.yml这么写:

spring: datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: ${DB_USER} password: ${DB_PASSWORD} redis: host: ${REDIS_HOST} port: ${REDIS_PORT}

这样做的好处是代码仓库里不含任何实际部署环境的密钥和地址,换环境只要改Compose的环境变量,镜像完全不用重新构建。数据库初始化用挂载init.sql到MySQL容器的方式,启动时自动建库建表,特别适合演示和交付。

5.5 部署完成后的两个小提醒

整体跑通之后,我在生产环境和本地之间来回切换时还遇到两个小问题,顺便分享出来。

第一,容器日志要配置滚动。项目跑久了,容器日志和MySQL日志会无限增长,很容易把磁盘撑满。可以在Docker的/etc/docker/daemon.json里配置日志轮转,这段配置我是直接加到Docker守护进程里的,之后新容器默认启用,省心很多。

{ "log-driver": "json-file", "log-opts": { "max-size": "50m", "max-file": "10" } }

第二,前端反向代理后端接口。我在前端Nginx配置里除了托管静态文件,还给/api路径配了一个反向代理,转发到后端容器。这样前端代码里的API地址统一写/api,避免了浏览器直接跨域请求后端的问题,部署起来干净利落。

location /api/ { proxy_pass http://backend:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

整套容器编排跑下来之后,部署这件事的体感就是:“项目代码提交完,服务器上执行一次docker compose up -d --build,四五个容器依次启动,再访问服务器IP就看到了登录页。”相比之前手工装JDK、配MySQL、跑Nginx的经历,效率提升非常明显。

6. 最后说几句我在整条链路里的个人体会

这个Project1_06的模拟项目做下来,我最深的一个体会是:它没有用到任何“高深”的技术,但把业务链路完整地走通,需要开发者对数据存储的取舍、回调安全、事务一致、部署交付都有清晰的概念。Redis在这里不是锦上添花的缓存,而是验证码过期、登录态共享、实时排行榜的承载者;支付回调不是写完API就结束,而是要防御重复通知、伪造通知、金额篡改这些真实风险;Docker部署不是套一个镜像模板就完事,时区、健康检查、配置外置、日志轮转都是实际运维会遇到的问题。

如果让我给刚要起步做类似项目的朋友一个建议,我会说:先用沙箱环境把支付流程从下单到回调整个串通,再考虑积分排行榜的细分和扩展。支付是整条链路的发动机,发动机不稳,后面全是空转。

下一步我计划在这个项目上做两件事:一是给排行榜增加WebSocket实时推送,排名变化直接推给前端,不用用户手动刷新;二是把积分体系升级成等级勋章,把日榜、周榜、月榜的合并策略再优化一轮。你如果也正在做这个方向的项目,有更好的设计思路,欢迎一起交流。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 22:04:10

Ubuntu 22.04 SSH 配置与安全加固实战指南

1. 先搞清楚&#xff1a;SSH 不只是"远程登录"那么简单很多人把 SSH 当成一个普通的远程登录工具&#xff0c;装了就能用&#xff0c;能用就不管。但在 Ubuntu 22.04 上真正把 SSH 配好&#xff0c;尤其是配到"能上生产环境"的程度&#xff0c;其实涉及一整…

作者头像 李华
网站建设 2026/10/21 21:59:36

项目标题不完整?补齐这些信息,才能写出高质量技术博文

好的&#xff0c;我明白了。在开始之前&#xff0c;我需要确认一下&#xff1a;您提供的项目标题似乎不完整&#xff0c;只有“rea”这个前缀。为了确保我能精准地挖掘标题背后的核心领域与细节&#xff0c;避免偏离方向&#xff0c;请您补充完整的【项目标题】&#xff0c;并尽…

作者头像 李华
网站建设 2026/10/21 21:58:05

如何构建虚拟AI产品经理:从知识库到Roadmap的完整落地指南

1. 先想清楚&#xff1a;你要的是一套“AI 打下手”的方案&#xff0c;还是一个“数字同事” 我见过太多团队把“引入 AI 产品经理”做成了一件很尴尬的事&#xff1a;买了一堆 AI 工具的会员&#xff0c;让产品经理用大模型写 PRD、生成用户故事、做竞品分析。结果呢&#xff…

作者头像 李华
网站建设 2026/10/21 21:56:23

损失函数从原理到实践:梯度行为、选型策略与调试指南

损失函数这东西&#xff0c;每个做深度学习的都绕不开。无论是训练一个图像分类模型&#xff0c;还是调一个目标检测的工程&#xff0c;你最先盯着的就是那张 loss 曲线。它升了你会慌&#xff0c;它降不下来你会焦虑&#xff0c;它变成 NaN 你就知道今晚又得加班了。但很多人对…

作者头像 李华
网站建设 2026/10/21 21:55:52

普通人抓住大模型风口的四大落地路径与实操指南

1. 先泼一盆冷水&#xff0c;再谈风口看到这个标题点进来的人&#xff0c;多半都在琢磨同一件事&#xff1a;大模型是不是真的能让我翻身&#xff1f;我过去一年花了大量时间研究、试用、落地各种大模型应用&#xff0c;先给个明确判断——风口是真的&#xff0c;但大多数人正在…

作者头像 李华
网站建设 2026/10/21 21:54:42

DeepContext:多轮对话意图漂移的LLM安全实时检测方案

多轮对话里的意图漂移&#xff0c;一直是LLM安全检测里最难缠的问题。单个回合看&#xff0c;每句话都干干净净&#xff0c;但把几轮串起来&#xff0c;攻击者的恶意意图就在对话进程中悄然完成了漂移和组装。我做的DeepContext项目&#xff0c;就是用有状态的实时检测机制&…

作者头像 李华