前言:
该文档只作为本人学习过程的记录,若还需要更详细的项目文档可以点击下方链接进行购买
文档地址
同时该项目已经在git上面开源,可以在购买前去看一下该项目。
项目后端的git地址:知光git后端地址
项目前端的git地址: 知光git前端地址
由于本人还在开发学习当中如果要看本人的代码可以进入以下地址:
本人的gitee地址
1 用户资料模块的实现
1.1 准备用户资料模块数据传输需要的DTO,VO
该模块代码我考虑到跟用户相关,因此我放在用户模块下的包内
package com.xiaoce.zhiguang.user.domain.dto;
import jakarta.validation.constraints.PastOrPresent;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
/**
* ProfilePatchRequest
* <p>
* 用户资料模块的dto
* 作用是在 前端(App/网页) 和 后端 之间传递用户想要修改的个人资料数据。
*
* @author 小策
* @date 2026/1/20 16:36
*/
public record ProfilePatchRequest(
@Size(min = 1, max = 64, message = "昵称长度需在 1-64 之间") String nickname,
@Size(max = 512, message = "个人描述长度不能超过 512") String bio,
@Pattern(regexp = "(?i)MALE|FEMALE|OTHER|UNKNOWN", message = "性别取值为 MALE/FEMALE/OTHER/UNKNOWN") String gender,
@PastOrPresent(message = "生日不能晚于今天") LocalDate birthday,
@Pattern(regexp = "^[a-zA-Z0-9_]{4,32}$", message = "知光号仅支持字母、数字、下划线,长度 4-32") String zgId,
@Size(max = 128, message = "学校名称长度不能超过 128") String school,
String tagJson
) {
}
package com.xiaoce.zhiguang.user.domain.vo;
import com.xiaoce.zhiguang.user.domain.po.Users;
import java.time.LocalDate;
/**
* ProfileResponse
* <p>
* 用户资料响应对象 (VO)
* 作用:后端将数据库中的用户信息脱敏、整理后,返回给前端展示。
* 它是用户在“个人中心”或“他人主页”看到的资料视图。
*
* @author 小策
* @date 2026/1/20 16:37
*/
public record ProfileResponse(
/**
* 用户唯一标识 ID
*/
Long id,
/**
* 用户昵称
*/
String nickname,
/**
* 头像 URL 地址 (通常是 OSS 访问链接)
*/
String avatar,
/**
* 个人简介/签名
*/
String bio,
/**
* 知光号 (用户自定义的唯一标识 ID)
*/
String zgId,
/**
* 性别 (如:MALE, FEMALE, OTHER, UNKNOWN)
*/
String gender,
/**
* 出生日期
*/
LocalDate birthday,
/**
* 学校/教育背景
*/
String school,
/**
* 绑定手机号 (通常会做掩码处理,如 138****0000)
*/
String phone,
/**
* 绑定邮箱
*/
String email,
/**
* 用户标签 (JSON 格式字符串)
*/
String tagJson
) {
/**
* 静态工厂方法:将数据库 PO 对象安全地转换为展示用的 VO 对象
* @param user 数据库 Users 实体
* @return 转换后的 ProfileResponse
*/
public static ProfileResponse fromEntity(Users user) {
if (user == null) return null;
return new ProfileResponse(
user.getId(),
user.getNickname(),
// 如果头像为空,这里可以处理默认值
user.getAvatar() == null ?"https://www.wsisp.com/helps/wp-content/uploads/2026/01/20260123084832-697335e02d09f.jpg" : user.getAvatar(),
user.getBio(),
user.getZgId(),
user.getGender(),
user.getBirthday(),
user.getSchool(),
// 在此处进行手机号脱敏处理
maskPhone(user.getPhone()),
maskEmail(user.getEmail()),
user.getTagsJson()
);
}
private static String maskPhone(String phone) {
if (phone == null || phone.length() < 11) return phone;
return phone.replaceAll("(\\\\d{3})\\\\d{4}(\\\\d{4})", "$1****$2");
}
private static String maskEmail(String email) {
if (email == null || !email.contains("@")) return email;
return email.replaceAll("(\\\\w).*(?=@)", "$1****");
}
}
1.2 配置跨域CorsConfig
那这是时候又有问题了,之前在配置模块中已经有了SecurityConfig里面有个corsConfigurationSource这个Bean,为什么这里又要有个CorsConfig呢?能不能直接在这个corsConfigurationSource里面做修改,不写配置类呢?
- 第一个问题回答:
主要原因是因为使用了PatchMapping请求,这是一个复杂请求会触发 CORS 预检;会先发送 OPTIONS 预检请求。如果后端未正确配置 CORS 或未放行 OPTIONS,请求就会被浏览器拦截,接口就无法请求成功。而之前的corsConfigurationSource并未做放行操作。
- 第二个问题回答
如果只用 SecurityConfig (标准配置):
Spring Security 的过滤器链条是严格按顺序执行的。默认情况下,它的 鉴权过滤器 (AuthorizationFilter) 往往排在 CORS过滤器 的前面或者紧挨着。
而定义的 CorsConfig 用了 Ordered.HIGHEST_PRECEDENCE,这相当于把 CORS 处理提到了安检门之外。
请求进来:OPTIONS 请求到达服务器。
接待员(你的 CorsConfig):
- 它排在最最前面,甚至在 Spring Security 安检门外面。
- 接待员:“哦,你是来问路的(OPTIONS)?行,我给你盖个章(Access-Control-Allow-Origin),你进去吧。”
- 直接放行:它直接返回 200 OK,或者把请求放过去。
安检员(Security):
- 看到请求已经处理完了(或者看到 OPTIONS 请求已经有了合法的 CORS 头)。
- Spring Security 内部机制发现如果是 已经处理过的 CORS 预检请求,往往就会直接放过,不再查 Token。
package com.xiaoce.zhiguang.user.config;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.List;
/**
* CorsConfig
* <p>
* 全局跨域配置类
* 采用 Filter 方案,在请求进入 Servlet 之前处理 CORS,优先级高于 Spring Security
* @author 小策
* @date 2026/1/20 18:05
*/
@Configuration
public class CorsConfig {
@Bean
public FilterRegistrationBean<CorsFilter> corsFilterRegistration() {
// 1. 创建 CORS 配置对象,用于定义具体的跨域规则
CorsConfiguration config = new CorsConfiguration();
// 允许的来源:使用 AllowedOriginPatterns 支持通配符,允许所有站点跨域请求
// 注意:生产环境下建议设置为具体的域名,如 "https://www.zhiguang.com"
config.setAllowedOriginPatterns(List.of("*"));
// 允许的 HTTP 方法:必须包含 OPTIONS 用于处理预检请求,PATCH 用于局部更新
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
// 允许的请求头:允许前端在 Header 中携带 Authorization (存放 JWT) 和 Content-Type 等信息
config.setAllowedHeaders(List.of("*"));
// 是否允许携带凭证:若前端需要跨域发送 Cookie,此项必须为 true
// 由于本项目目前将 Refresh Token 存在 localStorage,故设为 false 即可
config.setAllowCredentials(false);
// 预检请求的缓存时间:在此时间内(1小时),浏览器不再针对相同接口发送 OPTIONS 预检,提升性能
config.setMaxAge(3600L);
// 2. 配置配置源:将上述规则应用到具体的 URL 路径上
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// 针对 Profile 模块及 Auth 模块等所有 API 接口开启跨域
source.registerCorsConfiguration("/api/v1/**", config);
// 3. 核心:创建 Filter 注册对象,并设置最高优先级
FilterRegistrationBean<CorsFilter> bean = new FilterRegistrationBean<>(new CorsFilter(source));
// 设置执行顺序为最高优先级(Ordered.HIGHEST_PRECEDENCE)
// 确保跨域处理发生在 Spring Security 权限拦截之前,彻底解决预检请求被拦截的问题
bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
return bean;
}
}
1.3 用户资料模块的接口文档
接口详情
1.1 局部更新个人资料
采用 PATCH 语义,仅提交需要修改的字段。未提交的字段在数据库中保持不变。
- URL: /
- Method: PATCH
- 权限: 需要认证 (需在 Header 携带 Authorization: Bearer <token>)
- 请求头: Content-Type: application/json
请求参数 (Body):
- nickname (String, 选填): 用户昵称,长度 1-64。
- bio (String, 选填): 个人描述,长度不超过 512。
- gender (String, 选填): 性别,取值为 MALE, FEMALE, OTHER, UNKNOWN(忽略大小写)。
- birthday (LocalDate, 选填): 生日,格式 yyyy-MM-dd,不能晚于今天。
- zgId (String, 选填): 知光号,仅支持字母、数字、下划线,长度 4-32。
- school (String, 选填): 学校名称,长度不超过 128。
- tagJson (String, 选填): 用户标签的 JSON 格式字符串。
响应示例 (200 OK):
JSON
{
"id": 10086,
"nickname": "小策",
"avatar": "https://oss…/avatar.jpg",
"bio": "写代码的",
"zgId": "xiaoce_001",
"gender": "MALE",
"birthday": "2000-01-01",
"school": "赤峰学院",
"phone": "138****8888",
"email": "x****@example.com",
"tagJson": "[\\"Java\\", \\"后端\\"]"
}
1.2 更新用户头像
上传图片文件,后端会将其存入 OSS 并自动同步更新用户表中的头像地址。
- URL: /avatar
- Method: POST
- 权限: 需要认证
- 请求头: Content-Type: multipart/form-data
请求参数 (Form-Data):
- file (File, 必填): 头像图片文件流(通常限制为 jpg/png 格式)。
响应示例 (200 OK):
JSON
{
"id": 10086,
"nickname": "小策",
"avatar": "https://oss-cn-shenzhen…/new_avatar_uuid.png",
"bio": "写代码的",
…
}
1.4 用户资料模块controller层实现
package com.xiaoce.zhiguang.user.controller;
import com.xiaoce.zhiguang.Oss.service.Impl.OssStorageServiceImpl;
import com.xiaoce.zhiguang.auth.service.impl.JwtServiceImpl;
import com.xiaoce.zhiguang.user.domain.dto.ProfilePatchRequest;
import com.xiaoce.zhiguang.user.domain.vo.ProfileResponse;
import com.xiaoce.zhiguang.user.service.IUserProfileService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/**
* ProfileController
* <p>
* 用户简介相关业务的controller
*
* @author 小策
* @date 2026/1/20 16:44
*/
@RestController
@Tag(name = "用户简介模块")
@RequestMapping("/api/v1/profile")
@Validated
@RequiredArgsConstructor
public class ProfileController {
private final IUserProfileService profileService;
private final JwtServiceImpl jwtService;
private final OssStorageServiceImpl ossStorageService;
/**
* ATCH (局部修改)
* 操作目标: 资源的部分字段
* 提交要求: 只需提交想修改的字段
* 幂等性: 通常不幂等(取决于具体实现)
* 使用场景: 只需要发一个头像 URL 或文件即可
*/
@PatchMapping
@Operation(
summary = "局部更新个人资料",
description = "采用 PATCH 语义,仅提交需要修改的字段(如昵称、简介等)。未提交的字段在数据库中保持不变。"
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "更新成功",
content = @Content(schema = @Schema(implementation = ProfileResponse.class))),
@ApiResponse(responseCode = "400", description = "参数校验失败或业务逻辑错误"),
@ApiResponse(responseCode = "401", description = "未登录或 Token 无效")
})
public ProfileResponse patch(@AuthenticationPrincipal Jwt jwt,
@Valid @RequestBody ProfilePatchRequest request) {
long userId = jwtService.extractUserId(jwt);
return profileService.updateProfile(userId, request);
}
@PostMapping(value = "/avatar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "更新用户头像",
description = "上传图片文件,后端会将其存入 OSS 并同步更新用户表中的头像地址。注意:请求头需设为 multipart/form-data。"
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "更新成功",
content = @Content(schema = @Schema(implementation = ProfileResponse.class))),
@ApiResponse(responseCode = "400", description = "文件读取失败或格式不正确")
})
public ProfileResponse updateAvatar(
@AuthenticationPrincipal Jwt jwt,
@Parameter(description = "头像文件流", required = true) @RequestPart("file") MultipartFile file
) {
long userId = jwtService.extractUserId(jwt);
// 1. 调用 OSS 基础模块上传文件并获取 URL
String url = ossStorageService.updateAvatar(userId, file);
// 2. 调用用户业务模块更新数据库
return profileService.updateAvatar(userId, url);
}
}
1.5 用户资料模块IUserProfileService接口实现
package com.xiaoce.zhiguang.user.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.xiaoce.zhiguang.user.domain.dto.ProfilePatchRequest;
import com.xiaoce.zhiguang.user.domain.po.Users;
import com.xiaoce.zhiguang.user.domain.vo.ProfileResponse;
import jakarta.validation.Valid;
/**
* IUserProfileService
* <p>
* 用户简介相关业务接口
*
* @author 小策
* @date 2026/1/20 16:46
*/
public interface IUserProfileService extends IService<Users> {
ProfileResponse updateProfile(long userId, @Valid ProfilePatchRequest request);
ProfileResponse updateAvatar(long userId, String url);
}
1.6 用户资料模块UserProfileServiceImpl实现
1.6.1 实现逻辑
1.6.1.1 更新用户个人资料实现逻辑
暂时无法在飞书文档外展示此内容
1.6.1.2 更新用户头像实现逻辑
暂时无法在飞书文档外展示此内容
1.6.2 实现代码
package com.xiaoce.zhiguang.user.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xiaoce.zhiguang.common.exception.BusinessException;
import com.xiaoce.zhiguang.common.exception.ErrorCode;
import com.xiaoce.zhiguang.user.domain.dto.ProfilePatchRequest;
import com.xiaoce.zhiguang.user.domain.po.Users;
import com.xiaoce.zhiguang.user.domain.vo.ProfileResponse;
import com.xiaoce.zhiguang.user.mapper.UsersMapper;
import com.xiaoce.zhiguang.user.service.IUserProfileService;
import com.xiaoce.zhiguang.user.service.IUserService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
/**
* UserProfileServiceImpl
* <p>
* 用户简介相关业务实现类
*
* @author 小策
* @date 2026/1/20 16:47
*/
@Service
@RequiredArgsConstructor
public class UserProfileServiceImpl extends ServiceImpl<UsersMapper, Users> implements IUserProfileService{
private final IUserService userService;
/**
* 更新用户个人资料
* @param userId 用户ID
* @param request 包含需要更新的个人资料字段
* @return ProfileResponse 更新后的用户个人资料响应对象
* @throws BusinessException 当用户不存在或未提供任何更新字段时抛出
*/
@Override
@Transactional
public ProfileResponse updateProfile(long userId, ProfilePatchRequest request) {
// 根据用户ID查询用户信息,如果不存在则抛出异常
Users user = lambdaQuery().eq(Users::getId, userId).oneOpt().orElseThrow(
() -> new BusinessException(ErrorCode.IDENTIFIER_NOT_FOUND, "用户不存在"));
// 至少要提交一个字段,否则属于无效请求
boolean hasAnyField = request.nickname() != null || request.bio() != null || request.gender() != null
|| request.birthday() != null || request.zgId() != null || request.school() != null
|| request.tagJson() != null;
if (!hasAnyField) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "未提交任何更新字段");
}
String zgId = user.getZgId();
if (zgId != null && !zgId.isBlank()) {
userService.existsByZgIdExceptId(zgId, userId);
}
// 2. 执行动态 SQL 更新
boolean success = lambdaUpdate()
.eq(Users::getId, userId) // 定位用户
// 只有当 request 中的字段不为 null 时,才执行 set 更新
.set(request.nickname() != null, Users::getNickname, request.nickname())
.set(request.bio() != null, Users::getBio, request.bio())
.set(request.gender() != null, Users::getGender, request.gender())
.set(request.birthday() != null, Users::getBirthday, request.birthday())
.set(request.zgId() != null, Users::getZgId, request.zgId())
.set(request.school() != null, Users::getSchool, request.school())
.set(request.tagJson() != null, Users::getTagsJson, request.tagJson())
.set(Users::getUpdatedAt, LocalDateTime.now()) // 记录更新时间
.update();
if (!success) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "个人资料更新失败");
}
Users userUpdate = lambdaQuery().eq(Users::getId, userId).oneOpt().orElseThrow(
() -> new BusinessException(ErrorCode.IDENTIFIER_NOT_FOUND, "用户不存在"));
return ProfileResponse.fromEntity(userUpdate);
}
/**
* 更新用户头像信息
* 该方法使用事务注解,确保操作的原子性
*
* @param userId 用户ID
* @param url 新的头像URL地址
* @return ProfileResponse 包含更新后用户信息的响应对象
* @throws BusinessException 当用户不存在、头像链接为空、格式不合法或更新失败时抛出
*/
@Override
@Transactional
public ProfileResponse updateAvatar(long userId, String url) {
// 根据用户ID查询用户信息,如果用户不存在则抛出异常
Users user = lambdaQuery().eq(Users::getId, userId).oneOpt().orElseThrow(() ->
new BusinessException(ErrorCode.IDENTIFIER_NOT_FOUND, "用户不存在"));
// 检查头像链接是否为空
if (!StringUtils.hasText(url)) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "头像链接不能为空");
}
// 简单的正则:必须以 http(s) 开头,通常以常见图片后缀结束
String regex = "^(https?|ftp)://[^\\\\s/$.?#].[^\\\\s]*$";
if (!url.matches(regex)) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "头像链接格式不合法");
}
boolean success = lambdaUpdate().eq(Users::getId, userId).set(Users::getAvatar, url).update();
if (!success) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "头像更新失败");
}
Users userUpdate = this.getBaseMapper().selectById(user.getId());
return ProfileResponse.fromEntity(userUpdate);
}
}
网硕互联帮助中心



评论前必须登录!
注册