Building a Blog Platform with Spring MVC, Spring, and MyBatis
A web-based blog system is implemented using the SSM stack (Spring MVC, Spring, MyBatis). This platform consists of five core pages: user authentication, blog creation, blog editing, article listing, and post details. The backend is designed to fulfill the following functional requirements:
- Authentication: Validate user credentials (username and password).
- User Profile: Retrieve and display user information based on the user ID.
- Article List: Fetch and present a list of blog posts.
- Author Profile: Display author details by resolving the author ID from a blog post.
- Post Details: Show comprehensive content of a specific blog article using its unique ID.
- Edit Function: Load existing post data for modification and persist updates.
- Delete Operation: Remove a blog post through a logical delete operation.
- Create Post: Insert a new blog entry with user-provided data.
Service Endpoint Architecture
User Services:
- Validate credentials.
- Retrieve user profile data.
Blog Services:
- Fetch paginated or full article lists.
- Resolve author information via blog ID (process: Blog ID -> Author ID -> Author Details).
- Get detailed content for a single post.
- Update post information.
- Delete a post (logical deletion).
- Create a new post.
Core Data Models
- User Entity
- Blog Entity
- Operation Result Entity
Database Schema Design
1. Table Structures
A database java_blog_spring is created with two primary tables.
User Table (user)
id(INT, Primary Key, Auto Increment)user_name(VARCHAR(128), Unique)password(VARCHAR(128))github_url(VARCHAR(128))delete_flag(TINYINT, default 0)create_time(DATETIME)update_time(DATETIME)
Blog Table (blog)
id(INT, Primary Key, Auto Increment)title(VARCHAR(200))content(TEXT)user_id(INT(11))delete_flag(TINYINT, default 0)create_time(DATETIME)update_time(DATETIME)
2. Initialization Script
CREATE DATABASE IF NOT EXISTS java_blog_spring CHARACTER SET utf8mb4;
CREATE TABLE IF NOT EXISTS java_blog_spring.user (
`id` INT NOT NULL AUTO_INCREMENT,
`user_name` VARCHAR(128) NOT NULL,
`password` VARCHAR(128) NOT NULL,
`github_url` VARCHAR(128) NULL,
`delete_flag` TINYINT(4) DEFAULT 0,
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE INDEX `user_name_UNIQUE` (`user_name` ASC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='User accounts';
CREATE TABLE IF NOT EXISTS java_blog_spring.blog (
`id` INT NOT NULL AUTO_INCREMENT,
`title` VARCHAR(200) NULL,
`content` TEXT NULL,
`user_id` INT(11) NULL,
`delete_flag` TINYINT(4) DEFAULT 0,
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Blog posts';
-- Sample Data
INSERT INTO java_blog_spring.user (user_name, password, github_url) VALUES
('zhangsan', '123456', 'https://github.com/sample1'),
('lisi', '123456', 'https://github.com/sample2');
INSERT INTO java_blog_spring.blog (title, content, user_id) VALUES
('First Post', 'Initial blog entry', 1),
('Second Post', 'Follow-up article', 2);
3. Project Setup and Configuration
Create a Spring Boot project with dependencies for Spring MVC and MyBatis. Configure application.yml:
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/java_blog_spring?characterEncoding=utf8&useSSL=false
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mapper-locations: classpath:mapper/**Mapper.xml
logging:
file:
name: application.log
Core Application Layers
The architecture follows a standard layered pattern: Controller (presentation), Service (business logic), and Mapper (data persistence).
1. Domain Models
Utilize Lombok annotations to reduce boilerplate code for getters, setters, and constructors.
User Model
@Data
public class UserAccount {
private Integer id;
private String userName;
private String password;
private String githubUrl;
private Integer deleteFlag;
private Date createTime;
private Date updateTime;
}
Blog Model
@Data
public class BlogPost {
private Integer id;
private String title;
private String content;
private Integer userId;
private boolean isOwner;
private Integer deleteFlag;
private Date createTime;
private Date updateTime;
public String getFormattedCreateTime() {
return DateUtil.format(createTime);
}
}
2. Shared Componnets
Standardized Response Wrapper Define an enumeration for operation status codes and a generic result container.
public enum OperationStatus {
SUCCESS, FAILURE, UNAUTHENTICATED;
}
@Data
public class ApiResponse<T> {
private OperationStatus status;
private String message;
private T payload;
public static <T> ApiResponse<T> ok(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.setStatus(OperationStatus.SUCCESS);
response.setMessage("");
response.setPayload(data);
return response;
}
public static <T> ApiResponse<T> error(String errorMessage) {
ApiResponse<T> response = new ApiResponse<>();
response.setStatus(OperationStatus.FAILURE);
response.setMessage(errorMessage);
response.setPayload(null);
return response;
}
}
Response Normalization Implement a global advice to wrap all controller responses.
@ControllerAdvice
public class ResponseNormalizer implements ResponseBodyAdvice<Object> {
@Autowired
private ObjectMapper jsonMapper;
@Override
public boolean supports(MethodParameter returnType, Class<?> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType,
MediaType mediaType, Class<?> converterType,
ServerHttpRequest req, ServerHttpResponse res) {
if (body instanceof ApiResponse) {
return body;
}
if (body instanceof String) {
try {
return jsonMapper.writeValueAsString(ApiResponse.ok(body));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
return ApiResponse.ok(body);
}
}
Exception Handler Centralize error handling across the application.
@Slf4j
@ResponseBody
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ApiResponse<?> handleGeneric(Exception ex) {
log.error("Unhandled exception", ex);
return ApiResponse.error("Internal server error");
}
@ExceptionHandler(NoResourceFoundException.class)
public ApiResponse<?> handleMissingResource(NoResourceFoundException ex) {
log.warn("Resource not found: {}", ex.getResourcePath());
return ApiResponse.error("Requested resource unavailable");
}
}
Implementing Blog Listing
Data Access Layer
@Mapper
public interface ArticleMapper {
@Select("SELECT id, title, content, user_id, delete_flag, create_time, update_time " +
"FROM blog WHERE delete_flag = 0")
List<BlogPost> fetchAllArticles();
}
Service Layer
@Service
@Slf4j
public class ArticleService {
@Autowired
private ArticleMapper articleMapper;
public List<BlogPost> retrieveAllPosts() {
return articleMapper.fetchAllArticles();
}
}
Controller Endpoint
@RestController
@RequestMapping("/api/articles")
@Slf4j
public class ArticleController {
@Autowired
private ArticleService articleService;
@GetMapping
public List<BlogPost> listArticles() {
log.info("Fetching article list");
return articleService.retrieveAllPosts();
}
}
Client-Side Rendering (JavaScript)
$.ajax({
method: "GET",
url: "/api/articles",
success: function(response) {
if (response.status === "SUCCESS") {
const articles = response.payload;
let htmlContent = '';
articles.forEach(post => {
htmlContent += `<div class="post">
<h3>${post.title}</h3>
<span class="timestamp">${post.formattedCreateTime}</span>
<p>${post.content.substring(0, 150)}...</p>
<a href="/post.html?id=${post.id}">Read full article</a>
</div>`;
});
$(".article-container").html(htmlContent);
}
},
error: function(xhr) {
if (xhr.status === 401) {
window.location.href = "/login.html";
}
}
});
Date Formatting Utility
Create a utility class to standardize date presentation.
public class DateUtil {
private static final SimpleDateFormat FORMATTER =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static String format(Date date) {
return FORMATTER.format(date);
}
}
Retrieving Individual Blog Posts
Data Access Method
@Select("SELECT id, title, content, user_id, delete_flag, create_time, update_time " +
"FROM blog WHERE delete_flag = 0 AND id = #{postId}")
BlogPost fetchById(Integer postId);
Service Method
public BlogPost getPostDetail(Integer postId) {
log.info("Retrieving details for post ID: {}", postId);
return articleMapper.fetchById(postId);
}
Controller Endpoint
@GetMapping("/{postId}")
public BlogPost getPost(@PathVariable Integer postId, HttpServletRequest req) {
log.info("Request for post details, ID: {}", postId);
BlogPost post = articleService.getPostDetail(postId);
// Check if current user is the author
String authToken = req.getHeader("Authorization");
Integer currentUserId = TokenUtil.extractUserId(authToken);
if (currentUserId != null && currentUserId.equals(post.getUserId())) {
post.setOwner(true);
}
return post;
}
Authentication with JWT Tokens
To overcome session limitations in distributed environments, implement JSON Web Token authentication.
Dependencies
<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>
Token Utility
@Slf4j
public class TokenUtil {
private static final long EXPIRATION_MS = 3600000; // 1 hour
private static final String SECRET_KEY = "YourSecretKeyHere";
private static final Key KEY = Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8));
public static String generateToken(Map<String, Object> claims) {
return Jwts.builder()
.setClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_MS))
.signWith(KEY)
.compact();
}
public static Claims parseToken(String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(KEY)
.build()
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
log.warn("Token validation failed: {}", token);
return null;
}
}
public static Integer extractUserId(String token) {
Claims claims = parseToken(token);
return claims != null ? (Integer) claims.get("userId") : null;
}
}
Authentication Controller
@RestController
@RequestMapping("/api/auth")
@Slf4j
public class AuthController {
@Autowired
private UserService userService;
@PostMapping("/login")
public ApiResponse<String> authenticate(@RequestParam String username,
@RequestParam String password) {
if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) {
return ApiResponse.error("Credentials required");
}
UserAccount user = userService.findByUsername(username);
if (user == null) {
return ApiResponse.error("User not found");
}
if (!SecurityUtil.verifyPassword(password, user.getPassword())) {
return ApiResponse.error("Invalid credentials");
}
Map<String, Object> tokenData = new HashMap<>();
tokenData.put("userId", user.getId());
tokenData.put("username", user.getUserName());
String token = TokenUtil.generateToken(tokenData);
return ApiResponse.ok(token);
}
}
Client-Side Token Storage
function performLogin() {
$.ajax({
method: "POST",
url: "/api/auth/login",
data: {
username: $("#username").val(),
password: $("#password").val()
},
success: function(response) {
if (response.status === "SUCCESS" && response.payload) {
localStorage.setItem("auth_token", response.payload);
window.location.href = "/dashboard.html";
} else {
alert(response.message);
}
}
});
}
Mandatory Authentication Interceptor
Create an interceptor to validate tokens on protected endpoints.
@Component
@Slf4j
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res,
Object handler) throws Exception {
String token = req.getHeader("Authorization");
log.debug("Token from header: {}", token);
if (TokenUtil.parseToken(token) == null) {
res.setStatus(HttpStatus.UNAUTHORIZED.value());
return false;
}
return true;
}
}
Register the interceptor, excluding public resources.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private AuthInterceptor authInterceptor;
private final List<String> EXCLUDED_PATHS = Arrays.asList(
"/static/**",
"/api/auth/**",
"/**.html"
);
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns(EXCLUDED_PATHS);
}
}
Client-Side Header Injection
Add token to all AJAX requests.
$(document).ajaxSend(function(event, xhr, options) {
const token = localStorage.getItem("auth_token");
if (token) {
xhr.setRequestHeader("Authorization", token);
}
});
Add error handling for unauthorized requests.
error: function(xhr) {
if (xhr.status === 401) {
window.location.href = "/login.html";
}
}
User Information Display
Endpoints for User Data
@GetMapping("/profile")
public UserAccount getCurrentUser(HttpServletRequest req) {
String token = req.getHeader("Authorization");
Integer userId = TokenUtil.extractUserId(token);
return userService.getUserById(userId);
}
@GetMapping("/author/{postId}")
public UserAccount getPostAuthor(@PathVariable Integer postId) {
BlogPost post = articleMapper.fetchById(postId);
if (post == null || post.getUserId() == null) {
return null;
}
return userService.getUserById(post.getUserId());
}
Service Implementation
public UserAccount getPostAuthor(Integer postId) {
BlogPost post = articleMapper.fetchById(postId);
if (post == null || post.getUserId() <= 0) {
log.error("Invalid post or author ID for post: {}", postId);
return null;
}
return userMapper.findUserById(post.getUserId());
}
Client-Side User Display
function loadUserProfile(apiEndpoint) {
$.ajax({
method: "GET",
url: apiEndpoint,
success: function(response) {
if (response.status === "SUCCESS" && response.payload) {
const user = response.payload;
$(".user-name").text(user.userName);
$(".user-github").attr("href", user.githubUrl);
}
},
error: function(xhr) {
if (xhr.status === 401) {
window.location.href = "/login.html";
}
}
});
}
User Logout
Simply remove the token from client storage.
function logoutUser() {
localStorage.removeItem("auth_token");
window.location.href = "/login.html";
}
Creating Blog Posts
Controller Endpoint
@PostMapping
public ApiResponse<Boolean> createPost(@RequestBody BlogPost postData,
HttpServletRequest req) {
if (StringUtils.isEmpty(postData.getTitle()) ||
StringUtils.isEmpty(postData.getContent())) {
return ApiResponse.error("Title and content required");
}
String token = req.getHeader("Authorization");
Integer authorId = TokenUtil.extractUserId(token);
if (authorId == null) {
return ApiResponse.error("Authentication required");
}
postData.setUserId(authorId);
boolean created = articleService.addPost(postData);
return ApiResponse.ok(created);
}
Data Access Method
@Insert("INSERT INTO blog (title, content, user_id) " +
"VALUES (#{title}, #{content}, #{userId})")
Integer insertPost(BlogPost post);
Service Method
public boolean addPost(BlogPost post) {
try {
int rows = articleMapper.insertPost(post);
return rows == 1;
} catch (Exception e) {
log.error("Failed to create post", e);
return false;
}
}
Markdown Editor Integration
Include the editor in the post creation form.
<link rel="stylesheet" href="/assets/editormd/css/editormd.min.css">
<div id="editor">
<textarea style="display:none;" id="content" name="content">
Start writing your post here...
</textarea>
</div>
<script src="/assets/editormd/editormd.min.js"></script>
<script>
$(function() {
editormd("editor", {
width: "100%",
height: "500px",
path: "/assets/editormd/lib/"
});
});
</script>
Client-Side Submission
function submitPost() {
$.ajax({
method: "POST",
url: "/api/articles",
contentType: "application/json",
data: JSON.stringify({
title: $("#title").val(),
content: $("#content").val()
}),
success: function(response) {
if (response.status === "SUCCESS" && response.payload) {
window.location.href = "/posts.html";
} else {
alert(response.message);
}
},
error: function(xhr) {
if (xhr.status === 401) {
window.location.href = "/login.html";
}
}
});
}
Rendering Markdown Content
// In post detail page
editormd.markdownToHTML("content-display", {
markdown: post.content
});
Post Editing and Deletion
Authorization Check
Modify the post detail endpoint to include ownership flag.
@GetMapping("/{postId}")
public BlogPost getPost(@PathVariable Integer postId, HttpServletRequest req) {
BlogPost post = articleService.getPostDetail(postId);
String token = req.getHeader("Authorization");
Integer currentUserId = TokenUtil.extractUserId(token);
post.setOwner(currentUserId != null && currentUserId.equals(post.getUserId()));
return post;
}
Client-Side Conditional UI
if (post.owner) {
const controls = `<div class="post-controls">
<button onclick="editPost(${post.id})">Edit</button>
<button onclick="deletePost(${post.id})">Delete</button>
</div>`;
$(".post-header").append(controls);
}
Update Endpoint
@PutMapping("/{postId}")
public ApiResponse<Boolean> updatePost(@PathVariable Integer postId,
@RequestBody BlogPost updates) {
if (postId == null || StringUtils.isEmpty(updates.getTitle()) ||
StringUtils.isEmpty(updates.getContent())) {
return ApiResponse.error("Invalid update data");
}
updates.setId(postId);
articleService.modifyPost(updates);
return ApiResponse.ok(true);
}
XML Mapper for Dynamic Updates
<mapper namespace="com.example.blog.mapper.ArticleMapper">
<update id="updatePost">
UPDATE blog
<set>
<if test="title != null">title = #{title},</if>
<if test="content != null">content = #{content},</if>
<if test="userId != null">user_id = #{userId},</if>
<if test="deleteFlag != null">delete_flag = #{deleteFlag},</if>
</set>
WHERE id = #{id}
</update>
</mapper>
Client-Side Update
function savePost() {
$.ajax({
method: "PUT",
url: `/api/articles/${$("#postId").val()}`,
data: {
id: $("#postId").val(),
title: $("#title").val(),
content: $("#content").val()
},
success: function(response) {
if (response.status === "SUCCESS" && response.payload) {
window.location.href = `/post.html?id=${$("#postId").val()}`;
} else {
alert(response.message);
}
},
error: function(xhr) {
if (xhr.status === 401) {
window.location.href = "/login.html";
}
}
});
}
Loading Existing Content for Editing
function loadPostForEditing() {
const postId = new URLSearchParams(window.location.search).get('id');
$.ajax({
method: "GET",
url: `/api/articles/${postId}`,
success: function(response) {
if (response.status === "SUCCESS" && response.payload) {
const post = response.payload;
$("#postId").val(post.id);
$("#title").val(post.title);
editormd("editor", {
width: "100%",
height: "500px",
path: "/assets/editormd/lib/",
onload: function() {
this.watch();
this.setMarkdown(post.content);
}
});
}
}
});
}
Logical Deletion
Controller Endpoint
@DeleteMapping("/{postId}")
public boolean deletePost(@PathVariable Integer postId) {
log.info("Soft deleting post: {}", postId);
if (postId <= 0) return false;
BlogPost updates = new BlogPost();
updates.setId(postId);
updates.setDeleteFlag(1);
articleService.modifyPost(updates);
return true;
}
Client-Side Deletion
function deletePost(postId) {
if (!confirm("Confirm deletion?")) return;
$.ajax({
method: "DELETE",
url: `/api/articles/${postId}`,
success: function(response) {
if (response.status === "SUCCESS" && response.payload) {
window.location.href = "/posts.html";
} else {
alert(response.message);
}
},
error: function(xhr) {
if (xhr.status === 401) {
window.location.href = "/login.html";
}
}
});
}
Password Security with MD5 Hashing
Security Utility
public class SecurityUtil {
public static String hashPassword(String plainPassword) {
String salt = UUID.randomUUID().toString().replace("-", "");
String hashed = DigestUtils.md5DigestAsHex((plainPassword + salt).getBytes());
return salt + hashed;
}
public static boolean verifyPassword(String input, String stored) {
if (StringUtils.isEmpty(input) || stored == null || stored.length() != 64) {
return false;
}
String salt = stored.substring(0, 32);
String hashedInput = DigestUtils.md5DigestAsHex((input + salt).getBytes());
return (salt + hashedInput).equals(stored);
}
}
Updated Authentication
@PostMapping("/login")
public ApiResponse<String> authenticate(@RequestParam String username,
@RequestParam String password) {
UserAccount user = userService.findByUsername(username);
if (user == null) {
return ApiResponse.error("User not found");
}
if (!SecurityUtil.verifyPassword(password, user.getPassword())) {
return ApiResponse.error("Invalid credentials");
}
Map<String, Object> tokenData = new HashMap<>();
tokenData.put("userId", user.getId());
tokenData.put("username", user.getUserName());
String token = TokenUtil.generateToken(tokenData);
return ApiResponse.ok(token);
}
Database Password Update
Update existing passwords to use hashed format.
UPDATE user SET password =
CONCAT(SUBSTRING(MD5(RAND()), 1, 32),
MD5(CONCAT('plain_password', SUBSTRING(MD5(RAND()), 1, 32))))
WHERE user_name IN ('zhangsan', 'lisi');