我平时写得最多的是 Controller、Service、JPA 和 SQL。前端出问题时,我通常要补 Node 环境、Vite 配置、Vue 的响应式状态和路由。接口字段、空值和状态码只要没有对齐,前后端就得来回查。开工前,我估过一个中等复杂度 CRUD 项目:前后端各一人配合,大概需要一周。这个数只是我当时的工期预估,不是对照实验的结果。
飞算JavaAI 对全栈能力的宣传很明确:Java后端可以不系统学习前端框架,一个人完成前后端项目;它还给出过“传统约 7 天、AI 模式约 1 天”的效率对比。这些是官方宣传,不是本文验证的内容。我没有用传统方式重做同一项目,也没有记录逐项工时,所以不会拿这些数字替自己的项目背书。
我真正想弄清的是另一件事:首版生成后,如果需求继续加,代码还能不能维护?我又需要从哪里开始补前端知识?
这次我用“训程通|培训运营中心”来回答。它有培训场次、名额扣减、重复报名拦截、取消回补和现场签到;首版是我此前在 IntelliJ IDEA 中借助飞算JavaAI 插件生成的。系统跑通后,我又做了三轮维护:先把单页拆成多路由,再让 URL 保留名单筛选条件,最后补上账号登录、路由守卫和用户信息预填。本文只复盘这三轮,不讨论从零生成首版的过程。
先说结论:首版可以借助工具完成;一旦需求涉及跨页跳转、可恢复的筛选条件和登录态,我就需要自己理解路由、状态和请求链路。
一、起点基线:一个已经跑通的系统,和它被划定的边界
首先把项目基线和本次环境列出来,后面的代码与测试都以它为准:
| 项目 | 版本 / 说明 |
|---|---|
| 操作系统 | macOS 27.0(Build 26A428) |
| IDE | IntelliJ IDEA 2026.2.3 |
| 飞算JavaAI 插件 | CalEx-JavaAI: AI Coding Assistant(calex-javaai-jetbrains)3.9.16 |
| 后端编译目标 | Java 17(pom.xml 的 java.version);实际运行时为 JDK 25.0.2 |
| 后端框架 | Spring Boot 3.3.4 + Spring Data JPA;H2 内存数据库 |
| 后端构建 | Apache Maven 3.9.14 |
| 前端框架 | Vue 3.5.13 + TypeScript 5.7.2 + Vite 6.1.0 + Tailwind CSS 3.4.17 + Vue Router 4.6.4 |
| 前端运行时 | Node.js 26.3.0 / npm 11.13.0(仓库锁定 pnpm 11.10.0) |
| 浏览器 | Chrome 153.0.8010.50 |
| 业务场景 | 培训场次、名额扣减、重复报名拦截、取消回补、名单筛选和现场签到。 |
1. 首版需求原文:请注意其中划定的边界
我没有一上来就改代码,而是先回看首版到底承诺了什么。下面是我当时在 IDEA 插件对话框输入的完整需求,原文未删改。我把它放在代码块里,方便读者核对;其中“不用做登录、权限、收费、扫码和消息通知”这句话,也是理解第四节的前提:
我想从零做一个给公司培训管理员使用的培训报名后台,项目叫“训程通”。先做一个本地能跑起来的 Java Web 项目,前后端都要有。前端目录为 frontend,后端目录为 backend。不用做登录、权限、收费、扫码和消息通知,把培训场次、报名、取消和签到这条流程做好就行。
场次列表要显示培训名称、时间、地点、总名额、已报名人数和剩余名额,可以从某个场次进入报名表单、报名名单和签到页面。报名时填写姓名、工号、部门和联系电话,邮箱可选。报名成功后生成报名单号和报名时间,名单里能查到这条记录,已报名人数加一,剩余名额减一。同一个工号不能在同一场次重复有效报名,名额用完后不能继续报名。
报名名单要能按场次、姓名或工号搜索,并按报名状态和签到状态筛选。未签到的报名可以取消,取消后保留记录并释放一个名额;重复取消不能多释放名额,已签到的报名不能取消。取消后如果还有名额,可以重新报名,之前的取消记录要保留。已报名人数只计算未取消的报名,剩余名额等于总名额减去已报名人数。
签到时先选场次,再用工号或报名单号找到有效报名,成功后记录签到时间。未报名、已取消和重复签到都要给出明确提示,不能误改其他场次的记录。报名、取消和签到后,相关页面重新查询时都要显示同一份最新结果;名额和报名记录由后端一起更新,不能只改页面上的数字。
所有页面都要使用真实接口,数据写入数据库,不要只做静态页面或前端假数据。先准备两个虚构培训场次,其中一个只有三个名额,报名记录先为空,方便我检查报满、取消和重新报名。页面简洁、好用,有加载、空数据和失败提示,提交失败时保留输入。最后给我前后端代码、数据库初始化方式、接口说明和 README,写清楚怎么配置、启动和测试,以及重启后数据是否保留;实际做过哪些检查,也请如实告诉我。
所以,首版没有账号体系,是我当时划出的边界,不该算成 AI 漏了功能。另一方面,按场次和状态组合查名单的需求已经写在首版里,这也为第三节的维护埋下了伏笔。
2. 首版能验证的事实与我说不出口的数字
首版跑通后,我能写进复现记录的事实并不多,但都能回到源码或测试里核对:接口规范、前端 TypeScript 类型与后端 Entity/DTO 能对上;后端可以用 mvn spring-boot:run 启动,前端可用 pnpm dev 启动后联调。
证据边界:我没有用传统方式重做同一项目,也没有逐项记录工时。因此,本文不提供“节省多少天”或“提效多少倍”的数据。官方的“7 天 vs 1 天”属于官方口径,不是我的实测结论。
先把这个限制写清楚,后面的判断才不会被“提效数字”带偏。
二、第二轮需求来了:单页 App.vue 为什么会卡住?
如果需求只停在“先跑通一个 Demo”,全栈生成功能能帮我很快搭起首版。不过项目真正开始使用后,维护工作才会慢慢出现。
第二轮需求进来后,我发现首版的页面结构已经不适合继续往里塞功能:场次、名单和签到都挤在一个 App.vue 中。
项目当时还没有 Git 仓库,首版文件也没有单独留档。下面的变更前状态以维护包 CHANGE_REQUEST.md 为准:
- 我在名单页设好“已确认、未签到”等条件后,刷新页面,条件会丢失;
- 页面靠组件内变量切换,地址栏始终是
/,我没法把某个筛选后的名单链接交给同事; - 日期处理和状态文案散落在模板里,同一字段会出现多套写法。
这类问题已经不是给单个页面补按钮那么简单。如果我还把所有修改都塞回一个大组件,下一轮需求只会更难改。所以,我先把页面边界和状态来源梳理出来,再开始拆分。
三、多页拆分与 URL 状态持久化:刷新后还能恢复筛选条件
接下来,我没有急着重写业务接口。首先,我们把页面拆开;然后,我再处理筛选状态在刷新后的恢复。
1. 第一轮改动:三页独立路由与共享展示函数收敛
我把原来的单页拆成三个命名路由:
/courses:培训场次视图;/enrollments:报名名单视图;/check-in:现场签到视图。
我在 src/router/index.ts 中配置路由懒加载,再把模板里重复出现的日期和状态文案移到 src/utils/presentation.ts。下面是仓库中的真实代码:
export function formatDateTime(value?: string): string {
if (!value) {
return '—'
}
return value.replace('T', ' ').slice(0, 16)
}
export function formatDateRange(startTime?: string, endTime?: string): string {
if (!startTime || !endTime) {
return '—'
}
return formatDateTime(startTime) + ' 至 ' + formatDateTime(endTime)
}
export function courseStatusText(status: CourseStatus): string {
const labels: Record<CourseStatus, string> = {
ENROLLING: '报名中',
FULL: '名额已满',
FINISHED: '已结束'
}
return labels[status]
}
三个页面通过 component: () => import(...) 按需加载。这样一来,我们改日期格式或状态文案时,只需要改一处,不必在三个页面里反复搜替换。
2. 第二轮改动:URL Query 与响应式筛选双向绑定
我以前写后端时,很容易把筛选条件理解为组件里的局部变量。这次维护让我意识到:对于能分享、能刷新恢复的筛选页面,URL 必须是状态来源之一。页面状态和 URL 要能相互转换,不能只靠一次性的内存变量。
所以,我写了 src/composables/useEnrollmentFilters.ts。它把 route.query 转成 API 查询条件,也能把筛选结果写回地址栏。完整实现中的 filtersFromQuery 会校验 URL 参数并补上默认值;下面只保留转换和写回的关键部分,辅助解析、回读、请求参数和重置逻辑不在这里展开:
export function useEnrollmentFilters(route: RouteLocationNormalizedLoaded, router: Router) {
const filters = reactive<EnrollmentFilters>(filtersFromQuery(route.query))
watch(
() => route.fullPath,
() => Object.assign(filters, filtersFromQuery(route.query))
)
async function apply(): Promise<void> {
const query: Record<string, string> = {}
if (filters.courseId) {
query.courseId = String(filters.courseId)
}
if (filters.keyword.trim()) {
query.keyword = filters.keyword.trim()
}
if (filters.signStatus) {
query.signStatus = filters.signStatus
}
if (filters.status) {
query.status = filters.status
}
if (filters.page !== 1) {
query.page = String(filters.page)
}
// 筛选变化不新增历史记录,因此这里用 replace
await router.replace({ name: 'enrollments', query })
}
return { filters, asRequest, apply, reset }
}
这里我最在意的是 watch。它会在地址变化后重新读取筛选条件,因此浏览器前进、后退和刷新时,组件不会继续保留一份过期的内存状态。
然后,我把跨页面参数也接了起来:
- 场次卡片点击“名单”:带上
courseId,名单页据此筛选; - 名单操作点击“去核销”:把
courseId和学员identifier(工号)带到签到页。
筛选条件与 URL 的转换、跨页参数传递都由结构校验脚本逐条检查,具体见第六节。图 3、图 4 记录的是这次浏览器操作时的页面状态。
四、跨出首版需求边界:补齐账号登录、全局路由守卫与用户状态
回到第一节的需求原文。首版 Prompt 明确写了“不用做登录、权限、收费、扫码和消息通知”。所以首版没有账号认证和权限体系,是我当时划定的范围,不应把它当成生成结果的缺失。
不过,需求边界会变。如果我要让不同身份的人使用系统,报名需要识别用户,签到不能让任意访客操作,学员也不该每次报名都重复填写个人信息。于是,账号体系从首版的边界外需求,变成了第三轮的改造重点。做到这里,我必须同时理解路由守卫、响应式状态和请求生命周期。
1. 后端轻量 JWT 鉴权基础设施
这一轮里,我在后端 xunchengtong/backend 加入了本地 JWT 鉴权:
-
核心实体
SysUser:覆盖用户名、加盐哈希密码、真实姓名、部门、电话与角色枚举(ADMIN、TRAINER、STUDENT); -
密码存储:注册时使用 SHA-256 加随机 Salt(8 字节随机盐)。这是本地演示方案;如果进入生产环境,我会改用 BCrypt、PBKDF2 一类慢哈希;
-
预置三类演练账号:
- 管理员:
admin/admin123(拥有全量场次与名单管理权限); - 讲师:
trainer/123456(负责授课与签到核销); - 学员:
student/123456(自主选课报名并自动带出个人身份);另有备用学员EMP2002/123456(林峰),用于多学员场景演示,本文截图即以该账号登录。
- 管理员:
-
我增加了登录签发 Token(
POST /api/auth/login)、学员自主注册(POST /api/auth/register)和当前登录人资料查询(GET /api/auth/me)三个接口。
2. 前端路由守卫(Navigation Guards):未登录受控拦截与重定向回跳
前端不能只等接口返回 401。我还需要在路由跳转前先判断登录状态,把未登录用户送到登录页。
接下来,我在 src/router/index.ts 配置全局前置守卫。代码如下:
// 全局路由守卫:未登录时强制跳转登录页
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
if (to.path === '/login') {
if (token) {
next('/courses')
} else {
next()
}
} else {
if (!token) {
next({
path: '/login',
query: { redirect: to.fullPath }
})
} else {
next()
}
}
})
redirect 参数解决的是一个很实际的场景:有人打开同事分享的签到链接,例如 /check-in?courseId=2&identifier=EMP2005,系统先跳到登录页;登录成功后,再回到原来的地址,参数也还在。这样我不用在登录页另写一套临时状态来保存目标页面。
3. 响应式用户状态机:ref 与 LocalStorage 的配合
我在用户状态这里也绕开了两个常见问题:只存内存变量,刷新后登录态消失;只读 LocalStorage,其他组件又不知道用户已经登录或退出。
在 src/composables/useAuth.ts 中,我做了统一的状态机(以下节选自仓库真实源码,// 此处省略 register、fetchProfile 与 loading/error 处理):
// 持久化与响应式用户认证状态
const token = ref<string | null>(localStorage.getItem('token'))
const user = ref<User | null>((() => {
const cached = localStorage.getItem('user')
if (!cached) return null
try {
return JSON.parse(cached) as User
} catch {
return null
}
})())
export function useAuth() {
const isAuthenticated = computed(() => Boolean(token.value && user.value))
async function login(input: LoginInput) {
loading.value = true
error.value = null
try {
const result = await authApi.login(input)
token.value = result.token
user.value = result.user
localStorage.setItem('token', result.token)
localStorage.setItem('user', JSON.stringify(result.user))
return result.user
} catch (err) {
const message = err instanceof Error ? err.message : '登录失败'
error.value = message
throw err
} finally {
loading.value = false
}
}
async function logout() {
try {
await authApi.logout().catch(() => {})
} finally {
token.value = null
user.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
}
}
return {
token,
user,
loading,
error,
isAuthenticated,
login,
register,
logout,
fetchProfile
}
}
这里有两个我会继续沿用的做法。模块顶层的 ref 让各个组件拿到同一份状态,而不是各自读取 LocalStorage;登出时,我先调用后端 /api/auth/logout,再清理本地凭据,后端也能留下这次操作的记录。
4. 业务联动:表单预填与登出处理
登录态接通后,我又回到几个具体页面检查。LoginView.vue 里可以填入演示账号,也保留了新学员注册入口;App.vue 会显示当前登录人的名字,点击退出后会清掉本地状态并调用登出接口。学员在 CoursesView.vue 打开报名弹窗时,姓名、工号、部门、电话和邮箱会先带出来。
这里我没有把预填当作权限校验。表单字段仍然能修改,后端也没有强制报名工号必须等于当前账号。这个实现只服务于本地演示,离真正的账号绑定还有距离。
五、演进全景架构图
三轮迭代后,系统从单页拆成了多路由的本地应用,并加入了基础登录态:
flowchart LR
L[身份登录 /login] -->|登录成功 / 注册成功| C[培训场次 /courses]
G{全局路由守卫} -.->|未登录拦截 redirect| L
G -.->|已认证放行| C
G -.->|已认证放行| E[报名名单 /enrollments]
G -.->|已认证放行| S[现场签到 /check-in]
C -->|courseId| E
E -->|courseId + identifier| S
E -->|URL query| E
L -->|POST /api/auth/login, register| B[Spring Boot 后端]
C -->|GET /api/courses, POST /enrollments| B
E -->|GET /api/enrollments, POST cancel| B
S -->|GET /api/enrollments, POST sign| B
U[useAuth 用户上下文] -->|自动预填姓名/工号/部门/电话| C
U -->|实时展示姓名与登出| A[App.vue 顶部导航]
六、我实际跑过的检查
下面是我这次实际执行的命令和结果。如果你也在同一目录下,可以按顺序复跑:
1. 结构与契约自动化校验
运行维护包自带的校验脚本:
cd xunchengtong-maintenance
node evidence/verify-article12.mjs
实测结果:14/14 项检查全部 PASS。检查项包括 4 条命名路由(含 /login)、全局路由守卫、登录与自主注册入口、学员信息预填、跨页字段传递,以及后端认证和业务 Surefire 报告。它检查的是源码结构和已有测试报告,不能替代完整的浏览器端人工回归。
2. 前端类型检查与构建打包
cd xunchengtong/frontend
pnpm build
实测结果:vue-tsc && vite build 通过,没有类型错误;构建产物包含 LoginView、CoursesView、EnrollmentsView、CheckInView 四个按需加载页面,以及共享的 presentation 函数模块。
3. 后端自动化集成测试
cd xunchengtong/backend
mvn test
这次 mvn test 一共跑了 20 个用例,结果为 0 Failure、0 Error、0 Skipped。认证相关的 AuthIntegrationTest 有 5 个用例:它覆盖种子账号登录、错误密码的 401、注册时的用户名冲突、无效或缺失 Token,以及读取当前用户资料。
与跨页面维护直接相关的是 TrainingEnrollmentIntegrationTest,同样有 5 个用例。其中 courseAndEnrollmentFiltersExposeTheDateAndStatusFieldsUsedByThreePages 会检查课程日期和状态字段,也会检查报名记录按场次、关键字、报名状态和签到状态组合查询的结果。其余 10 个用例分属 EnrollmentSignRegressionIntegrationTest 和 SeedDataConsistencyTest,用于回归报名、签到和种子数据。
七、我看到的优点、问题和改进方向
我不想把这次经历写成工具优缺点的标准答案,只记录自己碰到的情况。
1. 优点
我拿到的前端类型、后端 Entity 和 DTO 能对上,第一轮没有花太多时间查字段不一致。页面和接口已经在那里,我可以直接从生成结果继续改,不用先搭一个空架子。更重要的是,项目的 Spring Boot 分层和 Vue 3 组合式 API 都是我能继续读下去的代码;拆路由、抽展示函数和加登录状态时,我能找到该改的文件。
2. 不足
但单页结构很快就卡住了。我要做深层跳转、分享筛选条件和刷新恢复时,只能自己拆路由、决定状态放在 URL 还是组件里。首版 Prompt 也没有写登录和权限,所以系统默认匿名运行。第三轮补鉴权时,前端路由、用户状态和后端接口保护必须一起改,不能只补一个登录页。
3. 改进建议
如果以后再做这类项目,我会在设计阶段先确认导航方式:只有一个简单操作面板时可以用单页;名单、详情、核销之间需要跳转时,我会直接选 Vue Router 多路由。鉴权也可以有一个可选骨架,但密码策略、密钥管理和权限模型仍要由项目自己确认。本地演示能跑,不等于能直接进生产环境。
八、我的结论:Java 后端要学到什么程度
做完从单页到多页、再到登录态的维护后,我回到开篇的问题:Java 后端还要不要学前端?
我的答案取决于任务本身。
如果只是给内部管理面板加表单和列表,我会先让全栈工具把首版搭出来,再自己检查接口、类型和基本运行结果。这个阶段不需要先把前端框架的每个概念学完。
可是一旦出现跨页面跳转、筛选条件要能复制分享、刷新后还得保留查询结果,我就绕不开 Vue Router。至少要知道编程式导航怎么传参数,route.query 怎样变成组件状态,什么时候该用 push,什么时候该用 replace。
再往下走到登录态、未登录拦截和表单预填,问题会从一个页面扩展到全局状态和请求过程。这时,我需要继续学 Navigation Guards、Composable 的响应式状态和 Axios 拦截器。否则,代码虽然能生成,我却很难判断 Token 应该在哪里读、退出后哪些状态要清、接口 401 又该怎样处理。
所以,我现在不会把“会用 AI 生成页面”当作完成交付。对我来说,工具负责加快首版落地,我们负责看懂后续修改会影响什么、在什么地方验证。前端不必一次学完,但路由、状态、请求和认证这些会直接影响维护的部分,Java 后端还是得自己接住。
#飞算JavaAI #AI 编程 #Java #全栈开发 #前后端分离 #后端开发 #前端开发 #IDEA 插件 #程序员 #IDEA 开发 #Java 开发 #SpringBoot #程序员必备
