这次我们来看一个非常典型的 Java 全栈实战项目方向:国之动力科普网站。它不是那种包装复杂的低代码平台,而是用目前企业里最主流的组合JAVA + SpringBoot3 + Vue.js3 + MySQL搭出来的前后端分离科普内容管理系统。无论你是准备做毕业设计,还是想自己练一个能写进简历的完整 Web 项目,这套技术栈都足够有代表性。
项目的核心问题很简单:科普内容要能发布、能分类、能检索、能管理,并且页面要能稳定展示。所以它的重点不在 AI、不在大模型,而在后端接口设计、数据库建模、前端页面渲染、登录鉴权和批量内容管理这些后端开发基本功上。这篇文章会直接用一套可落地的工程结构,把这个类型网站从数据库设计、后端接口、前端页面到本地启动全部走一遍,并给出实际可复制的代码骨架和验证流程。
本文适合下面几类读者:正在学 SpringBoot3 的 Java 后端、准备做 Vue3 管理后台或内容站的前端、需要一套科普类 CMS 参考实现的开发者。读完至少能获得三个收益:第一,知道这类项目应该如何拆模块、建表、写接口;第二,拿到一套能启动的前后端最小工程结构;第三,理解上线前要做哪些配置、测试和排查。
1. 项目定位与核心能力速览
先把整个项目的能力边界画出来。从项目名称看,网站的主题是“科普”,核心功能路径一般是:普通访客浏览科普文章/专题 → 按分类检索内容 → 管理员登录后台 → 对文章进行新增、编辑、上下架、删除 → 内容在前台同步展示。
| 能力项 | 说明 |
|---|---|
| 技术栈 | Java、SpringBoot3、Vue.js3、MySQL |
| 项目类型 | 前后端分离的科普内容展示与管理网站 |
| 主要功能 | 科普文章列表、文章详情、分类浏览、搜索、后台登录、文章管理、分类管理、批量上下架 |
| 前端用户 | 普通访客(不登录可浏览)、内容管理员(后台维护) |
| 后端服务 | RESTful API、统一返回结构、JWT 登录鉴权、参数校验 |
| 数据存储 | MySQL 存储用户、分类、文章、标签等核心数据 |
| 启动方式 | 本地启动后端 SpringBoot 服务 + 前端 Vue3 开发服务器 |
| 部署形态 | 前后端可分离部署,也可打包后由 Nginx 统一托管 |
| 适合场景 | Java 全栈练手、毕业设计、科普内容站快速原型 |
需要提醒一个点:由于原始项目介绍比较精简,这里不会写死某个开源仓库的细节。更多是把该技术栈下“科普网站”这类项目最通用、最可靠的实现路径讲完整,你在实际复刻时把表名、字段、页面文案替换成自己的内容即可。
2. 科普网站功能设计与使用边界
这类网站要做的不是花哨页面,而是把一套“内容从录入到发布”的链路走通畅。开发前先把角色和核心流程说清楚,写代码时才不会乱。
2.1 访客侧与管理员侧功能划分
普通访客能做的事情相对简单:
- 浏览首页推荐的科普文章。
- 按科普分类查看列表,比如“能源动力”“科技前沿”“工程原理”等。
- 通过标题关键字搜索文章。
- 查看文章详情,包括正文、发布时间、来源、所属分类。
管理员侧才是系统重点,通常需要拆成独立的后台界面:
- 管理员登录和退出登录。
- 分类管理:新增、重命名、停用分类。
- 文章管理:新增文章、编辑文章、删除文章、批量上下架。
- 内容审核状态控制:草稿、已发布、已下架。
- 网站基础统计:文章总数、分类数量、最近发布数量。
2.2 核心业务规则
设计时要明确几点规则,避免后面功能改来改去:
- 文章状态建议用枚举值:0 草稿、1 已发布、2 已下架。
- 前台只展示“已发布”的文章。
- 修改文章后,如果原来是已发布状态,可以选择重新发布或者转为草稿。
- 分类逻辑上不建议做太深,当前项目一级分类即可;如果未来扩展二级分类,需要再加 parent_id 字段。
- 科普文章内容可能较长,正文建议使用
TEXT或LONGTEXT类型存储,富文本或 Markdown 内容可在前端自行选择渲染方案。
2.3 使用边界与合规提醒
科普网站内容面向公众,需要注意几个边界,也同样适用于任何内容发布系统:
- 内容准确性问题:涉及科技、工程、能源等内容时,发布前要有来源核对机制,避免传播未经证实的信息。
- 版权问题:转载科普文章要获得授权;图片、视频、字体素材要确认版权。
- 内容安全:后台需要登录鉴权,不能让未登录用户直接调用管理接口;公开接口需要增加访问频率限制,防止被刷。
- 个人隐私:系统内如果有用户注册、评论等功能,涉及用户昵称、邮箱、手机号时应脱敏展示,并遵守隐私保护要求。
2.4 开发时需要提前想清楚的非功能需求
除了功能之外,下面的问题如果不提前设计,后期会很被动:
- 文章列表接口是否分页?必须分页,不然数据一多页面卡顿。
- 文章正文是否支持图片上传?如果需要,要提前规划上传接口和静态资源访问路径。
- 是否需要 Markdown 编辑器?如果希望编辑体验好,可以在后台引入 Markdown 编辑器,对应的前端需要有渲染组件。
- 是否记录文章创建人、创建时间、更新时间?建议统一增加 create_time、update_time 字段。
- 管理员数量多不多?如果只有一个管理员,登录表可以简单一些;如果多人管理,建议加角色字段。
3. 技术栈拆分与数据库设计
SpringBoot3 对 JDK 版本有要求,一般建议使用JDK 17 及以上。前端 Vue.js3 配合 Vite 构建,开发效率比 Webpack 时代高很多。MySQL 建议使用8.0 及以上,方便使用更完善的 JSON 支持和字符集能力。
3.1 技术栈与工具清单
后端: - JDK 17 - SpringBoot 3.x - Spring Web - Spring Data JPA 或 MyBatis-Plus - Spring Validation - MySQL Connector/J - JWT(登录鉴权,可使用 jjwt 或 hutool 等工具库) 前端: - Node.js 18+ - Vue 3 - Vite - Vue Router 4 - Pinia - Axios - Element Plus(后台管理界面组件库) 数据库: - MySQL 8.0 - Navicat 或 MySQL Workbench如果团队习惯 MyBatis,也可以把持久层换成MyBatis-Plus,它在单表 CRUD 场景下非常省事。下面示例使用 Spring Data JPA 思路做讲解,实际项目二选一即可。
3.2 数据库表设计
科普网站最核心的就是文章表和分类表。这里提供一套最简但可运行的表结构:
3.2.1 分类表 category
CREATE TABLE `category` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '分类ID', `name` varchar(64) NOT NULL COMMENT '分类名称', `sort` int DEFAULT 0 COMMENT '排序号,越小越靠前', `status` tinyint DEFAULT 1 COMMENT '状态:1启用 0停用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='科普分类表';3.2.2 文章表 article
CREATE TABLE `article` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '文章ID', `category_id` bigint NOT NULL COMMENT '分类ID', `title` varchar(200) NOT NULL COMMENT '文章标题', `summary` varchar(500) DEFAULT NULL COMMENT '摘要', `cover` varchar(500) DEFAULT NULL COMMENT '封面图URL', `content` longtext COMMENT '正文内容', `source` varchar(100) DEFAULT NULL COMMENT '内容来源', `author` varchar(64) DEFAULT NULL COMMENT '作者', `status` tinyint DEFAULT 0 COMMENT '状态:0草稿 1已发布 2已下架', `view_count` bigint DEFAULT 0 COMMENT '浏览量', `publish_time` datetime DEFAULT NULL COMMENT '发布时间', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category_status` (`category_id`, `status`), KEY `idx_publish_time` (`publish_time`), FULLTEXT KEY `ft_title_content` (`title`, `content`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='科普文章表';3.2.3 管理员表 admin_user
CREATE TABLE `admin_user` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '管理员ID', `username` varchar(64) NOT NULL COMMENT '登录名', `password` varchar(128) NOT NULL COMMENT '密码,BCrypt加密', `nickname` varchar(64) DEFAULT NULL COMMENT '昵称', `status` tinyint DEFAULT 1 COMMENT '状态:1启用 0禁用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='管理员表';从设计角度看,三张表就足够支撑一个最小可用的科普网站。如果后续要扩展标签、评论、友情链接,再单独加表就行。
4. 后端 SpringBoot3 工程结构与关键代码
后端工程建议按照常见的分层结构组织,避免所有代码堆在一个类里。
src/main/java/com/example/techpop ├── TechPopApplication.java ├── common │ ├── Result.java │ ├── ResultCode.java │ └── exception │ ├── BusinessException.java │ └── GlobalExceptionHandler.java ├── config │ ├── CorsConfig.java │ └── WebMvcConfig.java ├── controller │ ├── AdminArticleController.java │ ├── AdminAuthController.java │ ├── AdminCategoryController.java │ └── PortalArticleController.java ├── entity │ ├── Article.java │ ├── Category.java │ └── AdminUser.java ├── repository │ ├── ArticleRepository.java │ ├── CategoryRepository.java │ └── AdminUserRepository.java ├── service │ ├── ArticleService.java │ ├── CategoryService.java │ └── AdminUserService.java ├── dto │ ├── ArticleQuery.java │ ├── ArticleSaveRequest.java │ └── LoginRequest.java └── util └── JwtUtil.java4.1 Maven 依赖示例
以下是核心的 pom.xml 文件内容节选,启动类按 SpringBoot3 常规写法即可。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.3.4</version> <relativePath/> </parent> <properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> </dependencies>4.2 统一返回结果 Result 类
接口层不要直接返回 Map 或裸对象,最好定义统一的 Result 包装类,前端处理起来更省事。
package com.example.techpop.common; public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } // getter / setter 省略,实际代码需要补全 }4.3 application.yml 配置
配置文件中需要重点确认 URL、用户名、密码和 JPA 的 ddl-auto 策略。生产环境建议把 ddl-auto 设为 validate 或 none,避免自动改表结构。
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/tech_pop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true app: jwt: # 生产环境不要使用硬编码默认值,建议通过环境变量注入 secret: your-secret-key-change-in-production expire-hours: 244.4 JWT 登录与鉴权实现
登录流程不复杂:前端提交 username 和 password,后端用 BCrypt 校验密码,通过后生成 JWT 返回给前端;前端把 token 存起来,后续请求放到 Authorization header 中。
package com.example.techpop.util; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.util.Date; @Component public class JwtUtil { @Value("${app.jwt.secret}") private String secret; @Value("${app.jwt.expire-hours}") private Long expireHours; private SecretKey getSecretKey() { return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String generateToken(Long userId, String username) { Date now = new Date(); Date expireDate = new Date(now.getTime() + expireHours * 3600 * 1000); return Jwts.builder() .setSubject(username) .claim("userId", userId) .setIssuedAt(now) .setExpiration(expireDate) .signWith(getSecretKey()) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(getSecretKey()) .build() .parseClaimsJws(token) .getBody(); } }4.5 文章查询接口示例
前台列表接口带分页、分类筛选和关键字搜索。使用 JPA 时可以直接写 Specification 或命名方法查询;下面示例用 JPA 方法名方式做简单查询。
package com.example.techpop.repository; import com.example.techpop.entity.Article; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface ArticleRepository extends JpaRepository<Article, Long> { Page<Article> findByStatusAndCategoryIdAndTitleContaining( Integer status, Long categoryId, String keyword, Pageable pageable); Page<Article> findByStatusAndCategoryId(Integer status, Long categoryId, Pageable pageable); Page<Article> findByStatus(Integer status, Pageable pageable); }控制层写法如下:
package com.example.techpop.controller; import com.example.techpop.common.Result; import com.example.techpop.dto.ArticleQuery; import com.example.techpop.entity.Article; import com.example.techpop.service.PortalArticleService; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/portal/articles") public class PortalArticleController { private final PortalArticleService portalArticleService; public PortalArticleController(PortalArticleService portalArticleService) { this.portalArticleService = portalArticleService; } @GetMapping public Result<Page<Article>> list(ArticleQuery query) { return Result.success(portalArticleService.listPublished(query)); } @GetMapping("/{id}") public Result<Article> detail(@PathVariable Long id) { return portalArticleService.getPublishedDetail(id); } }ArticleQuery 里包含 page、size、categoryId、keyword,由 Spring MVC 自动绑定 GET 参数。
ArticleService里需要先查是否存在,再决定抛异常还是返回数据。例如前台查看详情时,如果文章不存在或状态不是已发布,直接返回业务错误。
5. 前端 Vue.js3 工程搭建
前端部分推荐直接用 Vite 创建 Vue3 工程,安装路由、状态管理和 UI 组件库。
npm create vite@latest techpop-web -- --template vue cd techpop-web npm install npm install vue-router@4 pinia axios element-plus5.1 前台页面目录
src ├── api │ ├── portal.js │ └── admin.js ├── router │ └── index.js ├── stores │ └── user.js ├── views │ ├── portal │ │ ├── HomeView.vue │ │ ├── CategoryView.vue │ │ └── ArticleDetailView.vue │ └── admin │ ├── AdminLoginView.vue │ ├── AdminLayout.vue │ ├── ArticleListView.vue │ ├── ArticleEditView.vue │ └── CategoryManageView.vue └── main.js前台和后台建议拆成两个布局。前台走访问者视角,后台走管理视角。比如路由可以分成/和/admin两套。
import { createRouter, createWebHistory } from 'vue-router'; const routes = [ { path: '/', component: () => import('../views/portal/HomeView.vue'), meta: { title: '首页' } }, { path: '/category/:id', component: () => import('../views/portal/CategoryView.vue'), meta: { title: '分类科普' } }, { path: '/article/:id', component: () => import('../views/portal/ArticleDetailView.vue'), meta: { title: '文章详情' } }, { path: '/admin/login', component: () => import('../views/admin/AdminLoginView.vue') }, { path: '/admin', component: () => import('../views/admin/AdminLayout.vue'), meta: { requiresAuth: true }, children: [ { path: '', redirect: '/admin/articles' }, { path: 'articles', component: () => import('../views/admin/ArticleListView.vue') }, { path: 'articles/edit/:id?', component: () => import('../views/admin/ArticleEditView.vue') }, { path: 'categories', component: () => import('../views/admin/CategoryManageView.vue') } ] } ]; const router = createRouter({ history: createWebHistory(), routes }); router.beforeEach((to, from, next) => { const token = localStorage.getItem('admin_token'); if (to.meta.requiresAuth && !token) { next('/admin/login'); } else { next(); } }); export default router;5.2 Axios 请求封装
请求封装要统一处理 token 注入和响应异常,特别是 401 跳转登录。
import axios from 'axios'; import { ElMessage } from 'element-plus'; import router from '../router'; const request = axios.create({ baseURL: 'http://localhost:8080/api', timeout: 15000 }); request.interceptors.request.use( (config) => { const token = localStorage.getItem('admin_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => Promise.reject(error) ); request.interceptors.response.use( (response) => { const res = response.data; if (res.code !== 200) { ElMessage.error(res.message || '请求失败'); return Promise.reject(new Error(res.message || '请求失败')); } return res.data; }, (error) => { if (error.response && error.response.status === 401) { localStorage.removeItem('admin_token'); router.push('/admin/login'); } ElMessage.error(error.response?.data?.message || '网络异常'); return Promise.reject(error); } ); export default request;5.3 前台文章列表页面
以一个简单的科普列表页为例,页面加载时调用后端接口,把返回值渲染成卡片列表。
<script setup> import { ref, onMounted } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { getArticleList } from '../../api/portal'; const route = useRoute(); const router = useRouter(); const articles = ref([]); const total = ref(0); const loading = ref(false); const page = ref(1); const size = ref(10); async function loadArticles() { loading.value = true; try { const categoryId = route.params.categoryId || ''; const data = await getArticleList({ page: page.value - 1, size: size.value, categoryId, keyword: route.query.keyword }); articles.value = data.content; total.value = data.totalElements; } finally { loading.value = false; } } function goDetail(id) { router.push(`/article/${id}`); } onMounted(loadArticles); </script> <template> <div> <h2>科普文章</h2> <div v-loading="loading"> <div v-for="article in articles" :key="article.id" class="article-card" @click="goDetail(article.id)" > <h3>{{ article.title }}</h3> <p>{{ article.summary }}</p> <span>{{ article.author }}</span> <span>{{ article.publishTime }}</span> </div> </div> </div> </template>前端开发时最常见的联调问题就是跨域。后端需要配置跨域访问,开发模式下也可以在前端 Vite 配置 proxy 把/api代理到后端端口。
5.4 后台文章管理列表
管理列表会多一些操作按钮,比如编辑、删除、上下架。建议用 Element Plus 的 Table 组件,配合分页组件,接口复用后端POST /api/admin/articles/query这类管理端分页查询接口。管理端查询和前端展示查询要分开,因为管理端要能看到草稿和下架的记录,而前台只展示已发布数据。
6. 本地启动与前后端联调
先说结论:整个项目启动流程可以归纳为四步。
- 创建数据库并执行建表 SQL。
- 启动后端 SpringBoot 服务。
- 启动前端 Vite 开发服务器。
- 浏览器访问前端地址,验证接口连通性。
6.1 创建数据库
mysql -uroot -p进入 MySQL 后执行:
CREATE DATABASE tech_pop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE tech_pop;然后执行本章第 3 节中的建表 SQL,再插入一条分类和文章测试数据。
INSERT INTO category (name, sort, status) VALUES ('能源动力', 1, 1); INSERT INTO article (category_id, title, summary, content, status, publish_time) VALUES ( 1, '什么是燃煤发电', '简要介绍燃煤发电的基本原理和能量转化过程', '燃煤发电的基本原理是将燃料中的化学能转化为热能,再利用热能产生高温高压蒸汽推动汽轮机转动,最终带动发电机产生电能。', 1, NOW() );6.2 启动后端服务
在 IDEA 里直接运行 TechPopApplication,或者使用 Maven 命令行:
mvn spring-boot:run启动成功后,终端能看到 Tomcat started on port 8080,说明后端服务已经可访问。此时访问http://localhost:8080/api/portal/articles,如果返回 JSON 数据,后端就正常。
6.3 启动前端开发服务
在 techpop-web 目录执行:
npm install npm run dev终端会输出 Vite 的访问地址,默认是http://localhost:5173。
6.4 联调验证清单
联调阶段建议按下面顺序验证,不要跳步骤:
- 访客直接访问首页,确认能加载文章列表。
- 点击分类,确认列表按分类过滤。
- 点击文章,确认正文详情能正常展示。
- 未登录时访问后台地址,应被路由守卫重定向到登录页。
- 用初始化好的管理员账号登录,确认能获取 token。
- 登录后进入文章管理页,能查看所有状态的文章。
- 新增一篇草稿文章,在前台应该看不到,发布后前台才能看到。
- 批量勾选文章下架,确认前台列表同步变化。
其中第 5 步前端页面需要把后端返回的 token 保存到 localStorage,具体字段名要前后端协商一致。下面给一个登录按钮的调用示例:
<script setup> import { reactive, ref } from 'vue'; import { useRouter } from 'vue-router'; import { doLogin } from '../../api/admin'; import { ElMessage } from 'element-plus'; const router = useRouter(); const loading = ref(false); const form = reactive({ username: '', password: '' }); async function handleLogin() { if (!form.username || !form.password) { ElMessage.warning('请输入用户名和密码'); return; } loading.value = true; try { const data = await doLogin(form); localStorage.setItem('admin_token', data.token); localStorage.setItem('admin_name', data.nickname || form.username); router.push('/admin/articles'); } finally { loading.value = false; } } </script>注意上面 axios 封装里已经做过响应拦截,返回结果是res.data,所以data.token直接拿的就是后端返回体里的 token 字段。
7. 核心接口设计与批量任务场景
科普网站虽然内容偏展示,但只要后续维护工作量上来,接口设计就必须考虑通用性和批处理能力。
7.1 统一接口返回结构
建议所有接口统一返回:
{ "code": 200, "message": "操作成功", "data": {} }前端只用判断 code 是否为 200,就能决定是否继续渲染逻辑。
7.2 核心接口清单
| 模块 | 方法 | 路径 | 说明 | 是否需要登录 |
|---|---|---|---|---|
| 前台 | GET | /api/portal/articles | 分页查询已发布文章 | 否 |
| 前台 | GET | /api/portal/articles/{id} | 查询已发布文章详情 | 否 |
| 前台 | GET | /api/portal/categories | 查询启用分类 | 否 |
| 认证 | POST | /api/admin/auth/login | 管理员登录 | 否 |
| 后台 | GET | /api/admin/articles | 分页查询全部文章 | 是 |
| 后台 | POST | /api/admin/articles | 新增文章 | 是 |
| 后台 | PUT | /api/admin/articles/{id} | 编辑文章 | 是 |
| 后台 | PUT | /api/admin/articles/{id}/status | 修改文章状态 | 是 |
| 后台 | DELETE | /api/admin/articles/{id} | 删除文章 | 是 |
| 后台 | POST | /api/admin/articles/batch-status | 批量上下架 | 是 |
批量接口建议设计成下面这样:
{ "ids": [1, 2, 3], "status": 2 }后端 Service 层用一个循环或一条 update 完成即可。如果处理上百条数据,建议使用 JPA 的findAllById查出实体列表,再批量设置状态后调用saveAll,这样可以减少多次数据库往返的隐患。
package com.example.techpop.service; import com.example.techpop.entity.Article; import com.example.techpop.repository.ArticleRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; @Service public class ArticleBatchService { private final ArticleRepository articleRepository; public ArticleBatchService(ArticleRepository articleRepository) { this.articleRepository = articleRepository; } @Transactional public void batchUpdateStatus(List<Long> ids, Integer status) { List<Article> articles = articleRepository.findAllById(ids); for (Article article : articles) { article.setStatus(status); if (status == 1) { article.setPublishTime(java.time.LocalDateTime.now()); } } articleRepository.saveAll(articles); } }7.3 管理端文章导入批量任务
如果需要把 Excel 或 CSV 里的科普文章批量导入到系统,应该单独设计一个导入任务,而不是通过后台表单逐条提交。比较稳妥的做法是:
- 前端上传 CSV 文件到后端。
- 后端解析文件,校验必填字段:标题、分类名称、摘要、正文。
- 按分类名称匹配已有分类,不存在则自动创建或忽略。
- 将合法数据批量保存,非法数据记录到失败列表。
- 返回成功导入数量和失败原因,前端下载失败记录。
这一套流程复杂度不高,但能极大提升后台内容维护效率。批量导入时的关键词提示:如果 CSV 中包含无分类的记录,建议跳过而不是直接报错整个任务失败,这样更符合生产环境实际需求。
7.4 API 调用示例
开发者调试时可以先用 curl 验证接口。以后台登录接口为例:
curl -X POST http://localhost:8080/api/admin/auth/login \ -H "Content-Type: application/json" \ -d '{ "username": "admin", "password": "123456" }'正常返回示例:
{ "code": 200, "message": "操作成功", "data": { "token": "eyJhbGciOiJIUzI1NiJ9.xxx.xxx", "nickname": "系统管理员" } }调用需要鉴权的后台接口时,把 token 放到请求头:
curl -X GET http://localhost:8080/api/admin/articles?page=0&size=10 \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.xxx.xxx"8. 批量任务与性能观察方法
科普网站的接口没有大模型推理那么重的资源消耗,但也不代表不用关注性能。前后端分离架构下的常见性能瓶颈主要集中在三个方面:慢查询、大字段传输、前端渲染数量过大。
8.1 后端常见瓶颈
- 文章列表接口如果使用
SELECT *并且把所有记录的 content 大字段返回,会非常浪费带宽。管理端列表应该只查 id、title、summary、status、create_time、publish_time,不查 content。等编辑时再按 id 查详情。 - 对于全文搜索,不要直接用
LIKE '%关键词%'去查 content 字段,数据量大时性能很差。可以先用标题模糊搜索,后续需要全文搜索再引入 Elasticsearch。 - 列表接口一定要做分页,前端传 page 和 size,后端限制最大 size 不要超过 50,防止有人一次性拉全表数据。
8.2 前端性能观察
- 文章列表图片不要直接加载原图,前端可以使用缩略图,后端返回 cover 字段时可以用同一个 URL,但上传时最好生成多尺寸图片。
- 首页推荐接口如果有轮播图,请不要一次性请求所有图片资源,使用懒加载。
- 如果后台文章列表上千条,不要在 Table 中一次性渲染全部数据,必须使用分页组件,每页 10 到 20 条比较合适。
8.3 如何观察服务占用的资源
后端启动后,可以在终端通过 JDK 自带命令查看进程状态:
jps -l如果要看后端进程的 CPU 和内存:
top -p <pid>如果是 Windows 环境,可以直接打开任务管理器查看 Java 进程占用,前端node进程对应 Vite 开发服务器。这个项目在开发模式下后端占用内存通常在 300MB 到 800MB 之间,差异取决于 IDE、JVM 参数和依赖数量;前端开发服务器占用相对较小。
8.4 启动参数的调优建议
如果本机内存有限,启动后端时可以限制 JVM 堆内存:
java -Xms256m -Xmx512m -jar techpop-web.jar多环境配置可以拆成application-dev.yml、application-prod.yml,通过启动参数指定环境:
java -jar techpop-web.jar --spring.profiles.active=dev这样的好处是数据库连接、JWT 密钥、日志级别可以分开管理,不至于把生产信息暴露到开发配置中。
9. 常见问题与排查方法
下面整理一套从开发到部署最常见的排错清单,按现象分类。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 前端页面可以打开,但接口全部报错 | 后端服务没启动或端口不对 | 检查 IDEA 或终端日志,访问后端端口测试 | 确认后端启动成功,检查 Vite proxy 配置 |
| 数据库连不上 | MySQL 没启动、用户名密码错误、数据库不存在 | 检查后端日志,用 Navicat 或命令行连接测试 | 启动 MySQL,核对 application.yml 配置 |
| Article 表字段变了,启动报错 | JPA ddl-auto update 与实体映射不一致 | 检查 JPA 日志输出的 SQL | 备份数据后重新建表,或手动执行 ALTER TABLE |
| 登录成功后访问后台接口返回 401 | token 未携带或已过期 | 打开浏览器开发者工具查看请求头 | 确认前端请求拦截器从 localStorage 读取 token |
| 前端能访问但接口出现 CORS 错误 | 跨域未配置 | 看浏览器 Console 报错 | 后端配置 CorsConfig,或在 Vite 配置 proxy |
| 中文内容乱码 | 表字符集不是 utf8mb4 | 使用 SHOW TABLE STATUS 查看 | 将库表和连接设置改为 utf8mb4 |
| 发布文章后前台看不到 | status 不是 1 | 查数据库状态字段 | 通过后台修改状态,确认 publish_time 非空 |
| 文章新增成功但 content 丢失 | 后端实体 longtext 映射问题 | 检查实体字段类型 | 在 JPA 实体字段上使用 @Lob 声明内容字段 |
9.1 端口冲突处理
SpringBoot 默认占用 8080。如果你本机有别的服务占用了 8080,启动时会报 Port already in use。最简单的处理是换端口,修改 application.yml:
server: port: 8081前端 Vite 默认是 5173,如果被占用,Vite 会自动换到 5174,所以大多数情况下不用手动处理。
9.2 启动报出错误排查思路
后端启动失败时优先关注异常堆栈最下面的 Caused by,不要只看最上面一大段。比如出现:
Caused by: java.sql.SQLSyntaxErrorException: Unknown database 'tech_pop'说明数据库没创建。这种情况直接进入 MySQL 执行建库语句即可。
9.3 前端 npm install 失败
如果 npm install 网络较慢或失败,可以尝试切换 npm 镜像源:
npm config set registry https://registry.npmmirror.com npm install如果某个依赖包安装后启动报错,优先删除 node_modules 和 package-lock.json 后重新安装:
rm -rf node_modules package-lock.json npm install这个方法解决大多数依赖版本漂移问题。当然,实际生产部署时,如果你使用的是企业内部私有 npm 仓库,则必须按你的企业源来配置。
9.4 数据库时间字段显示差 8 小时
MySQL 连接串里带上 serverTimezone 是其中一种解决手段,更稳妥的方法是 JVM 和 MySQL 都设置为 Asia/Shanghai 时区。另外CURRENT_TIMESTAMP默认值依赖数据库服务器时区,如果服务器设置了 UTC,写入的时间会差 8 小时。排查时先执行SELECT NOW()看数据库当前时间是否正常。
10. 最佳实践与上线建议
科普网站虽然功能不算复杂,但要跑得稳并且被别人认可,还是需要把工程细节做好。
10.1 项目结构规范
- 后端按 controller、service、repository、entity、dto 分层,避免业务逻辑写在 controller 里。
- 配置区分 dev 和 prod,不要把生产数据库密码写在代码仓库里。
- 前端 API 调用统一走封装后的 request,不要每个页面直接 new axios。
- 路由守卫统一校验登录状态,不要在页面里散落一堆 token 判断。
10.2 数据初始化
正式上线前要先准备一套基础数据脚本:
- 管理员账号初始化:首次启动时可以通过 CommandLineRunner 创建默认管理员。
- 科普分类初始化:能源动力、科技前沿等分类。
package com.example.techpop.config; import com.example.techpop.entity.AdminUser; import com.example.techpop.repository.AdminUserRepository; import org.springframework.boot.CommandLineRunner; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Component; @Component public class DataInitializer implements CommandLineRunner { private final AdminUserRepository adminUserRepository; public DataInitializer(AdminUserRepository adminUserRepository) { this.adminUserRepository = adminUserRepository; } @Override public void run(String... args) { if (adminUserRepository.count() == 0) { AdminUser admin = new AdminUser(); admin.setUsername("admin"); admin.setPassword(new BCryptPasswordEncoder().encode("admin123456")); admin.setNickname("系统管理员"); admin.setStatus(1); adminUserRepository.save(admin); } } }这段代码的意义在于:你 clone 项目后第一次启动就能直接登录,不需要手动往表里插密码。
10.3 日志与监控
后端打印日志要分级,不要用 System.out 输出业务信息。Spring Boot 自带 Logback,直接使用:
import org.slf4j.Logger; import org.slf4j.LoggerFactory;在业务方法里记录关键操作,比如管理员登录成功、新增文章、批量下架等。日志文件保留周期建议 7 到 30 天,避免磁盘被撑满。
10.4 内容审核与发布规范
科普类网站的公信力建立在内容准确性上。后台发布流程至少要加入“来源”字段,文章详情页可以展示来源和作者信息。如果未来接入 AI 辅助生成内容,必须在页面上做显著标识,并设置人工审核环节。
10.5 安全加固
- 密码不能明文存储,必须 BCrypt 加密。
- JWT 密钥长度不要少于 32 个字节,建议通过环境变量注入。
- 管理端接口除登录外都要校验 token。
- 对外公开接口最好加 IP 频率限制,避免爬虫刷接口。
- 文件上传场景要限制文件类型和大小,上传目录不能和前端静态目录混在一起。
- 后端接口返回值不要直接打印所有异常堆栈给前端,统一由全局异常处理器转换为友好提示。
11. 后续扩展方向
在现有三张表的基础上,这个科普网站还可以继续扩展成更完整的科普内容平台:
- 增加文章标签表,形成多对多关系,文章详情页展示标签,列表页支持按标签筛选。
- 增加用户收藏和阅读历史功能,提升访问者粘性。
- 增加评论模块,让读者可以围绕科普内容互动。此时需要重点考虑防灌水和敏感词过滤。
- 引入搜索服务,当文章量达到几千篇以上时,可以用 Elasticsearch 替换数据库 like 查询。
- 把资源上传、访问统计这些公共能力抽成独立服务,方便后续多个站点复用。
从开发角度讲,用 JAVA + SpringBoot3 + Vue.js3 + MySQL 做科普网站,核心优势就是框架成熟、资料多、招聘需求大,遇到问题能很快找到解决方案。把上面的工程结构和接口设计跑通后,换一个内容主题,比如“城市文化科普”“健康知识科普”,只是多了一张分类表和不同页面文案,整体骨架基本可以复用。
如果你准备开始动手,建议按这个顺序推进:先按文中建表 SQL 把数据库建好,把管理员表、分类表、文章表的数据初始化掉;再启动后端,用 curl 或者 Apifox 验证文章查询接口;然后搭一个最简单的 Vue3 页面,只展示文章标题列表;等接口联调通了以后,再慢慢补后台管理页面和发布功能。先跑通最小闭环,后面扩展任何功能都会轻松得多。
如果觉得这篇文章对排查思路和接口设计有帮助,建议收藏备用。项目做完之后,把数据库设计文档、接口文档和效果截图整理好,这一套内容是完全可以写进项目经验里的。