Java Web

在线简历生成系统

一个在线简历编辑和预览系统,主要练习表单建模、模板渲染、数据保存和页面展示。

在线简历生成系统

技术栈

Java / Spring Boot / Thymeleaf / MySQL / 表单

项目说明

基于 Java + Spring Boot + MySQL 的在线简历生成系统开发记录

一、项目简介

这次开发的是一个在线简历生成系统,项目名称叫 云简匠 CareerCraft。它不是一个简单的静态简历模板页面,而是一个完整的 Java Web 系统,用户可以注册登录、在线填写简历、选择不同简历模板、实时预览简历效果,并且可以将最终简历导出为 PDF 文件。

项目使用的技术栈主要是:

  • Java 17
  • Spring Boot 3.3.5
  • Spring MVC
  • Spring Security
  • Spring Data JPA
  • Thymeleaf
  • MySQL
  • OpenHTMLToPDF

从功能上看,这个项目比较适合作为 Java Web 综合练习项目,也可以作为毕业设计、课程设计或者简历项目来展示。项目不仅有普通用户端,还有管理员后台。普通用户主要完成简历填写、模板选择、保存草稿、保存历史简历、预览和下载 PDF;管理员则可以对简历模板、用户和积分进行管理。

我这篇博客主要记录一下这个项目的整体开发过程,包括项目功能、页面效果、数据库设计、核心业务逻辑、关键代码和开发过程中遇到的问题。

二、项目页面展示

1. 系统主页

系统主页主要展示网站名称、核心功能说明、模板展示入口和登录注册入口。首页风格偏简洁实用,用户进入后可以很快知道这是一个在线简历生成系统。

系统主页

首页中比较重要的设计是模板缩略图展示。很多简历生成系统只是显示模板名称,用户很难知道模板最终长什么样。这个项目中首页和控制台都可以显示真实模板缩略预览,用户点击模板后可以直接进入对应模板的编辑页面。

2. 管理员后台页面

系统提供管理员后台,管理员登录后可以进行模板管理、用户管理、积分管理等操作。

管理员后台

后台的作用主要是维护系统数据。例如管理员可以新增模板、编辑模板、启用或停用模板,也可以查看用户列表、调整用户角色、修改用户积分等。这样项目就不是单纯的“个人简历编辑页面”,而是具备了一定平台管理能力。

3. 简历模板展示

系统内置了多个简历模板,每个模板都有自己的排版风格。模板支持真实缩略图展示,用户可以直接根据预览效果选择适合自己的模板。

简历模板

项目中内置模板名称也做了原创化处理,比如:

流光双栏模板、白屿极简模板、玄夜代码模板、靛蓝侧栏模板、青柠校招模板、松石事务模板、星链项目模板、琥珀数据模板、玫瑰卡片模板、墨线正式模板、星岸蓝线模板、夜幕弧光模板、海屿标题模板、青锋时间轴模板、紫境双栏模板、暖阳侧栏模板、云灰通栏模板、松青清雅模板、藏蓝名片模板。

这些模板不是简单地换颜色,而是通过 HTML 模板和占位符替换机制实现不同的排版样式。

三、项目功能分析

这个在线简历生成系统主要分为普通用户端和管理员端。

普通用户端功能包括:

  • 用户注册
  • 用户登录
  • 用户退出
  • 在线编辑简历
  • 上传头像
  • 选择简历模板
  • 实时预览简历
  • 控制简历模块显示与隐藏
  • 保存草稿
  • 保存到我的简历
  • 查看历史简历
  • 在线预览简历
  • 下载 PDF 简历
  • 编辑个人主页信息

管理员端功能包括:

  • 管理员登录
  • 模板新增
  • 模板编辑
  • 模板启用 / 停用
  • 用户管理
  • 用户启用 / 禁用
  • 用户角色调整
  • 用户积分管理

从实际开发角度看,这个项目的重点不只是页面展示,而是以下几个核心问题:

第一,用户登录之后,如何保证每个用户只能操作自己的简历数据。

第二,简历模板如何设计,才能让不同模板共用同一套用户数据。

