【Uni-App+SSM 宠物项目实战】Day11:个人信息修改

74 阅读13分钟

一、前言

欢迎回到mypet项目实战!🎉 今天我们聚焦 个人信息修改 模块 —— 这是用户完善个人资料、提升使用体验的核心功能。用户登录后(基于 Day10 的 Token 登录态),可修改头像、昵称、手机号等信息,其中头像上传是本次重点(涉及跨端文件上传、后端文件存储、URL 回显全流程)。

本次实现的核心逻辑:

前端通过uni-popup弹窗展示修改表单,用uni.chooseImage选图 + uni.uploadFile上传至后端FileController,获取图片 URL 后,调用UserController的更新接口,通过 MyBatis-Plus 的updateById()更新数据库,最终实时刷新页面显示新信息。即使是新手,也能通过 step-by-step 代码掌握文件上传与信息更新的联动。

📌 学习目标

  1. 掌握 MP 的updateById()方法,实现用户信息精准更新;
  1. 熟练使用uni.chooseImage+uni.uploadFile完成跨端图片上传;
  1. 理解FileController的文件接收、存储、URL 返回逻辑;
  1. 解决 “信息更新后页面实时刷新”“上传时 Token 验证” 等实战问题。

二、前置准备

开始编码前,请确认以下内容已就绪,避免后续开发受阻:

项目检查内容注意事项
登录态基础Day10 的登录功能正常,uni.getStorageSync('token')能获取有效 Token若 Token 无效,需先重新登录,确保拦截器能通过 Token 解析出用户 ID
数据库字段yonghu表需包含待修改字段:zhaopian(头像 URL,varchar)、nicheng(昵称,varchar)、shoujihao(手机号,varchar)若字段缺失,需执行 ALTER TABLE 语句添加,例如:ALTER TABLE yonghu ADD COLUMN zhaopian VARCHAR(255);
后端依赖pom.xml需包含文件处理依赖(commons-io):xmlcommons-iocommons-io2.6用于FileController处理文件流,避免 ClassNotFound 错误
前端组件已导入 Uni-App 的uni-popup弹窗组件、uni-forms表单组件在pages.json中配置组件:"usingComponents": {"uni-popup": "@dcloudio/uni-ui/lib/uni-popup/uni-popup"}
文件配置后端config.properties配置文件存储路径:upload.path=D:/mypet/upload/确保路径对应的文件夹已创建(如 D:/mypet/upload),否则文件无法保存

三、个人信息修改流程图

先通过流程图理清 “选图→上传→更新→回显” 的完整逻辑:

四、代码实现

4.1 后端:核心接口开发(3 个关键部分)

4.1.1 1. FileController:文件上传接口(接收图片并返回 URL)

路径:src/main/java/com/controller/FileController.java

核心功能:接收前端上传的图片文件,保存到服务器指定路径,生成可访问的 URL。

