前言:
该文档只作为本人学习过程的记录,若还需要更详细的项目文档可以点击下方链接进行购买
文档地址
同时该项目已经在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://xiaoce-zhiguang.oss-cn-shenzhen.aliyuncs.com/default.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过滤器的前面或者紧挨着。
- 请求进来:
OPTIONS请求到达 Spring Security。 - 安检员(Security)拦住:
- 安检员:“站住!你访问的是 API 接口。”
- 安检员:“你的 Token 呢?”
- 关键点:浏览器的
OPTIONS请求通常是不带 Token的!
- 结果:安检员直接打回 ——401 Unauthorized(未授权)。
- 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); } }