云计算百科
云计算领域专业知识百科平台

知光项目用户资料模块

前言:

该文档只作为本人学习过程的记录,若还需要更详细的项目文档可以点击下方链接进行购买

文档地址

同时该项目已经在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过滤器 的前面或者紧挨着。

  • 请求进来: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);
    }
    }

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 知光项目用户资料模块
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!