import com.utils.FileUtil;
import com.utils.R;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletRequest;
import java.io.File;
import java.util.UUID;
@RestController
@RequestMapping("/file")
public class FileController {
    // 从配置文件读取上传路径(避免硬编码)
    @Value("${upload.path}")
    private String uploadPath;
    // 图片上传接口(需登录,拦截器验证Token)
    @PostMapping("/upload")
    public R upload(MultipartFile file, HttpServletRequest request) {
        try {
            // 1校验文件是否为空
            if (file.isEmpty()) {
                return R.error("请选择要上传的图片!");
            }
            // 校验文件格式(只允许jpg/png/jpeg,避免恶意文件)
            String originalFilename = file.getOriginalFilename();
            String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
            if (!suffix.matches(".(jpg|png|jpeg)$")) {
                return R.error("仅支持jpg、png、jpeg格式的图片!");
            }
            // 生成唯一文件名(避免同名文件覆盖)
            String fileName = UUID.randomUUID().toString() + suffix; // 例如:123e4567-e89b-12d3-a456-426614174000.jpg
            // 构建文件保存路径(服务器路径 + 唯一文件名)
            File saveFile = new File(uploadPath + fileName);
            // 若父文件夹不存在,自动创建
            if (!saveFile.getParentFile().exists()) {
                saveFile.getParentFile().mkdirs();
            }
            // 保存文件到服务器(调用FileUtil工具类)
            FileUtil.copyInputStreamToFile(file.getInputStream(), saveFile);
            // 生成图片访问URL(前端可直接通过该URL显示图片)
            // 格式:http://服务器IP:端口/mypet/upload/唯一文件名
            String fileUrl = request.getScheme() + "://" + request.getServerName() 
                    + ":" + request.getServerPort() + "/mypet/upload/" + fileName;
            // 返回URL给前端(键为"file",与前端解析对应)
            return R.ok("上传成功").put("file", fileUrl);
        } catch (Exception e) {
            e.printStackTrace();
            return R.error("上传失败:" + e.getMessage());
        }
    }
}

4.1.2 2. FileUtil:文件工具类(辅助保存文件)

路径:src/main/java/com/utils/FileUtil.java

核心功能:封装文件流复制逻辑,简化FileController代码。

import org.apache.commons.io.IOUtils;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
public class FileUtil {
    /**
     * 复制输入流到文件
     * @param inputStream 上传文件的输入流
     * @param file 保存的目标文件
     */
    public static void copyInputStreamToFile(InputStream inputStream, File file) {
        try (FileOutputStream outputStream = new FileOutputStream(file)) {
            // 用commons-io的IOUtils复制流,自动处理缓冲区
            IOUtils.copy(inputStream, outputStream);
        } catch (Exception e) {
            throw new RuntimeException("文件保存失败:" + e.getMessage());
        }
    }
}

4.1.3 3. UserController/UserService:用户信息更新接口

路径 1(Controller):src/main/java/com/controller/UserController.java

路径 2(ServiceImpl):src/main/java/com/service/impl/YonghuServiceImpl.java

第一步:Service 层实现 updateById(MP 原生方法)

import com.entity.YonghuEntity;
import com.mapper.YonghuMapper;
import com.service.YonghuService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
@Service
public class YonghuServiceImpl extends ServiceImpl<YonghuMapper, YonghuEntity> implements YonghuService {
    // 直接继承MP的ServiceImpl,无需重写updateById(父类已实现)
    // 若需自定义更新逻辑(如只更新非空字段),可重写:
    @Override
    public boolean updateById(YonghuEntity yonghu) {
        // 仅更新非空字段(避免覆盖原有未修改的信息)
        return baseMapper.updateById(yonghu) > 0;
    }
}

第二步:Controller 层添加 update 接口(验证 Token + 更新信息)

import com.entity.YonghuEntity;
import com.service.YonghuService;
import com.utils.R;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
@RestController
@RequestMapping("/yonghu")
public class UserController {
    @Autowired
    private YonghuService yonghuService;
    // 从Token解析密钥(与Day10一致,建议配置在application.properties)
    private static final String JWT_SECRET = "mypet-secret-2024";
    /**
     * 用户信息更新接口(需登录,Token验证)
     * @param yonghu 前端传递的新信息(含用户ID,避免越权)
     * @param request 用于获取Token
     */
    @PostMapping("/update")
    public R update(@RequestBody YonghuEntity yonghu, HttpServletRequest request) {
        // 从请求头获取Token并解析(验证当前登录用户)
        String token = request.getHeader("token");
        Claims claims = Jwts.parser().setSigningKey(JWT_SECRET).parseClaimsJws(token).getBody();
        String loginUserId = claims.getSubject(); // 解析出当前登录用户ID
        // 校验权限(只能修改自己的信息,避免越权)
        if (!loginUserId.equals(yonghu.getId().toString())) {
            return R.error("无权限修改他人信息!");
        }
        // 调用Service更新信息(MP的updateById)
        boolean updateSuccess = yonghuService.updateById(yonghu);
        if (updateSuccess) {
            return R.ok("个人信息修改成功!");
        } else {
            return R.error("修改失败,请重试!");
        }
    }
}