第三,用户填写内容后,右侧实时预览如何刷新。

第四,HTML 简历如何转换为 PDF,而且还要解决中文字体显示问题。

第五,管理员后台如何对模板和用户进行统一管理。

四、项目整体结构

项目采用 Spring Boot 标准目录结构,核心目录大致如下:

resume-builder-java-mysql
├── src
│   └── main
│       ├── java
│       │   └── com.example.resumebuilder
│       │       ├── config
│       │       ├── controller
│       │       ├── data
│       │       ├── dto
│       │       ├── entity
│       │       ├── repository
│       │       ├── service
│       │       └── ResumeBuilderApplication.java
│       └── resources
│           ├── static
│           ├── templates
│           └── application.properties
├── pom.xml
├── schema.sql
└── README.md

其中几个核心目录的作用如下:

config 目录主要放系统配置类,例如 Spring Security 权限配置。

controller 目录主要放控制器,例如登录注册控制器、首页控制器、简历控制器、管理员模板控制器、管理员用户控制器等。

entity 目录主要放 JPA 实体类,例如用户实体、简历实体、模板实体、历史简历记录实体等。

repository 目录主要负责数据库访问,使用 Spring Data JPA 简化增删改查操作。

service 目录主要放业务逻辑,例如简历保存、模板渲染、PDF 生成、头像保存等。

templates 目录主要存放 Thymeleaf 页面模板。

static 目录主要存放 CSS、JavaScript、图标等静态资源。

五、数据库设计思路

这个项目使用 MySQL 保存用户、简历、模板和历史记录。核心表可以按照下面几个实体来理解。

1. 用户表

用户表主要保存登录账号、密码、角色、状态、积分等信息。系统使用 Spring Security 做权限控制,因此用户表需要能够区分普通用户和管理员。

大致字段包括:

id
username
password
role
enabled
points
created_at

