我不再使用 Excel:如何使用 Spring Boot 3 与 Angular 17 构建企业级工作跟踪器

发布: (2025年12月23日 GMT+8 18:59)
6 min read
原文: Dev.to

Source: Dev.to

Note: 这不仅仅是一个简单的 CRUD 应用。我把它当作一次 系统设计挑战 来处理,重点关注安全性、并发性和云原生存储模式。

🔗 资源

🛠️ 技术栈

技术
后端Java 21 + Spring Boot 3.2
安全Spring Security 6 (OAuth2 + JWT)
数据库PostgreSQL(生产)/ MySQL(开发)
存储Cloudflare R2(兼容 AWS S3)
邮件Spring Mail(SMTP)+ Brevo
前端Angular 17(Signals)+ TailwindCSS + D3.js
DevOpsDocker + Google Cloud Run + GitHub Actions

Source:

🧠 架构深度解析

以下是我在构建 JobTrackPro 时解决的四个最有趣的工程难题。

1️⃣ 混合认证(Google/GitHub + 本地)

目标 – 让用户可以使用 Google 或 GitHub 登录 并且 后续设置密码以脱离外部账号。

流程

Hybrid Authentication Flow

实现了自定义的 OAuth2SuccessHandler,它:

  • 检查邮箱是否已存在。
  • 如有必要,实时创建用户。
  • 同步头像 URL。
  • 为无状态前端颁发 JWT
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        // Extract provider‑specific attributes
        OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication;
        OAuth2User oAuth2User = authToken.getPrincipal();
        String registrationId = authToken.getAuthorizedClientRegistrationId();

        UserInfo userInfo = extractUserInfo(registrationId, oAuth2User.getAttributes());

        // Upsert user (create if absent, update otherwise)
        User user = userService.upsertUser(userInfo);

        // Generate JWT
        String token = jwtService.createToken(user);

        // Redirect back to Angular with the token
        String redirectUrl = uiUrl + "/login-success?token=" + token;
        getRedirectStrategy().sendRedirect(request, response, redirectUrl);
    }
}

2️⃣ 使用 CompletableFuture 的多线程分析

仪表盘展示四个关键指标。顺序执行查询成为瓶颈,于是我将它们并行化:

// Run aggregations in parallel
CompletableFuture<Long> activeFuture = CompletableFuture.supplyAsync(
    () -> jobRepository.countActiveJobs(email), executor);

CompletableFuture<Long> interviewFuture = CompletableFuture.supplyAsync(
    () -> jobRepository.countInterviews(email), executor);

CompletableFuture<Long> offerFuture = CompletableFuture.supplyAsync(
    () -> jobRepository.countOffers(email), executor);

// Wait for all to finish
CompletableFuture.allOf(activeFuture, interviewFuture, offerFuture).join();

Long activeCount    = activeFuture.join();
Long interviewCount = interviewFuture.join();
Long offerCount      = offerFuture.join();

3️⃣ 原子化的个人资料更新 & Cloudflare R2

将图片存为 BLOB 会导致备份膨胀,而本地磁盘在无服务器平台上不可用。解决方案是使用 Cloudflare R2,并通过单一原子事务完成:

@Transactional
public void updateProfile(ProfileDto dto, MultipartFile avatar) {
    // 1. Upload new avatar to R2 (if present)
    String newAvatarUrl = null;
    if (avatar != null && !avatar.isEmpty()) {
        newAvatarUrl = r2Service.upload(avatar);
    }

    // 2. Update profile fields
    User user = userRepository.findById(dto.getUserId())
                               .orElseThrow(() -> new NotFoundException("User not found"));
    user.setFullName(dto.getFullName());
    user.setBio(dto.getBio());

    // 3. Replace avatar URL atomically
    if (newAvatarUrl != null) {
        String oldAvatarUrl = user.getAvatarUrl();
        user.setAvatarUrl(newAvatarUrl);
        // Delete old file after successful DB commit
        if (oldAvatarUrl != null) {
            r2Service.delete(oldAvatarUrl);
        }
    }

    userRepository.save(user);
}

4️⃣ 异步邮件系统

“忘记密码”流程现在会立即返回,而邮件则在后台发送:

@Service
public class EmailService {

    @Async
    public void sendPasswordReset(String to, String resetLink) {
        SimpleMailMessage message = new SimpleMailMessage();
        message

.setTo(to);
        message.setSubject("Password Reset Request");
        message.setText("Click the link to reset your password: " + resetLink);
        mailSender.send(message);
    }
}

⚡ Angular Signals & Optimistic UI

前端完全依赖 Angular 17 Signals。状态(工作列表、统计、个人资料)是响应式的,支持乐观 UI 更新。

“魔法”时刻 – 当添加工作时,UI 会立即更新 Signal 列表,同时在后台执行 POST 请求。仪表盘图表会即时重新渲染。

// Angular service: optimistic update
async addJob(job: Job) {
  // 1️⃣ Optimistically update the local signal
  const current = this.jobsSignal();
  this.jobsSignal.set([...current, job]);

  // 2️⃣ Send request to backend
  try {
    const saved = await firstValueFrom(this.http.post(`${this.apiUrl}/jobs`, job));
    // Replace the optimistic entry with the server‑generated one (e.g., ID)
    this.jobsSignal.update(list => list.map(j => (j.tempId === job.tempId ? saved : j)));
  } catch (err) {
    // Rollback on error
    this.jobsSignal.update(list => list.filter(j => j.tempId !== job.tempId));
    throw err;
  }
}

🎉 最后思考

JobTrackPro 展示了 精心构建的云原生堆栈 如何将个人生产力工具转变为企业级应用。关键要点:

  • 混合 OAuth2/JWT 认证
  • 使用 CompletableFuture 进行并行分析
  • 在 Cloudflare R2 上进行原子媒体处理
  • 异步邮件发送
  • 响应式 Angular Signals

这种组合提供了快速、安全且令人愉悦的用户体验——正是现代求职工作流所应得的。欢迎随意探索演示、给仓库加星,或在有建议时提交 issue! 🚀

🏁 结论

构建 JobTrackPro 让我明白,“全栈”不仅仅是把数据库连接到前端。它还涉及处理边缘情况——例如图片上传失败、保护 OAuth2 重定向,以及使分析可扩展。该项目保持 开源,以帮助开发者构建类似的仪表盘。

💬 Discussion

你尝试过使用 Cloudflare R2 来存储图片吗?我发现它比 S3 便宜很多。请在评论中告诉我你对该架构的看法!

感谢阅读!祝编码愉快!🚀

Back to Blog

相关文章

阅读更多 »