4.2 前端:完整表单 + 弹窗 + 上传逻辑

路径:pages/user/edit.vue

核心功能:展示修改弹窗、加载当前用户信息、图片上传、表单提交、页面刷新。

<template>
  <view class="user-edit-page">
    <!-- 1. 触发修改的按钮 -->
    <button class="edit-btn" @click="openPopup">修改个人信息</button>
    <!-- 2. 弹窗表单(uni-popup) -->
    <uni-popup ref="editPopup" type="center" :mask="true">
      <view class="popup-content">
        <view class="popup-title">修改个人信息</view>
        <!-- 头像预览+上传 -->
        <view class="avatar-container">
          <!-- 预览当前/新头像 -->
          <image 
            class="avatar" 
            :src="form.zhaopian || '/static/default-avatar.png'" 
            mode="widthFix"
          ></image>
          <button class="upload-btn" @click="chooseAndUploadAvatar">上传新头像</button>
        </view>
        <!-- 其他信息表单(昵称、手机号) -->
        <uni-forms ref="editForm" :model="form" labelWidth="120rpx">
          <uni-forms-item label="昵称" required>
            <input 
              v-model="form.nicheng" 
              placeholder="请输入昵称" 
              maxlength="10"
            />
          </uni-forms-item>
          <uni-forms-item label="手机号" required>
            <input 
              v-model="form.shoujihao" 
              placeholder="请输入手机号" 
              type="number"
              maxlength="11"
            />
          </uni-forms-item>
        </uni-forms>
        <!-- 提交/取消按钮 -->
        <view class="btn-group">
          <button class="cancel-btn" @click="closePopup">取消</button>
          <button class="submit-btn" type="primary" @click="submitEdit">提交修改</button>
        </view>
      </view>
    </uni-popup>
  </view>