其中 role 用于判断用户角色,普通用户可以访问简历编辑相关页面,管理员可以访问 /admin/** 后台页面。

2. 简历信息表

简历信息表保存用户当前正在编辑的简历内容,包括姓名、求职岗位、联系方式、教育经历、工作经历、项目经历、技能、证书、荣誉、头像路径、模板 ID 等。

为了让用户可以自由控制模块是否展示,表中还保存了很多布尔字段,例如:

show_phone
show_email
show_location
show_avatar
show_summary
show_education
show_experience
show_projects
show_skills
show_certificates
show_awards

这样用户在编辑页面取消勾选某个模块时,最终模板渲染时就不会输出对应内容。

3. 简历模板表

模板表用于保存模板名称、模板 HTML、主题色、缩略预览等内容。

模板的关键设计是:模板 HTML 中不直接写死用户数据,而是写占位符。例如:

{{fullName}}
{{jobTitle}}
{{contactLine}}
{{avatarBlock}}
{{summarySection}}
{{educationSection}}
{{experienceSection}}
{{projectsSection}}
{{skillsSection}}
{{certificatesSection}}
{{awardsSection}}

系统渲染模板时,会把这些占位符替换为用户真实填写的内容。

4. 历史简历记录表

历史简历记录表用于保存用户已经生成过的简历版本。用户每次点击“保存到我的简历”时,系统会生成一条历史记录,并保存当时渲染好的 HTML。

这样做的好处是,即使用户后面又修改了当前简历,历史简历仍然可以保持原来的内容,方便用户针对不同岗位保存不同版本。

六、项目运行方式

首先创建数据库:

CREATE DATABASE resume_builder DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

然后修改 src/main/resources/application.properties 中的数据库连接配置:

spring.datasource.url=jdbc:mysql://localhost:3306/resume_builder?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
spring.datasource.username=root
spring.datasource.password=root

spring.jpa.hibernate.ddl-auto=update
app.upload.dir=uploads

启动项目:

mvn spring-boot:run

浏览器访问:

http://localhost:8080

打包运行:

mvn clean package -DskipTests
java -jar target/resume-builder-1.0.0.jar

系统默认管理员账号:

用户名:admin
密码:admin123

普通用户可以通过 /register 页面自行注册。

七、核心功能一:Spring Security 登录权限控制

这个项目中,登录认证和权限控制使用的是 Spring Security。这样可以避免自己手动写很多拦截器和登录状态判断逻辑。

关键配置如下:

@Configuration
public class SecurityConfig {

    private final CustomUserDetailsService userDetailsService;

    public SecurityConfig(CustomUserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authenticationProvider(authenticationProvider())
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(
                                "/",
                                "/login",
                                "/register",
                                "/css/**",
                                "/js/**",
                                "/uploads/**",
                                "/icons/**",
                                "/manifest.webmanifest",
                                "/favicon.ico"
                        ).permitAll()
                        .requestMatchers("/admin/**").hasRole("ADMIN")
                        .requestMatchers(
                                "/resume/**",
                                "/dashboard",
                                "/profile",
                                "/profile/**"
                        ).authenticated()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/login")
                        .defaultSuccessUrl("/dashboard", false)
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/")
                        .permitAll()
                );

        return http.build();
    }
}

这段配置主要完成了三件事。

第一,首页、登录页、注册页和静态资源可以直接访问。

第二,/resume/**/dashboard/profile/** 这些页面必须登录后才能访问。

第三,/admin/** 后台页面必须拥有管理员角色才能访问。

密码加密使用的是 BCryptPasswordEncoder,这样用户密码不会以明文方式保存到数据库中,比直接存储明文密码安全很多。

八、核心功能二:简历编辑与保存

简历编辑是整个项目中最核心的功能。用户进入 /resume/edit 后,系统会获取当前登录用户的信息,然后查询或创建该用户对应的简历记录。

关键逻辑如下:

@GetMapping("/edit")
public String edit(@RequestParam(value = "templateId", required = false) Long templateId,
                   Model model,
                   Principal principal) {

    AppUser user = currentUser(principal);
    ResumeProfile profile = resumeService.getOrCreate(user);

    ResumeForm form;

    if (model.containsAttribute("form")) {
        form = (ResumeForm) model.asMap().get("form");
    } else {
        form = resumeService.toForm(profile);
    }

    List<ResumeTemplate> templates = templateRepository.findByActiveTrueOrderByIdAsc();

    ResumeTemplate selectedTemplate = null;

    if (templateId != null) {
        selectedTemplate = templateRepository.findById(templateId)
                .filter(ResumeTemplate::isActive)
                .orElse(null);

        if (selectedTemplate != null) {
            form.setTemplateId(selectedTemplate.getId());
        }
    }

    ResumeProfile previewProfile = resumeService.previewProfile(user, profile, form);

    model.addAttribute("form", form);
    model.addAttribute("profile", profile);
    model.addAttribute("selectedTemplate", selectedTemplate);
    model.addAttribute("resumePreviewHtml", renderService.renderEditorPreview(previewProfile));

    return "resume/edit";
}

这段代码的思路是:

用户进入编辑页面后,先根据当前登录用户查询简历信息。如果用户之前没有填写过简历,就创建一条默认简历数据。然后查询所有启用状态的模板,如果用户从首页点击某个模板进入编辑页,就根据 templateId 设置当前选中的模板。最后调用模板渲染服务生成右侧实时预览 HTML。

保存简历时,系统会根据按钮的不同动作进行不同处理。例如点击“保存草稿”只保存当前内容;点击“保存到我的简历”会生成一条历史简历记录。

@PostMapping("/edit")
public String save(@ModelAttribute("form") ResumeForm form,
                   @RequestParam(value = "avatarFile", required = false) MultipartFile avatarFile,
                   @RequestParam(value = "action", required = false, defaultValue = "publish") String action,
                   Principal principal,
                   RedirectAttributes redirectAttributes) {

    try {
        AppUser user = currentUser(principal);
        ResumeProfile profile = resumeService.save(user, form, avatarFile);

        if ("draft".equalsIgnoreCase(action)) {
            redirectAttributes.addFlashAttribute("success", "草稿已保存,下次可从“草稿箱”继续编辑。");
            return "redirect:/dashboard#drafts";
        }

        String html = renderService.render(profile);

        ResumeRecord record = new ResumeRecord();
        record.setUser(user);
        record.setTemplate(profile.getTemplate());
        record.setFullName(profile.getFullName());
        record.setJobTitle(profile.getJobTitle());
        record.setTemplateName(
                profile.getTemplate() == null ? "未选择模板" : profile.getTemplate().getName()
        );
        record.setRenderedHtml(html);

        ResumeRecord savedRecord = recordRepository.save(record);

        redirectAttributes.addFlashAttribute("success", "简历已保存到“我的简历”,可以预览或下载 PDF。");

        return "redirect:/resume/preview?recordId=" + savedRecord.getId();

    } catch (RuntimeException e) {
        redirectAttributes.addFlashAttribute("error", e.getMessage());
        redirectAttributes.addFlashAttribute("form", form);
        return "redirect:/resume/edit";
    }
}

这个功能设计比较符合真实使用场景。用户在编辑简历时,有时只是想临时保存,不一定马上生成最终版本;而当用户觉得某一版简历已经完成时,就可以保存到“我的简历”中,后续可以预览、回看或下载 PDF。

九、核心功能三:实时预览

项目编辑页采用“左侧填写信息 + 右侧实时预览”的工作台布局。用户输入内容后,前端通过请求 /resume/live-preview 接口获取最新 HTML,再刷新右侧预览区域。

后端接口如下:

@PostMapping(value = "/live-preview", produces = MediaType.TEXT_HTML_VALUE)
@ResponseBody
public String livePreview(@ModelAttribute ResumeForm form, Principal principal) {
    AppUser user = currentUser(principal);
    ResumeProfile savedProfile = resumeService.getOrCreate(user);
    ResumeProfile previewProfile = resumeService.previewProfile(user, savedProfile, form);
    return renderService.renderEditorPreview(previewProfile);
}

这里并不会立即把每次输入都保存到数据库,而是根据用户当前表单内容临时生成一个预览对象,然后交给模板渲染服务渲染 HTML。

这样做的好处是:

  • 输入时可以实时预览;
  • 不会频繁写入数据库;
  • 用户只有点击保存时才真正持久化;
  • 预览效果和最终导出效果使用同一套模板逻辑。

十、核心功能四:模板占位符渲染

模板系统是这个项目里比较重要的设计。不同简历模板并不是写死的页面,而是通过占位符进行动态替换。

模板 HTML 中可以写:

<div class="name">{{fullName}}</div>
<div class="job">{{jobTitle}}</div>
<div class="contact">{{contactLine}}</div>

{{avatarBlock}}
{{summarySection}}
{{educationSection}}
{{experienceSection}}
{{projectsSection}}
{{skillsSection}}
{{certificatesSection}}
{{awardsSection}}

后端渲染时,会把这些占位符替换成用户填写的数据。核心逻辑可以整理成下面这样:

@Service
public class TemplateRenderService {

    private final TemplateRepository templateRepository;
    private final AvatarStorageService avatarStorageService;

    public TemplateRenderService(TemplateRepository templateRepository,
                                 AvatarStorageService avatarStorageService) {
        this.templateRepository = templateRepository;
        this.avatarStorageService = avatarStorageService;
    }

    public String render(ResumeProfile profile) {
        ResumeTemplate template = profile.getTemplate();

        if (template == null) {
            template = templateRepository.findByActiveTrueOrderByIdAsc()
                    .stream()
                    .findFirst()
                    .orElseThrow();
        }

        return renderInternal(profile, template, false, false);
    }

    private String renderInternal(ResumeProfile profile,
                                  ResumeTemplate template,
                                  boolean usePlaceholderAvatar,
                                  boolean miniMode) {

        String html = template.getHtmlContent();

        Map<String, String> values = new LinkedHashMap<>();

        values.put("accentColor", safe(template.getAccentColor(), "#2563eb"));
        values.put("templateName", safe(template.getName(), "简历模板"));
        values.put("fullName", display(profile.getFullName(), "姓名"));
        values.put("jobTitle", display(profile.getJobTitle(), "求职岗位"));

        values.put("phone", visibleText(profile.getShowPhone(), profile.getPhone()));
        values.put("email", visibleText(profile.getShowEmail(), profile.getEmail()));
        values.put("location", visibleText(profile.getShowLocation(), profile.getLocation()));
        values.put("website", visibleText(profile.getShowWebsite(), profile.getWebsite()));

        values.put("contactLine", contactLine(profile, " | "));
        values.put("contactDot", contactLine(profile, " · "));
        values.put("contactStack", contactLine(profile, "<br>"));

        values.put("avatarBlock", avatarBlock(profile, usePlaceholderAvatar));

        values.put("summarySection", section(profile.getShowSummary(), "个人优势", profile.getSummary()));
        values.put("educationSection", section(profile.getShowEducation(), "教育背景", profile.getEducation()));
        values.put("experienceSection", section(profile.getShowExperience(), "工作 / 实习经历", profile.getExperience()));
        values.put("projectsSection", section(profile.getShowProjects(), "项目经历", profile.getProjects()));
        values.put("skillsSection", section(profile.getShowSkills(), "技能", profile.getSkills()));
        values.put("certificatesSection", section(profile.getShowCertificates(), "证书", profile.getCertificates()));
        values.put("awardsSection", section(profile.getShowAwards(), "荣誉", profile.getAwards()));

        for (Map.Entry<String, String> entry : values.entrySet()) {
            html = html.replace("{{" + entry.getKey() + "}}", entry.getValue());
        }

        return miniMode ? toMiniPreviewHtml(html) : html;
    }
}

这个设计的优点是模板和业务数据解耦。以后如果想新增模板,只需要在后台写一份新的 HTML 模板,然后使用同样的占位符即可,不需要改简历保存逻辑,也不需要重新设计数据库。

十一、核心功能五:模块显示与隐藏

简历中不同模块并不是每个用户都需要展示。例如,有些用户没有证书,有些用户没有项目经历,有些用户希望隐藏手机号或个人主页。

所以项目中给每个模块都加了显示控制字段,比如:

showPhone
showEmail
showLocation
showWebsite
showAvatar
showSummary
showEducation
showExperience
showProjects
showSkills
showCertificates
showAwards

模板渲染时会判断这个字段。如果用户取消显示某个模块,就返回空字符串。

示例逻辑如下:

private String visibleText(Boolean visible, String text) {
    if (!defaultTrue(visible) || isBlank(text)) {
        return "";
    }
    return display(text, "");
}

private String section(Boolean visible, String title, String text) {
    if (!defaultTrue(visible) || isBlank(text)) {
        return "";
    }

    return """
            <section class="section">
                <h2>%s</h2>
                <div class="section-content">%s</div>
            </section>
            """.formatted(
            HtmlUtils.htmlEscape(title),
            display(text, "")
    );
}

这个功能让简历生成更加灵活。用户不需要修改模板源码,只需要在编辑页面勾选或取消勾选,就可以控制最终简历中展示哪些内容。

十二、核心功能六:头像上传与预览

简历系统中头像处理也是一个比较常见的需求。用户可以上传头像,系统保存头像文件路径,然后在简历模板中渲染头像。

头像处理需要注意两个问题:

第一,头像文件不能直接写入数据库,一般保存到服务器目录中,数据库只保存文件路径。

第二,生成 PDF 时,图片路径可能无法被 PDF 渲染器正确读取,所以可以将图片转为 Data URI,再写入 HTML 中。

头像渲染的大致逻辑如下:

private String avatarBlock(ResumeProfile profile, boolean usePlaceholderAvatar) {
    if (!defaultTrue(profile.getShowAvatar())) {
        return "";
    }

    String dataUri = "";
    String avatarPath = profile.getAvatarPath();

    if (isBlank(avatarPath) && profile.getUser() != null) {
        avatarPath = profile.getUser().getDefaultAvatarPath();
    }

    if (!isBlank(avatarPath)) {
        dataUri = avatarStorageService.toDataUri(avatarPath);
    }

    if (dataUri.isBlank() && usePlaceholderAvatar) {
        dataUri = placeholderAvatarDataUri();
    }

    if (dataUri.isBlank()) {
        return "";
    }

    return "<div class=\"avatar\"><img src=\"" + dataUri + "\" alt=\"avatar\"></div>";
}

这样无论是网页预览还是 PDF 导出,都可以更稳定地显示头像。

十三、核心功能七:PDF 导出

PDF 导出是在线简历生成系统最重要的功能之一。用户填写完简历后,最终肯定希望下载一个可以投递的 PDF 文件。

项目中使用 OpenHTMLToPDF 将 HTML 转换为 PDF。控制器中的下载接口大致如下:

@GetMapping("/download")
public void download(@RequestParam(value = "recordId", required = false) Long recordId,
                     Principal principal,
                     HttpServletResponse response) throws IOException {

    AppUser user = currentUser(principal);
    ResumeProfile currentProfile = resumeService.getOrCreate(user);

    String html;
    String name;

    if (recordId != null) {
        ResumeRecord record = recordRepository.findByIdAndUser(recordId, user).orElse(null);

        if (record != null && record.getRenderedHtml() != null && !record.getRenderedHtml().isBlank()) {
            html = record.getRenderedHtml();
            name = record.getTitle();
        } else {
            html = renderService.render(currentProfile);
            name = currentProfile.getFullName();
        }
    } else {
        html = renderService.render(currentProfile);
        name = currentProfile.getFullName();
    }

    writePdf(response, html, name);
}

private void writePdf(HttpServletResponse response, String html, String name) throws IOException {
    byte[] pdf = pdfService.toPdf(html);

    String safeName = name == null || name.isBlank() ? "resume" : name;
    String fileName = URLEncoder.encode(safeName + "-简历.pdf", StandardCharsets.UTF_8)
            .replaceAll("\\+", "%20");

    response.setContentType(MediaType.APPLICATION_PDF_VALUE);
    response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + fileName);
    response.getOutputStream().write(pdf);
}

这里的细节是:如果用户下载的是历史简历,就优先使用历史记录中保存的 HTML;如果历史记录不存在或者 HTML 为空,则使用当前简历内容重新渲染。这样可以避免下载历史简历时出现空内容或报错。

十四、PDF 中文字体处理

HTML 转 PDF 时,经常会遇到中文显示为方块的问题。原因是 PDF 渲染器找不到合适的中文字体。

项目中没有直接打包字体文件,而是在运行时尝试读取系统字体。这样可以避免字体版权问题,也能兼容不同操作系统。

关键逻辑如下:

private void registerCjkFont(PdfRendererBuilder builder) {
    List<String> candidates = List.of(
            "C:/Windows/Fonts/msyh.ttc",
            "C:/Windows/Fonts/msyh.ttf",
            "C:/Windows/Fonts/simsun.ttc",
            "C:/Windows/Fonts/simsun.ttf",
            "/System/Library/Fonts/PingFang.ttc",
            "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
            "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
            "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"
    );

    for (String p : candidates) {
        File file = Path.of(p).toFile();
        if (file.exists()) {
            builder.useFont(file, "AppCJK");
            return;
        }
    }
}

这段代码会依次查找 Windows、macOS 和 Linux 上常见的中文字体。如果找到了字体,就注册给 PDF 渲染器使用。

如果部署到 Linux 服务器后 PDF 中文显示异常,可以安装 Noto Sans CJK 字体,或者在这段代码中加入服务器实际字体路径。

十五、PDF 生成兜底处理

PDF 生成还有一个问题:浏览器可以正常显示的 HTML,不一定能被 OpenHTMLToPDF 完全支持。例如部分 CSS 渐变、阴影、transform、WebP 图片等,都可能导致 PDF 生成失败或者排版异常。

因此项目中对 HTML 进行了规范化处理:

public byte[] toPdf(String html) {
    String safeHtml = normalizeHtml(html);
    String pdfHtml = prepareHtmlForPdf(safeHtml);

    try {
        return renderToPdf(pdfHtml);
    } catch (Exception first) {
        try {
            return renderToPdf(removeImages(pdfHtml));
        } catch (Exception second) {
            try {
                return renderToPdf(prepareHtmlForPdf(fallbackHtml(safeHtml)));
            } catch (Exception third) {
                throw new IllegalStateException("PDF 生成失败,请检查模板 HTML 是否闭合完整。", third);
            }
        }
    }
}

这里做了三层兜底:

第一层,正常渲染 HTML。

第二层,如果图片导致失败,就移除图片后重新生成,保证简历主体内容还能导出。

第三层,如果模板 HTML 本身有问题,就生成一份兜底版纯文本简历,避免用户点击下载后直接报 500 错误。

这种处理方式提升了系统的稳定性,尤其是在模板数量较多、用户上传头像格式不统一的情况下比较实用。

十六、管理员后台模板管理

管理员后台主要用于维护模板。模板管理功能包括:

  • 查看模板列表
  • 新增模板
  • 编辑模板
  • 启用模板
  • 停用模板
  • 设置模板名称
  • 设置模板主题色
  • 编辑模板 HTML 内容

管理员新增模板时,只需要按照系统占位符规则编写 HTML,例如:

<div class="page">
    <header class="resume-header">
        {{avatarBlock}}
        <h1>{{fullName}}</h1>
        <p>{{jobTitle}}</p>
        <div>{{contactLine}}</div>
    </header>

    {{summarySection}}
    {{educationSection}}
    {{experienceSection}}
    {{projectsSection}}
    {{skillsSection}}
    {{certificatesSection}}
    {{awardsSection}}
</div>

只要占位符命名正确,后端渲染服务就可以自动把用户数据填充进去。这个机制让模板扩展变得非常方便。

十七、我的简历与草稿箱

项目后期新增了“草稿箱”和“我的简历”功能。

草稿箱主要用于保存用户当前编辑到一半的内容。用户可能今天只填了基本信息和教育经历,明天再继续补充项目经历和工作经历,这种情况下保存草稿就很实用。

“我的简历”则用于保存已经生成过的简历版本。用户可以针对不同岗位保存不同简历,例如:

  • Java 开发工程师简历
  • 后端开发实习简历
  • 运维助理简历
  • 项目管理方向简历

每条历史简历记录都保存了当时的渲染 HTML,因此用户后续修改当前简历,也不会影响历史版本。

简历中心页面路径为:

/resume/my

里面集中展示草稿和历史简历记录,用户可以进行预览、回看和下载 PDF。

十八、移动端适配

这个项目还做了安卓手机端适配。移动端适配主要包括:

  • 添加 viewport,避免手机端默认缩放成电脑网页;
  • 首页、登录页、注册页、控制台、简历编辑页做单列布局;
  • 输入框字号调整为 16px,减少 Android Chrome 自动放大输入框;
  • 导航栏在小屏幕下支持横向滑动;
  • 简历预览页使用自适应 iframe;
  • 模板预览在手机端自动调整显示;
  • 增加 Web App Manifest,可以添加到手机主屏幕。

手机访问时,需要电脑和手机连接同一个 Wi-Fi,然后在手机浏览器中访问:

http://电脑局域网IP:8080

如果访问不了,一般需要检查 Windows 防火墙是否放行 8080 端口。

十九、开发过程中遇到的问题

1. PDF 中文乱码或方块字

这是 HTML 转 PDF 中最常见的问题。浏览器显示中文正常,不代表 PDF 渲染器也能正常显示。最后通过运行时查找系统中文字体来解决。

Windows 下优先查找微软雅黑和宋体,macOS 下查找 PingFang,Linux 下查找 Noto Sans CJK 或文泉驿字体。

2. 浏览器 CSS 和 PDF CSS 支持不一致

浏览器支持很多现代 CSS,例如阴影、渐变、flex、grid、transform 等。但是 OpenHTMLToPDF 对这些样式支持不完全。如果完全照搬浏览器页面样式,PDF 导出可能会失败。

解决方法是对 PDF 渲染前的 HTML 进行清洗,移除不稳定 CSS,只保留核心排版、字号、颜色、边距等样式。

3. 历史简历下载时出现空 HTML

一开始历史简历下载时,如果 recordId 异常或者历史 HTML 为空,可能导致 Whitelabel 500 错误。后面在下载接口中增加判断,如果历史记录不可用,就回退到当前简历重新渲染,避免用户直接看到错误页。

4. 头像图片导致 PDF 生成失败

部分头像格式或者图片数据可能导致 PDF 生成失败。项目中增加了图片失败兜底逻辑:如果正常渲染失败,就去掉图片再生成 PDF,至少保证文字内容和模板主体可下载。

5. 模板缩略图显示不真实

最初如果只是用模板名称展示,用户无法直观选择模板。后面通过示例简历数据渲染每个模板的真实 HTML,再以缩略图方式展示,使模板选择体验更好。

二十、项目亮点总结

这个项目的亮点主要有以下几点。

第一,功能完整。系统包含用户注册登录、简历编辑、模板选择、实时预览、头像上传、草稿箱、历史简历、PDF 下载和管理员后台。

第二,模板机制灵活。通过占位符替换实现简历数据和模板样式分离,后期可以比较方便地新增模板。

第三,权限控制清晰。使用 Spring Security 区分游客、普通用户和管理员,不同角色可以访问不同页面。

第四,PDF 导出具有实用价值。简历生成系统最终一定要能下载文件,项目中通过 OpenHTMLToPDF 实现了 HTML 到 PDF 的转换。

第五,考虑了中文字体问题。PDF 生成时自动尝试加载系统中文字体,减少中文乱码和方块字。

第六,有管理员后台。管理员可以维护模板和用户,使系统更像一个完整平台,而不是简单 Demo。

第七,有移动端适配。安卓手机浏览器可以访问同一个地址,页面布局会自动适应小屏幕。

二十一、简历项目描述写法

如果要把这个项目写进简历,可以这样写:

云简匠 CareerCraft 在线简历生成系统 技术栈:Java 17、Spring Boot、Spring Security、Spring Data JPA、Thymeleaf、MySQL、OpenHTMLToPDF

  • 独立完成在线简历生成系统设计与开发,实现用户注册登录、简历编辑、模板选择、实时预览、草稿保存、历史简历管理和 PDF 导出等功能。
  • 基于 Spring Security 实现权限控制,区分普通用户与管理员角色,普通用户可编辑和管理个人简历,管理员可进行模板、用户和积分管理。
  • 设计模板占位符替换机制,实现用户数据与 HTML 简历模板的动态渲染,支持多套简历模板和真实缩略图预览。
  • 集成 OpenHTMLToPDF 实现简历一键导出 PDF,并处理中文字体兼容、图片异常和 HTML 兜底渲染问题。
  • 使用 Thymeleaf、HTML、CSS、JavaScript 完成首页、控制台、编辑页、预览页和后台管理页面开发,并进行移动端响应式适配。

二十二、项目总结

通过这个项目,我对 Spring Boot Web 项目的完整开发流程有了更加系统的理解。这个项目从用户登录、权限控制、数据库设计、表单保存、模板渲染、文件上传、PDF 生成到管理员后台管理,覆盖了 Java Web 开发中的多个常见模块。

相比普通的增删改查系统,这个项目更有实际应用场景。用户不是单纯地录入数据,而是通过系统生成一份可以真实使用的 PDF 简历。模板渲染、模块显示控制、实时预览和 PDF 导出这些功能,也让项目比传统后台管理系统更有展示价值。

后续如果继续完善,可以增加更多功能,例如:

  • 增加在线支付或积分兑换模板;
  • 增加 AI 简历优化建议;
  • 增加简历评分功能;
  • 增加更多行业模板;
  • 增加简历分享链接;
  • 增加模板市场;
  • 增加用户投递记录管理;
  • 增加简历内容版本对比功能。

总体来说,云简匠 CareerCraft 是一个比较适合写进简历和博客展示的 Java Web 项目,能够体现 Spring Boot、Spring Security、MySQL、Thymeleaf、文件上传、PDF 生成和后台管理系统开发等综合能力。