一、前言
欢迎回到mypet项目实战!🎉 今天我们聚焦 个人信息修改 模块 —— 这是用户完善个人资料、提升使用体验的核心功能。用户登录后(基于 Day10 的 Token 登录态),可修改头像、昵称、手机号等信息,其中头像上传是本次重点(涉及跨端文件上传、后端文件存储、URL 回显全流程)。
本次实现的核心逻辑:
前端通过uni-popup弹窗展示修改表单,用uni.chooseImage选图 + uni.uploadFile上传至后端FileController,获取图片 URL 后,调用UserController的更新接口,通过 MyBatis-Plus 的updateById()更新数据库,最终实时刷新页面显示新信息。即使是新手,也能通过 step-by-step 代码掌握文件上传与信息更新的联动。
📌 学习目标:
- 掌握 MP 的updateById()方法,实现用户信息精准更新;
- 熟练使用uni.chooseImage+uni.uploadFile完成跨端图片上传;
- 理解FileController的文件接收、存储、URL 返回逻辑;
- 解决 “信息更新后页面实时刷新”“上传时 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>
📌 前端关键细节讲解:
- 组件注册:必须在components中注册uni-popup等组件,否则弹窗无法显示;
- 表单回显:onLoad时调用getCurrUserInfo接口,加载当前用户信息,避免表单为空;
- 文件上传参数:name: 'file'必须与FileController的MultipartFile file参数名一致,否则后端无法接收文件;
- 权限校验:表单携带form.id(当前登录用户 ID),后端通过 Token 解析的 ID 对比,防止越权修改;
- 用户体验:添加上传进度、失败提示、表单校验,避免用户操作无反馈。
五、效果验证
按以下步骤验证功能是否正常,确保全流程通畅:
✅ 1. 后端接口测试(Postman)
测试 1:FileController 上传接口
- 请求方式:POST
- 请求头:token: 你的有效Token
- 请求体:选择form-data,Key 为file,Value 选择本地一张 jpg/png 图片
- 成功返回:
{
"code": 0,
"msg": "上传成功",
"file": "http://localhost:8080/mypet/upload/123e4567-e89b-12d3-a456-426614174000.jpg"
}
- 验证:打开config.properties配置的upload.path(如 D:/mypet/upload),确认图片已保存。
测试 2:UserController 更新接口
- 请求方式:POST
- 请求头:token: 你的有效Token
- 请求体(JSON) :
{
"id": "1", // 当前登录用户ID
"nicheng": "宠物爱好者",
"shoujihao": "13812345678",
"zhaopian": "http://localhost:8080/mypet/upload/123e4567-e89b-12d3-a456-426614174000.jpg"
}
- 成功返回:{"code":0,"msg":"个人信息修改成功!"}
- 验证:查询yonghu表,确认nicheng、zhaopian字段已更新。
✅ 2. 前端功能测试(Uni-App 模拟器 / 真机)
- 登录:通过 Day10 的登录页面登录,确保mypet_token和mypet_userId已存储;
- 打开修改弹窗:点击 “修改个人信息” 按钮,弹窗正常显示,表单回显现有昵称、手机号、头像;
- 上传头像:点击 “上传新头像”→选择本地图片→上传成功后,头像预览区显示新图片;
- 修改其他信息:输入新昵称(如 “喵星人铲屎官”),确认手机号格式正确;
- 提交修改:点击 “提交修改”→弹出 “修改成功” 提示→弹窗关闭;
- 刷新页面:个人信息页面(如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. 图片上传成功,但前端无法访问 URL | 1. 服务器路径配置错误;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组件实现头像裁剪:
- 导入uni-cropper组件:"uni-cropper": "@dcloudio/uni-ui/lib/uni-cropper/uni-cropper";
- 选图后打开裁剪弹窗,设置裁剪比例(如 1:1,适合头像);
- 裁剪完成后再调用uni.uploadFile上传,确保头像比例统一。
7.2 性能优化:大文件分片上传
若用户上传大尺寸图片(如几 MB),直接上传易超时,可实现分片上传:
- 前端用uni.getFileSystemManager()读取文件,分割为多个小分片(如 512KB / 片);
- 每次上传一个分片,携带chunkIndex(分片索引)、totalChunks(总分片数)、fileId(文件唯一标识);
- 后端接收分片并暂存,所有分片上传完成后合并为完整文件。
7.3 安全优化:文件校验与防注入
- 文件格式校验:除了前端校验,后端需再次校验文件类型(避免伪装成图片的恶意文件),可通过file.getContentType()或文件头信息判断;
- 文件大小限制:在FileController中添加大小限制,如不超过 5MB:
if (file.getSize() > 5 * 1024 * 1024) { // 5MB
return R.error("图片大小不能超过5MB!");
}
- SQL 注入防护:依赖 MP 的updateById(),避免手动拼接 SQL,防止注入攻击。
八、课堂互动
🙋♂️ 思考题 / 互动:
- 若用户想修改手机号,如何添加 “短信验证码验证”(确保是本人操作)?
- 上传的头像图片,如何实现 “压缩后再上传”(减少服务器存储和带宽消耗)?
💡 互动引导:你的头像上传和信息修改功能跑通了吗?如果遇到 “URL 无法访问” 或 “修改后不刷新” 的问题,欢迎分享你的uploadRes数据或页面代码,我们一起排查!
九、下节预告
👉 明天 Day12:宠物列表与详情页!我们将学习:
- 用 MP 的page()方法实现宠物列表分页查询;
- 前端用uni-list展示宠物列表(含图片、名称、价格);
- 实现 “点击列表项进入详情页” 的路由跳转与参数传递;
- 详情页展示宠物完整信息(如品种、年龄、描述)。
记得提前复习 MP 的分页查询用法哦!