</template>
<script>
// 导入请求工具、uni-popup组件
import request from '@/api/request.js';
import uniPopup from '@dcloudio/uni-ui/lib/uni-popup/uni-popup';
import uniForms from '@dcloudio/uni-ui/lib/uni-forms/uni-forms';
import uniFormsItem from '@dcloudio/uni-ui/lib/uni-forms-item/uni-forms-item';
export default {
  components: { // 注册组件
    uniPopup,
    uniForms,
    uniFormsItem
  },
  data() {
    return {
      form: { // 表单数据(含用户ID,用于后端权限校验)
        id: '',       // 当前登录用户ID(从Token解析或页面跳转携带)
        zhaopian: '', // 头像URL
        nicheng: '',  // 昵称
        shoujihao: '' // 手机号
      }
    };
  },
  onLoad() {
    // 页面加载时:获取当前用户信息(用于表单回显)
    this.getCurrUserInfo();
  },
  methods: {
    // 1. 打开修改弹窗
    openPopup() {
      this.$refs.editPopup.open();
    },
    // 2. 关闭修改弹窗
    closePopup() {
      this.$refs.editPopup.close();
    },
    // 3. 获取当前用户信息(表单回显)
    getCurrUserInfo() {
      // 从Storage获取用户ID(Day10登录时存储)
      const userId = uni.getStorageSync('mypet_userId');
      this.form.id = userId;
      // 调用后端接口获取用户详情(需实现UserController/getInfo接口)
      request.get('/yonghu/getInfo', { params: { id: userId } })
        .then(res => {
          if (res.data.code === 0) {
            // 表单赋值(回显现有信息)
            this.form.zhaopian = res.data.data.zhaopian;
            this.form.nicheng = res.data.data.nicheng;
            this.form.shoujihao = res.data.data.shoujihao;
          }
        })
        .catch(err => {
          uni.showToast({ title: '获取信息失败', icon: 'none' });
          console.error(err);
        });
    },
    // 4. 选择并上传头像(核心:选图+上传)
    chooseAndUploadAvatar() {
      // 第一步:选择本地图片
      uni.chooseImage({
        count: 1, // 最多选1张
        sizeType: ['original', 'compressed'], // 原图/压缩图
        sourceType: ['album', 'camera'], // 相册/相机选图
        success: (chooseRes) => {
          // 第二步:上传图片到FileController
          uni.uploadFile({
            url: 'http://localhost:8080/file/upload', // 后端上传接口
            filePath: chooseRes.tempFilePaths[0], // 选中图片的临时路径
            name: 'file', // 与后端MultipartFile参数名一致(必须对应)
            header: { 
              'token': uni.getStorageSync('mypet_token'), // 携带Token验证登录
              'Content-Type': 'multipart/form-data' // 文件上传必须的Content-Type
            },
            // 上传进度回调(可选,提升用户体验)
            progress: (progressRes) => {
              console.log('上传进度:' + progressRes.progress + '%');
            },
            success: (uploadRes) => {
              // 解析后端返回的JSON(uploadRes.data默认是字符串)
              const uploadData = JSON.parse(uploadRes.data);
              if (uploadData.code === 0) {
                // 赋值头像URL到表单
                this.form.zhaopian = uploadData.file;
                uni.showToast({ title: '头像上传成功', icon: 'success' });
              } else {
                uni.showToast({ title: uploadData.msg, icon: 'none' });
              }
            },
            fail: (uploadErr) => {
              // 上传失败处理(如网络错误、接口不可达)
              uni.showToast({ title: '上传失败,请重试', icon: 'none' });
              console.error('上传错误:', uploadErr);
            }
          });
        },
        fail: (chooseErr) => {
          // 选图失败处理(如用户取消选图)
          console.log('用户取消选图或选图失败:', chooseErr);
        }
      });
    },
    // 5. 提交修改(调用UserController/update接口)
    submitEdit() {
      // 前端表单校验(兜底,避免无效请求)
      if (!this.form.nicheng.trim()) {
        return uni.showToast({ title: '昵称不能为空', icon: 'none' });
      }
      if (!/^1[3-9]\d{9}$/.test(this.form.shoujihao.trim())) {
        return uni.showToast({ title: '请输入正确的手机号', icon: 'none' });
      }
      // 调用更新接口
      request.post('/yonghu/update', this.form, {
        headers: { 'token': uni.getStorageSync('mypet_token') } // 携带Token
      })
        .then(res => {
          if (res.data.code === 0) {
            uni.showToast({ title: '修改成功', icon: 'success' });
            this.closePopup(); // 关闭弹窗
            this.$emit('infoUpdated'); // 触发父组件刷新个人信息页面
          } else {
            uni.showToast({ title: res.data.msg, icon: 'none' });
          }
        })
        .catch(err => {
          uni.showToast({ title: '网络异常,请重试', icon: 'none' });
          console.error(err);
        });
    }
  }
};
</script>
<style scoped>
/* 页面整体样式 */
.user-edit-page {
  padding: 30rpx;
  background-color: #f5f5f5;
}
/* 触发修改的按钮 */
.edit-btn {
  width: 100%;
  padding: 20rpx 0;
  background-color: #fff;
  border: 1px solid #007aff;
  color: #007aff;
  border-radius: 10rpx;
  font-size: 28rpx;
}
/* 弹窗内容容器 */
.popup-content {
  width: 80%;
  padding: 40rpx 30rpx;
  background-color: #fff;
  border-radius: 20rpx;
}
/* 弹窗标题 */
.popup-title {
  text-align: center;
  font-size: 32rpx;
  font-weight: bold;
  margin-bottom: 30rpx;
  color: #333;
}
/* 头像容器 */
.avatar-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-bottom: 30rpx;
}
/* 头像样式 */
.avatar {
  width: 160rpx;
  height: 160rpx;
  border-radius: 50%;
  border: 1px solid #eee;
  margin-bottom: 20rpx;
}
/* 上传头像按钮 */
.upload-btn {
  padding: 15rpx 30rpx;
  background-color: #f5f5f5;
  border: none;
  border-radius: 8rpx;
  font-size: 24rpx;
}
/* 按钮组样式 */
.btn-group {
  display: flex;
  justify-content: space-between;
  margin-top: 40rpx;
}
/* 取消按钮 */
.cancel-btn {
  width: 45%;
  padding: 20rpx 0;
  background-color: #f5f5f5;
  border: none;
  border-radius: 10rpx;
  font-size: 28rpx;
}
/* 提交按钮 */
.submit-btn {
  width: 45%;
  padding: 20rpx 0;
  border-radius: 10rpx;
  font-size: 28rpx;
}
</style>

