ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用Java+Spring Boot打造生日礼物网页:倒计时与加密彩蛋实战

用Java+Spring Boot打造生日礼物网页:倒计时与加密彩蛋实战 简介一款用Java实现的创意表白礼物程序面向有一定Java基础、希望用编程表达浪漫的程序员或学习者。项目组合音乐、蛋糕动画与心形图案演示Java Sound API音频播放、Swing界面设计、Java2D绘图和Timer动画控制体现面向对象设计模式的实际运用。压缩包内共109个文件包含Java源码、class字节码、png图片素材、XML配置、MP3背景音乐及jar依赖13个java源文件与14个class文件构成主体62张png图片负责界面装饰整体约52.13MB结构清晰便于导入IDE运行或二次修改。已有3225人学习下载。通过源码可掌握音乐循环播放、图形绘制与动态动画的具体实现借鉴单例模式与事件监听的写法将技术与浪漫创意结合适用于课程设计、期末项目或日常练手。1. 用 Java 给女朋友做生日礼物先想清楚做哪种形态用 Java 给女朋友做生日礼物最容易翻车的不是代码而是她根本没耐心打开你的作品。你要么给了她一个需要装 JRE 的桌面程序要么让她在浏览器里输一串 IP结果第二天服务就挂了。常见的做法有三条桌面小程序、网页小站、自动化脚本。桌面程序仪式感强但分发麻烦脚本省事但没有互动感网页最能兼顾“打开就能看”和“能藏彩蛋”。我建议走 Spring Boot H2 的网页方案本文就把这套方案的骨架、日期计算、加密彩蛋和踩坑点完整讲一遍。适合有 Java 基础、想送点带技术含量礼物的开发者和学生也适合借一个小项目把 Java 基础、Spring Boot、日期处理和加解密串起来复习。2. 搭一个能跑起来的生日礼物站Spring Boot H2 的骨架与三张表2.1 为什么选 Spring Boot H2而不是 Swing 或纯脚本很多人第一反应是用 Swing 或 JavaFX 写一个桌面弹窗标题写上“Happy Birthday”。这个方向的问题在于对方的电脑上大概率没有配好的 JRE 环境就算打成了 exe杀毒软件也可能直接拦掉你的签名。还有一条路是写一个定时脚本零点自动给她发一封邮件技术上是 JavaMailSender 几句代码的事但邮件被当垃圾邮件、或者她根本没注意到的概率都不低。网页方案的好处是跨平台、不需要装环境、互动感强。你只需要让她打开浏览器访问 localhost 的一个端口页面里能看到倒计时、回忆时间线和加密彩蛋打开一次就关掉也不影响下次再启动。Spring Boot 自带的 Tomcat 可以内嵌数据库用 H2 内存库连安装 MySQL 都省了。有人可能会说 Python 写这种小站更简单但 Java 这套做完能直接覆盖面向对象、集合排序、日期 API、Spring 容器这些 Java 工程师面试题里的高频考点值回票价。方案打开方式环境依赖维护成本仪式感Swing/JavaFX 桌面程序双击 jar/exe需 JRE可能被杀软拦打包繁琐高自动化脚本/邮件无界面靠推送SMTP 授权码容易被当垃圾低Spring Boot H2 网页浏览器打开 localhost只需 JDK本地启动即可中高2.2 最小工程结构pom.xml 与 application.yml 的关键配置先建一个普通 Maven 工程依赖就选四个spring-boot-starter-web提供 MVCspring-boot-starter-data-jpa负责和 H2 打交道spring-boot-starter-thymeleaf渲染页面h2作为内嵌数据库。版本号不用死记用 start.spring.io 生成时它会给你一组兼容版本唯一要记住的是Spring Boot 3.x 必须配 JDK 17机器只有 JDK 8 就选 2.7.x 分支否则启动必翻车。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.x.x/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scoperuntime/scope /dependency /dependencies这里我把 parent 的 version 写成3.x.x目的是提醒你不要手动填一个网上的版本号硬套本机 JDK 版本决定你该用哪个大版本。依赖里不需要写版本号因为它们都由 parent 统一管理这也是 Spring Boot 工程和普通 Maven 工程最大的区别。接着是配置文件src/main/resources/application.yml这个文件是整个礼物站的“总开关”server: port: 8080 spring: datasource: url: jdbc:h2:mem:giftdb;DB_CLOSE_DELAY-1 driver-class-name: org.h2.Driver username: sa password: jpa: hibernate: ddl-auto: update show-sql: false thymeleaf: encoding: UTF-8几个关键参数值得单独说。jdbc:h2:mem:giftdb;DB_CLOSE_DELAY-1表示 H2 跑在内存里DB_CLOSE_DELAY-1防止连接池在应用运行中意外关闭数据库。ddl-auto: update是让 Hibernate 根据实体类自动建表这跟你平时用 MyBatis-Plus 根据实体类生成建表 SQL 的思路一致实体类就是表结构的事实来源。如果以后数据想持久化把 url 改成jdbc:h2:file:./data/giftdb就能落到本地文件这一行配置送完礼物也能继续用。2.3 三张核心表与实体类纪念日、时间线、加密彩蛋礼物站至少需要三张表纪念日表存她的生日和你们的关键节点时间线表存回忆条目加密彩蛋表存一段密文。实体类就是这三张表的 Java 映射写的时候正好练习一下面向对象的设计。先看纪念日实体Entity Table(name anniversary) public class Anniversary { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false) private String name; // 纪念日名称生日、在一起的日子 Column(nullable false) private LocalDate day; // 重要日期用 LocalDate不带时分秒 protected Anniversary() { // JPA 需要无参构造这里留空即可 } public Anniversary(String name, LocalDate day) { this.name name; this.day day; } // getter / setter }日期字段必须用LocalDate而不是java.util.Date这是 Java 8 之后最值得坚持的习惯。LocalDate只存年月日天然避开了Date的时区偏移和可变性坑在数据库里对应 H2 的DATE类型存生日这种“日期语义”的数据刚刚好。时间线条目结构类似字段更简单title存事件标题happenedAt存发生日期。加密彩蛋表最特殊它不需要存明文只存一个content字段放 Base64 后的密文。我一般把这三张表配合三个 Repository 接口使用比如public interface AnniversaryRepository extends JpaRepositoryAnniversary, Long { OptionalAnniversary findFirstByOrderByDayAsc(); }Spring Data JPA 会根据方法名自动拼 SQLfindFirstByOrderByDayAsc()的意思是“按日期升序取第一条”用来取生日最合适。这三张表不需要手工建启动应用后打开 H2 控制台看一眼就能确认表结构Hibernate 日志里也能看到生成的 DDL。第一步跑通之后接下来的核心工作就是算倒计时和渲染时间线。3. 生日倒计时与时间线LocalDate 计算、日期排序和页面渲染3.1 倒计时算法先补今年的生日再算天数倒计时的需求很朴素打开页面就看到“还有多少天”。常见的错误写法是直接用Period.between(today, birthday)然后取getDays()这里有个陷阱Period是把两个日期拆成“几年几个月零几天”不是纯粹的总天数跨年的时候算出来和你直觉差很多。正确做法是先用今天所在的年份去构造“下一个生日”再做天数差。Service public class GiftService { private final AnniversaryRepository anniversaryRepository; public GiftService(AnniversaryRepository anniversaryRepository) { this.anniversaryRepository anniversaryRepository; } public long daysUntilNextBirthday() { LocalDate birthday anniversaryRepository.findFirstByOrderByDayAsc() .map(Anniversary::getDay) .orElseThrow(() - new IllegalStateException(还没有录入生日)); return daysUntilNextBirthday(birthday, LocalDate.now()); } long daysUntilNextBirthday(LocalDate birthday, LocalDate today) { LocalDate next birthday.withYear(today.getYear()); if (next.isBefore(today)) { // 今年的生日已经过了就按下一年算 next next.plusYears(1); } return ChronoUnit.DAYS.between(today, next); } }核心逻辑在第 12 行到第 15 行。birthday.withYear(today.getYear())把她的生日年份换成今年然后判断这个日期是否已经早于今天早于就plusYears(1)跳到明年。ChronoUnit.DAYS.between算的是两个日期之间的真实天数不用自己处理 30 天还是 31 天。注意我留了一个接收today的重载方法这是为了后面第 6 章的单元测试能传入固定日期不让测试结果跟着系统日期漂移。还有一个隐藏边界如果她的生日是 2 月 29 日withYear在平年会直接把它变成 2 月 28 日这种映射会让倒计时悄悄少一天。我的做法是显式处理当年不是闰年时手动构造成了 2 月 28 日并在页面上标注“今年按 2/28 过”。另外如果她过的是农历生日别在 Java 里自己换算农历标准库不提供农历支持你只需要把所有日期统一换算成公历后存进LocalDate计算逻辑不用改。提示页面显示“还有几天”之前先确认数据库里的生日是公历还是农历。我第一次就把农历当公历存结果提前一个月送了礼物这是整个项目里最不值得踩的坑。3.2 时间线排序Comparator 的用法和 yyyy/YYYY 的翻车现场时间线的数据来源是“回忆条目”比如第一次见面、第一次旅行、某次吵架和好。页面要按日期倒序展示最近的发生在上面。Java 里给集合排序有两种写法Collections.sort(list)和list.sort(comparator)后者是 List 接口自带的现在基本都用这个。ListTimelineItem timeline timelineRepository.findAll(); timeline.sort(Comparator.comparing(TimelineItem::getHappenedAt).reversed());Comparator.comparing(TimelineItem::getHappenedAt)是 JDK 8 引入的链式比较器先按happenedAt字段提取排序键.reversed()把正序变成倒序。这种写法比手写匿名内部类ComparatorTimelineItem干净得多也比用sort函数时自己写两两比较不容易出错。底层排序算法是稳定排序也就是说同一天发生的多条记录会保留它们在数据库里的原始顺序这个行为在时间线场景下很有用。真正让我翻车的不是排序本身而是日期解析。如果你从 Excel、CSV 或者前端表单收日期字符串千万别用new SimpleDateFormat(YYYY-MM-dd)这个YYYY是“周年”不是“公历年”它会把 2021 年 12 月 31 日格式化出2022来。正确姿势是用LocalDate.parse(2021-12-31, DateTimeFormatter.ofPattern(yyyy-MM-dd))。小写yyyy才是日历上的年份。看到这里你可以顺手检查一下自己项目里有没有大写YYYY的代码这个东西在 Java 基础面试里是经典八股陷阱。3.3 用 Controller 把数据送进模板最小页面渲染后端算好数据之后要交给 Thymeleaf 模板去渲染。Controller 的职责很轻只负责把数据塞进 ModelController public class GiftController { private final GiftService giftService; private final TimelineItemRepository timelineRepository; public GiftController(GiftService giftService, TimelineItemRepository timelineRepository) { this.giftService giftService; this.timelineRepository timelineRepository; } GetMapping(/) public String home(Model model) { model.addAttribute(days, giftService.daysUntilNextBirthday()); model.addAttribute(timeline, timelineRepository.findAll(Sort.by(Sort.Direction.DESC, happenedAt))); return index; } }model.addAttribute(days, ...)相当于往页面上下文里塞了一个名为days的变量模板里用th:text${days}取出来。时间线排序这里我用的是Sort.by(Sort.Direction.DESC, happenedAt)这是把排序下推到数据库的写法和上一节在内存里list.sort效果一样二选一即可没必要两层都做。对应的模板片段长这样p距离她的生日还有 span th:text${days}0/span 天/p ul li th:eachitem : ${timeline} th:text${item.happenedAt} ${item.title} 2019-09-01第一次见面 /li /ulth:each是 Thymeleaf 的循环${timeline}是 Controller 传进来的列表item是循环变量。渲染时每一条TimelineItem都会生成一个li把happenedAt和title拼成一行文字。做到这里一个能打开、能看到倒计时和回忆时间线的礼物站已经能跑了下一步给它加一个值得炫耀的惊喜功能。4. 加密彩蛋把想说的话用 AES-GCM 藏起来口令不对解不开4.1 为什么要把情话加密而不是直接写在页面里如果只是想在页面写一段表白直接存数据库字段、模板里th:text一下就行。但这样做有一个问题源代码会被对方看到H2 的数据库文件也能被任何文本编辑器打开惊喜感在打开的那一刻就泄底了。更好的设计是页面上留一个输入框提示“输入我们在一起那天的日期解锁一句话”口令输对了才显示内容。这里就涉及 Java 里常用到的对称加密AES。AES 是双向加密加密和解密用同一个密钥配合 GCM 分组模式还能同时校验数据完整性。放到礼物场景里的价值是密文写在代码库里也不怕被提前看到只有知道口令的人才能解开。这个思路跟生产环境里保护配置文件的逻辑是一样的做一遍等于把 Java 加密接口给练熟了。4.2 口令派生密钥不硬编码密钥用 SHA-256 取 16 字节AES 需要一个 128 位或 256 位的密钥你当然可以直接写一个byte[] key {...}硬编码在类里但这样密钥和密文在一起等于没有加密。合理做法是让密钥从口令推导出来她输入的口令是一串字符先做 SHA-256 哈希取前 16 个字节作为 AES-128 密钥。这个设计的巧妙之处在于不需要单独保存密钥加密时用口令推密钥解密时也推一次派生出的密钥相同就说明口令正确。这是 KDF密钥派生函数的最简形态生产系统会改用 PBKDF2 加盐但礼物场景追求可复现和零依赖SHA-256 够用。注意一点密钥只有 16 字节即 128 位不需要装 JCE 无限制策略文件JDK 8 到 JDK 17 的标准环境都能直接跑。4.3 一个可以直接抄的 AesUtil加密、解密与参数说明下面这个工具类可以原样放进项目里功能就是把“一句话”加密成一串 Base64再把那串 Base64 解回原话。public class AesUtil { private static final int IV_LENGTH 12; private static final int TAG_BITS 128; public static String encrypt(String plaintext, String passphrase) throws Exception { // 1. 生成 12 字节随机 IV每次加密结果都不一样 byte[] iv new byte[IV_LENGTH]; SecureRandom random new SecureRandom(); random.nextBytes(iv); // 2. AES/GCM/NoPadding 是 JDK 自带的推荐组合 Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); cipher.init(Cipher.ENCRYPT_MODE, deriveKey(passphrase), new GCMParameterSpec(TAG_BITS, iv)); byte[] ciphertext cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); // 3. 把 IV 和密文拼在一起返回解密时按长度切分 ByteBuffer buffer ByteBuffer.allocate(iv.length ciphertext.length); buffer.put(iv); buffer.put(ciphertext); return Base64.getEncoder().encodeToString(buffer.array()); } public static String decrypt(String payload, String passphrase) throws Exception { byte[] data Base64.getDecoder().decode(payload); ByteBuffer buffer ByteBuffer.wrap(data); // 前 12 字节是 IV剩下的才是密文 byte[] iv new byte[IV_LENGTH]; buffer.get(iv); byte[] ciphertext new byte[buffer.remaining()]; buffer.get(ciphertext); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); cipher.init(Cipher.DECRYPT_MODE, deriveKey(passphrase), new GCMParameterSpec(TAG_BITS, iv)); byte[] plaintext cipher.doFinal(ciphertext); return new String(plaintext, StandardCharsets.UTF_8); } private static SecretKey deriveKey(String passphrase) throws Exception { MessageDigest digest MessageDigest.getInstance(SHA-256); byte[] hash digest.digest(passphrase.getBytes(StandardCharsets.UTF_8)); byte[] keyBytes Arrays.copyOf(hash, 16); return new SecretKeySpec(keyBytes, AES); } }三个地方是网上搜“java aes解密”最容易抄错的。第一GCM 模式必须显式指定GCMParameterSpec标签长度TAG_BITS固定 128 位漏掉会抛InvalidAlgorithmParameterException。第二IV 必须随机且每次加密都变同一段明文加密两次结果不同是正常的所以不能把密文当固定指纹。第三我把 IV 和密文拼在一起再 Base64解密时先取前 12 字节这样就不用额外保存 IV 参数了代码也短。deriveKey里的SecretKeySpec是 AES 密钥的标准包装方式注释写着“SHA-256 前 16 字节”正好对应 AES-128。接着在 Controller 里加一个解锁接口PostMapping(/unlock) public String unlock(RequestParam(passphrase) String passphrase, Model model) { try { String secret aesUtil.decrypt( secretBoxRepository.findFirstByOrderByIdAsc().getContent(), passphrase); model.addAttribute(unlocked, secret); } catch (GeneralSecurityException | IllegalArgumentException e) { // 口令错、Base64 非法都会走这里统一提示 model.addAttribute(unlockError, 口令不对再想想); } return index; }注意异常捕获要兜住两类口令错误会让 GCM 在doFinal阶段抛AEADBadTagException它是GeneralSecurityException的子类而如果传进来的字符串连 Base64 都不合法会抛IllegalArgumentException。两个都捕获页面上统一显示“口令不对再想想”不要把异常堆栈返回给浏览器。密文存进SecretBox实体的时候就用AesUtil.encrypt(这里放一句只有你俩懂的话, 你们的某个日期)生成那一串 Base64 字符串。5. 送礼当天大概率踩的坑JDK、端口、乱码与编译内存 4 项排查礼物做完最怕的不是功能 bug而是拿到她面前启动不起来。下面这四个问题是我在 Java 工程里见得最多的启动期故障先给一张速查表后面逐个说排查命令和处理方式。现象首查方向常见程度启动闪退或UnsupportedClassVersionError本机 JDK 版本与 Spring Boot 不匹配极高Port 8080 was already in use端口被占用极高中文乱码页面或控制台出现“锟斤拷”编码没统一为 UTF-8中等编译时OutOfMemoryError: Java heap spaceIDEA 编译堆与 Maven 堆设置分离中等5.1 启动失败先查 JDK多版本环境变量最容易翻车现象是java -jar gift.jar后窗口一闪而过或者在日志里看到UnsupportedClassVersionError: org/springframework/boot/... has been compiled by a more recent version of the Java Runtime。原因基本就是 Spring Boot 3.x 的 class 文件是用 Java 17 编译的而机器上跑的是 JDK 8。先执行下面两条命令看基础环境java -version javac -version如果java -version显示1.8而javac -version显示17说明 PATH 里混了多个 JDK热搜里“java环境变量使用多个jdk”就是这个场景。解决方法是把JAVA_HOME指到一个确定的版本并让 PATH 优先使用%JAVA_HOME%\binWindows或$JAVA_HOME/binLinux/macOS然后关掉终端重新开一个再验证。如果不想动环境变量退而求其次在项目pom.xml里把 Spring Boot 的 parent 版本改成 2.7.x 分支它兼容 JDK 8。两条路都能救但最根治的还是统一 JDK 版本。5.2 端口被占用Address already in use 的三步处理现象是日志中间出现一行Web server failed to start. Port 8080 was already in use.或者BindException: Address already in use: bind。这是上一个没退干净的 Java 进程占了端口也可能是其他服务用了 8080。换端口是最快的绕行方案java -jar gift.jar --server.port8081如果你就是想让 8080 空出来那就查占用并杀掉。Windows 用netstat -ano | findstr :8080 taskkill /F /PID 12345Linux 用ss -ltnp | grep 8080 kill -9 12345netstat -ano里最后一列是 PIDtaskkill /F是强制结束。这套命令在所有 Java Web 项目里通用不光是生日礼物站。5.3 中文乱码从 IDE 到控制台再到页面现象是页面里“她的生日”显示成“她的甥日”或者控制台日志里的中文变成一堆问号。原因要分两层看编译期没有指定源码编码运行期 JVM 默认文件编码不对。Windows 下默认编码是 GBK而 Java 源码和 Thymeleaf 模板都是 UTF-8两边对不上就会乱。先保证 Maven 编译时用 UTF-8在pom.xml的properties里加上properties project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties运行 jar 的时候显式指定 JVM 文件编码java -Dfile.encodingUTF-8 -jar gift.jar如果页面还乱检查application.yml里的spring.thymeleaf.encoding是否被改过没有就显式写成UTF-8。控制台本身也要求终端编码是 UTF-8IDEA 里在设置中把文件编码统一为 UTF-8不要只用默认值。5.4 IDEA 编译堆内存 OOM不是调大到 8000 就完事现象是编译到一半报java.lang.OutOfMemoryError: Java heap space有些人直接把 IDEA 的编译堆调到 8000 MB 还是报错这种玄学调参我见过不少。原因在于 IDEA 里有两个独立的堆设置一个是 IDE 自身的 JVM 堆一个是构建进程的堆还有一个是 Maven 运行时自己的 JVM 堆。你只调了其中一个另外两个还是默认值自然没用。我的建议是IDEA 的Build, Execution, Deployment→Compiler→Shared build process heap size调到 1500 到 2000 MB 就够用别一上来就 8000笔记本内存扛不住。Maven 的问题用环境变量解决MAVEN_OPTS-Xmx1024m mvn clean package如果这样还 OOM先跑一遍mvn clean清掉旧的编译产物再检查是不是有依赖冲突或者某个模块在循环编译而不是继续加内存。6. 进阶与收尾生日当天自动切换生日模式再给日期计算补一个测试6.1 Scheduled 定时任务让网站在零点自己进入生日模式倒计时算对了还要保证生日当天打开页面显示的是“就是今天”而不是“还有 0 天”这种干巴巴的数字。用 Spring 自带的定时任务就能做到在启动类上加EnableScheduling然后在 Service 里写一个 cron 任务每天零点检查一次。cron 表达式0 0 0 * * *从左到右是“秒 分 时 日 月 周”也就是每天 00:00:00 触发。Scheduled(cron 0 0 0 * * *) public void markBirthdayToday() { if (daysUntilNextBirthday() 0) { log.info(今天是她的生日页面已切换生日模式); } }这里不要依赖定时任务去替你做“说生日快乐”这种动作它的意义在于让程序状态和真实日期保持同步。页面上再配合一句th:if${days 0}当天显示特别文案过了零点自动切回倒计时这个细节会让整个礼物更像一个“活的程序”。6.2 给倒计时逻辑补一个单元测试防止明年翻车日期代码最怕的不是写错而是改了别的功能之后把边界条件带崩。所以我在第 3 章特意给GiftService留了一个接收today的重载方法就是为了测试时把“今天”固定住不让断言结果跟着真实日期漂移。Spring Boot 自带 JUnit 5 和 AssertJ直接写就行Test void 生日当天返回0() { LocalDate birthday LocalDate.of(1999, 5, 20); GiftService service new GiftService(null); assertThat(service.daysUntilNextBirthday(birthday, LocalDate.of(2025, 5, 20))) .isEqualTo(0); } Test void 今年生日已过则算到明年() { LocalDate birthday LocalDate.of(1999, 5, 20); GiftService service new GiftService(null); assertThat(service.daysUntilNextBirthday(birthday, LocalDate.of(2025, 6, 1))) .isEqualTo(354); }两个测试分别锁住“当天为 0”和“已过生日自动跳明年”这两个最容易改坏的分支。GiftService(null)是因为测试只调重载方法不触发数据库访问所以 Repository 传 null 不会出问题。做完这个项目你会发现一个看似简单的生日礼物实际上把 Java 环境配置、LocalDate 日期计算、集合排序、AES 加解密、Spring Boot 自动装配和单元测试全串了一遍用来当 Java 面试前的练手项目非常值。我第一次送的时候因为农历公历没分清提前了一个月后来才明白所有花哨功能都不如一个能固定住日期的测试来得可靠。希望这个方案能帮到你也祝你的礼物一次成功。本文还有配套的精品资源点击获取
返回列表