📌 前端关键细节讲解

  1. 组件注册:必须在components中注册uni-popup等组件,否则弹窗无法显示;
  1. 表单回显:onLoad时调用getCurrUserInfo接口,加载当前用户信息,避免表单为空;
  1. 文件上传参数:name: 'file'必须与FileController的MultipartFile file参数名一致,否则后端无法接收文件;
  1. 权限校验:表单携带form.id(当前登录用户 ID),后端通过 Token 解析的 ID 对比,防止越权修改;
  1. 用户体验:添加上传进度、失败提示、表单校验,避免用户操作无反馈。

五、效果验证

按以下步骤验证功能是否正常,确保全流程通畅:

✅ 1. 后端接口测试(Postman)

测试 1:FileController 上传接口

  1. 请求地址http://localhost:8080/file/upload
  1. 请求方式:POST
  1. 请求头:token: 你的有效Token
  1. 请求体:选择form-data,Key 为file,Value 选择本地一张 jpg/png 图片
  1. 成功返回
{
  "code": 0,
  "msg": "上传成功",
  "file": "http://localhost:8080/mypet/upload/123e4567-e89b-12d3-a456-426614174000.jpg"
}
  1. 验证:打开config.properties配置的upload.path(如 D:/mypet/upload),确认图片已保存。

测试 2:UserController 更新接口

  1. 请求地址http://localhost:8080/yonghu/update
  1. 请求方式:POST
  1. 请求头:token: 你的有效Token
  1. 请求体(JSON)
{
  "id": "1", // 当前登录用户ID
  "nicheng": "宠物爱好者",
  "shoujihao": "13812345678",
  "zhaopian": "http://localhost:8080/mypet/upload/123e4567-e89b-12d3-a456-426614174000.jpg"
}
  1. 成功返回:{"code":0,"msg":"个人信息修改成功!"}
  1. 验证:查询yonghu表,确认nicheng、zhaopian字段已更新。

✅ 2. 前端功能测试(Uni-App 模拟器 / 真机)

  1. 登录:通过 Day10 的登录页面登录,确保mypet_token和mypet_userId已存储;
  1. 打开修改弹窗:点击 “修改个人信息” 按钮,弹窗正常显示,表单回显现有昵称、手机号、头像;
  1. 上传头像:点击 “上传新头像”→选择本地图片→上传成功后,头像预览区显示新图片;
  1. 修改其他信息:输入新昵称(如 “喵星人铲屎官”),确认手机号格式正确;
  1. 提交修改:点击 “提交修改”→弹出 “修改成功” 提示→弹窗关闭;
  1. 刷新页面:个人信息页面(如pages/user/index.vue)显示新昵称和新头像,数据库同步更新。

六、常见问题与排查

问题现象可能原因解决方式
1. 弹窗不显示1. 未注册uni-popup组件;2. 未调用open()方法;3. 组件引用错误(如ref不匹配)1. 在components中注册uniPopup;2. 确认this.$refs.editPopup.open()被调用;3. 检查ref="editPopup"与组件ref一致
2. 上传失败,后端提示 “file 参数为空”前端uni.uploadFile的name字段与后端MultipartFile参数名不一致确保前端name: 'file',后端参数为MultipartFile file
3. 图片上传成功,但前端无法访问 URL1. 服务器路径配置错误;2. 后端未配置静态资源映射1. 检查upload.path是否存在,且文件已保存;2. 在SpringMVC配置静态资源映射:<mvc:resources mapping="/mypet/upload/**" location="file:D:/mypet/upload/"/>
4. 提示 “无权限修改他人信息”前端form.id与 Token 解析的用户 ID 不一致确保form.id赋值为uni.getStorageSync('mypet_userId'),且与 Token 中的用户 ID 相同
5. 上传时提示 “Token 无效”1. Token 过期;2. 上传请求头未携带 Token;3. Token 格式错误1. 重新登录获取新 Token;2. 在uni.uploadFile的header中添加'token': 存储的Token;3. 检查 Token 是否完整(无空格 / 截断)
6. 文件保存失败,提示 “路径不存在”upload.path对应的文件夹未创建手动创建配置的路径(如 D:/mypet/upload),或在FileController中添加saveFile.getParentFile().mkdirs()确保父文件夹存在

七、扩展与提升

7.1 功能优化:头像裁剪(提升用户体验)

当前选图后直接上传,可能存在图片比例不合适的问题,可集成uni-cropper组件实现头像裁剪:

  1. 导入uni-cropper组件:"uni-cropper": "@dcloudio/uni-ui/lib/uni-cropper/uni-cropper";
  1. 选图后打开裁剪弹窗,设置裁剪比例(如 1:1,适合头像);
  1. 裁剪完成后再调用uni.uploadFile上传,确保头像比例统一。

7.2 性能优化:大文件分片上传

若用户上传大尺寸图片(如几 MB),直接上传易超时,可实现分片上传:

  1. 前端用uni.getFileSystemManager()读取文件,分割为多个小分片(如 512KB / 片);
  1. 每次上传一个分片,携带chunkIndex(分片索引)、totalChunks(总分片数)、fileId(文件唯一标识);
  1. 后端接收分片并暂存,所有分片上传完成后合并为完整文件。

7.3 安全优化:文件校验与防注入

  1. 文件格式校验:除了前端校验,后端需再次校验文件类型(避免伪装成图片的恶意文件),可通过file.getContentType()或文件头信息判断;
  1. 文件大小限制:在FileController中添加大小限制,如不超过 5MB:
if (file.getSize() > 5 * 1024 * 1024) { // 5MB
    return R.error("图片大小不能超过5MB!");
}
  1. SQL 注入防护:依赖 MP 的updateById(),避免手动拼接 SQL,防止注入攻击。

八、课堂互动

🙋‍♂️ 思考题 / 互动:

  1. 若用户想修改手机号,如何添加 “短信验证码验证”(确保是本人操作)?
  1. 上传的头像图片,如何实现 “压缩后再上传”(减少服务器存储和带宽消耗)?

💡 互动引导:你的头像上传和信息修改功能跑通了吗?如果遇到 “URL 无法访问” 或 “修改后不刷新” 的问题,欢迎分享你的uploadRes数据或页面代码,我们一起排查!

九、下节预告

👉 明天 Day12:宠物列表与详情页!我们将学习:

  1. 用 MP 的page()方法实现宠物列表分页查询;
  1. 前端用uni-list展示宠物列表(含图片、名称、价格);
  1. 实现 “点击列表项进入详情页” 的路由跳转与参数传递;
  1. 详情页展示宠物完整信息(如品种、年龄、描述)。

记得提前复习 MP 的分页查询用法哦!