完成头像上传

This commit is contained in:
柏码の讲师 2023-09-20 17:47:32 +08:00
parent aba88b26a2
commit 915718a07d
17 changed files with 334 additions and 92 deletions

View File

@ -94,6 +94,12 @@
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.1.0</version>
</dependency>
<!-- 对象存储Minio依赖 -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.3.9</version>
</dependency>
</dependencies>
<profiles>

View File

@ -0,0 +1,28 @@
package com.example.config;
import io.minio.MinioClient;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Slf4j
@Configuration
public class MinioConfiguration {
@Value("${spring.minio.endpoint}")
String endpoint;
@Value("${spring.minio.username}")
String username;
@Value("${spring.minio.password}")
String password;
@Bean
public MinioClient minioClient(){
log.info("Init minio client...");
return MinioClient.builder()
.endpoint(endpoint)
.credentials(username, password)
.build();
}
}

View File

@ -54,6 +54,7 @@ public class SecurityConfiguration {
return http
.authorizeHttpRequests(conf -> conf
.requestMatchers("/api/auth/**", "/error").permitAll()
.requestMatchers("/images/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().hasAnyRole(Const.ROLE_DEFAULT)
)

View File

@ -0,0 +1,35 @@
package com.example.controller;
import com.example.entity.RestBean;
import com.example.service.ImageService;
import com.example.utils.Const;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@Slf4j
@RestController
@RequestMapping("/api/image")
public class ImageController {
@Resource
ImageService service;
@PostMapping("/avatar")
public RestBean<String> uploadAvatar(@RequestParam("file") MultipartFile file,
@RequestAttribute(Const.ATTR_USER_ID) int id) throws IOException {
if(file.getSize() > 1025 * 100)
return RestBean.failure(400, "头像图片不能大于100KB");
log.info("正在进行头像上传操作...");
String url = service.uploadAvatar(file, id);
if(url != null) {
log.info("头像上传成功,大小: " + file.getSize());
return RestBean.success(url);
} else {
return RestBean.failure(400, "头像上传失败,请联系管理员!");
}
}
}

View File

@ -0,0 +1,46 @@
package com.example.controller;
import com.example.entity.RestBean;
import com.example.service.ImageService;
import io.minio.errors.ErrorResponseException;
import jakarta.annotation.Resource;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
public class ObjectController {
@Resource
ImageService service;
@GetMapping("/images/avatar/**")
public void imageFetch(HttpServletRequest request, HttpServletResponse response) throws Exception {
this.fetchImage(request, response);
}
private void fetchImage(HttpServletRequest request, HttpServletResponse response) throws Exception {
String imagePath = request.getServletPath().substring(7);
ServletOutputStream stream = response.getOutputStream();
if(imagePath.length() <= 13) {
response.setStatus(404);
stream.println(RestBean.failure(404, "Not found").toString());
} else {
try {
service.fetchImageFromMinio(stream, imagePath);
response.setHeader("Cache-Control", "max-age=2592000");
} catch (ErrorResponseException e) {
if(e.response().code() == 404) {
response.setStatus(404);
stream.println(RestBean.failure(404, "Not found").toString());
} else {
log.error("从Minio获取图片出现异常: "+e.getMessage(), e);
}
}
}
}
}

View File

@ -22,5 +22,6 @@ public class Account implements BaseData {
String password;
String email;
String role;
String avatar;
Date registerTime;
}

View File

@ -9,5 +9,6 @@ public class AccountVO {
String username;
String email;
String role;
String avatar;
Date registerTime;
}

View File

@ -29,7 +29,7 @@ public class RequestLogFilter extends OncePerRequestFilter {
@Resource
SnowflakeIdGenerator generator;
private final Set<String> ignores = Set.of("/swagger-ui", "/v3/api-docs");
private final Set<String> ignores = Set.of("/swagger-ui", "/v3/api-docs", "/images");
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

View File

@ -0,0 +1,11 @@
package com.example.service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.OutputStream;
public interface ImageService {
String uploadAvatar(MultipartFile file, int id) throws IOException;
void fetchImageFromMinio(OutputStream stream, String image) throws Exception;
}

View File

@ -99,7 +99,7 @@ public class AccountServiceImpl extends ServiceImpl<AccountMapper, Account> impl
if(this.existsAccountByUsername(username)) return "该用户名已被他人使用,请重新更换";
String password = passwordEncoder.encode(info.getPassword());
Account account = new Account(null, info.getUsername(),
password, email, Const.ROLE_DEFAULT, new Date());
password, email, Const.ROLE_DEFAULT, null, new Date());
if(!this.save(account)) {
return "内部错误,注册失败";
} else {

View File

@ -0,0 +1,62 @@
package com.example.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.example.entity.dto.Account;
import com.example.mapper.AccountMapper;
import com.example.service.ImageService;
import io.minio.GetObjectArgs;
import io.minio.GetObjectResponse;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.compress.utils.IOUtils;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.OutputStream;
import java.util.UUID;
@Slf4j
@Service
public class ImageServiceImpl implements ImageService {
@Resource
MinioClient client;
@Resource
AccountMapper mapper;
@Override
public void fetchImageFromMinio(OutputStream stream, String image) throws Exception {
GetObjectArgs args = GetObjectArgs.builder()
.bucket("study")
.object(image)
.build();
GetObjectResponse response = client.getObject(args);
IOUtils.copy(response, stream);
}
@Override
public String uploadAvatar(MultipartFile file, int id) throws IOException {
String imageName = UUID.randomUUID().toString().replace("-", "");
imageName = "/avatar/" + imageName;
PutObjectArgs args = PutObjectArgs.builder()
.bucket("study")
.stream(file.getInputStream(), file.getSize(), -1)
.object(imageName)
.build();
try {
client.putObject(args);
if(mapper.update(null, Wrappers.<Account>update()
.eq("id", id).set("avatar", imageName)) > 0) {
return imageName;
} else
return null;
} catch (Exception e) {
log.error("图片上传出现问题: "+ e.getMessage(), e);
return null;
}
}
}

View File

@ -39,3 +39,7 @@ spring:
origin: '*'
credentials: false
methods: '*'
minio:
endpoint: 'http://localhost:9000'
username: 'minio'
password: 'password'

View File

@ -43,3 +43,7 @@ spring:
origin: '*'
credentials: false
methods: '*'
minio:
endpoint: 'http://localhost:9000'
username: 'minio'
password: 'password'

View File

@ -99,4 +99,4 @@ function unauthorized() {
return !takeAccessToken()
}
export { post, get, login, logout, unauthorized }
export { post, get, login, logout, unauthorized, accessHeader }

View File

@ -1,4 +1,5 @@
import { defineStore } from "pinia";
import axios from "axios";
export const useStore = defineStore('general', {
state: () => {
@ -7,8 +8,16 @@ export const useStore = defineStore('general', {
username: '',
email: '',
role: '',
avatar: null,
registerTime: null
}
}
}, getters: {
avatarUrl() {
if(this.user.avatar)
return `${axios.defaults.baseURL}/images${this.user.avatar}`
else
return 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
}
}
})

View File

@ -59,7 +59,7 @@ function userLogout() {
<div>{{store.user.email}}</div>
</div>
<el-dropdown>
<el-avatar src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"/>
<el-avatar :src="store.avatarUrl"/>
<template #dropdown>
<el-dropdown-item>
<el-icon><Operation/></el-icon>

View File

@ -4,8 +4,9 @@ import Card from "@/components/Card.vue";
import {Message, Refresh, Select, User} from "@element-plus/icons-vue";
import {useStore} from "@/store";
import {computed, reactive, ref} from "vue";
import {get, post} from "@/net";
import {accessHeader, get, post} from "@/net";
import {ElMessage} from "element-plus";
import axios from "axios";
const store = useStore()
@ -29,7 +30,7 @@ const emailForm = reactive({
const validateUsername = (rule, value, callback) => {
if (value === '') {
callback(new Error('请输入用户名'))
} else if(!/^[a-zA-Z0-9\u4e00-\u9fa5]+$/.test(value)){
} else if (!/^[a-zA-Z0-9\u4e00-\u9fa5]+$/.test(value)) {
callback(new Error('用户名不能包含特殊字符,只能是中文/英文'))
} else {
callback()
@ -37,10 +38,10 @@ const validateUsername = (rule, value, callback) => {
}
const rules = {
username: [
{ validator: validateUsername, trigger: ['blur', 'change'] },
{ min: 2, max: 10, message: '用户名的长度必须在2-10个字符之间', trigger: ['blur', 'change'] },
{validator: validateUsername, trigger: ['blur', 'change']},
{min: 2, max: 10, message: '用户名的长度必须在2-10个字符之间', trigger: ['blur', 'change']},
], email: [
{ required: true, message: '请输入邮件地址', trigger: 'blur' },
{required: true, message: '请输入邮件地址', trigger: 'blur'},
{type: 'email', message: '请输入合法的电子邮件地址', trigger: ['blur', 'change']}
]
}
@ -52,7 +53,7 @@ const loading = reactive({
function saveDetails() {
baseFormRef.value.validate(isValid => {
if(isValid) {
if (isValid) {
loading.base = true
post('/api/user/save-details', baseForm, () => {
ElMessage.success('用户信息保存成功')
@ -66,6 +67,7 @@ function saveDetails() {
}
})
}
get('/api/user/details', data => {
baseForm.username = store.user.username
baseForm.gender = data.gender
@ -80,18 +82,19 @@ get('/api/user/details', data => {
const coldTime = ref(0)
const isEmailValid = ref(true)
const onValidate = (prop, isValid) => {
if(prop === 'email')
if (prop === 'email')
isEmailValid.value = isValid
}
function sendEmailCode() {
emailFormRef.value.validate(isValid => {
if(isValid) {
if (isValid) {
coldTime.value = 60
get(`/api/auth/ask-code?email=${emailForm.email}&type=modify`, () => {
ElMessage.success(`验证码已成功发送到邮箱:${emailForm.email},请注意查收`)
const handle = setInterval(() => {
coldTime.value--
if(coldTime.value === 0) {
if (coldTime.value === 0) {
clearInterval(handle)
}
}, 1000)
@ -102,9 +105,10 @@ function sendEmailCode() {
}
})
}
function modifyEmail() {
emailFormRef.value.validate(isValid => {
if(isValid) {
if (isValid) {
post('/api/user/modify-email', emailForm, () => {
ElMessage.success('邮件修改成功')
store.user.email = emailForm.email
@ -113,94 +117,124 @@ function modifyEmail() {
}
})
}
function beforeAvatarUpload(rawFile) {
if (rawFile.type !== 'image/jpeg' && rawFile.type !== 'image/png') {
ElMessage.error('头像只能是 JPG/PNG 格式的')
return false
} else if(rawFile.size / 1024 > 100) {
ElMessage.error('头像大小不能大于 100KB')
return false
}
return true
}
function uploadSuccess(response){
ElMessage.success('头像上传成功')
store.user.avatar = response.data
}
</script>
<template>
<div style="display: flex;max-width: 950px;margin: auto">
<div class="settings-left">
<card :icon="User" title="账号信息设置" desc="在这里编辑您的个人信息,您可以在隐私设置中选择是否展示这些信息"
v-loading="loading.form">
<el-form :model="baseForm" :rules="rules" ref="baseFormRef" label-position="top" style="margin: 0 10px 10px 10px">
<el-form-item label="用户名" prop="username">
<el-input v-model="baseForm.username" maxlength="10"/>
</el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="baseForm.gender">
<el-radio :label="0"></el-radio>
<el-radio :label="1"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="baseForm.phone" maxlength="11"/>
</el-form-item>
<el-form-item label="QQ号" prop="qq">
<el-input v-model="baseForm.qq" maxlength="13"/>
</el-form-item>
<el-form-item label="微信号" prop="wx">
<el-input v-model="baseForm.wx" maxlength="20"/>
</el-form-item>
<el-form-item label="个人简介" prop="desc">
<el-input v-model="baseForm.desc" type="textarea" :rows="6" maxlength="200"/>
</el-form-item>
<div>
<el-button :icon="Select" @click="saveDetails" :loading="loading.base"
type="success">保存用户信息</el-button>
</div>
</el-form>
</card>
<card style="margin-top: 10px" :icon="Message" title="电子邮件设置" desc="您可以在这里修改默认绑定的电子邮件地址">
<el-form :rules="rules" @validate="onValidate" :model="emailForm" ref="emailFormRef" label-position="top" style="margin: 0 10px 10px 10px">
<el-form-item label="电子邮件" prop="email">
<el-input v-model="emailForm.email"/>
</el-form-item>
<el-form-item prop="code">
<el-row style="width: 100%" :gutter="10">
<el-col :span="18">
<el-input placeholder="请获取验证码" v-model="emailForm.code"/>
</el-col>
<el-col :span="6">
<el-button type="success" style="width: 100%" :disabled="!isEmailValid || coldTime > 0"
@click="sendEmailCode" plain>
{{ coldTime > 0 ? `请稍后 ${coldTime}` : '获取验证码'}}
</el-button>
</el-col>
</el-row>
</el-form-item>
<div>
<el-button :icon="Refresh" type="success" @click="modifyEmail">更新电子邮件</el-button>
</div>
</el-form>
</card>
<div style="display: flex;max-width: 950px;margin: auto">
<div class="settings-left">
<card :icon="User" title="账号信息设置" desc="在这里编辑您的个人信息,您可以在隐私设置中选择是否展示这些信息"
v-loading="loading.form">
<el-form :model="baseForm" :rules="rules" ref="baseFormRef" label-position="top"
style="margin: 0 10px 10px 10px">
<el-form-item label="用户名" prop="username">
<el-input v-model="baseForm.username" maxlength="10"/>
</el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="baseForm.gender">
<el-radio :label="0"></el-radio>
<el-radio :label="1"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="baseForm.phone" maxlength="11"/>
</el-form-item>
<el-form-item label="QQ号" prop="qq">
<el-input v-model="baseForm.qq" maxlength="13"/>
</el-form-item>
<el-form-item label="微信号" prop="wx">
<el-input v-model="baseForm.wx" maxlength="20"/>
</el-form-item>
<el-form-item label="个人简介" prop="desc">
<el-input v-model="baseForm.desc" type="textarea" :rows="6" maxlength="200"/>
</el-form-item>
<div>
<el-button :icon="Select" @click="saveDetails" :loading="loading.base"
type="success">保存用户信息
</el-button>
</div>
</el-form>
</card>
<card style="margin-top: 10px" :icon="Message" title="电子邮件设置"
desc="您可以在这里修改默认绑定的电子邮件地址">
<el-form :rules="rules" @validate="onValidate" :model="emailForm" ref="emailFormRef"
label-position="top" style="margin: 0 10px 10px 10px">
<el-form-item label="电子邮件" prop="email">
<el-input v-model="emailForm.email"/>
</el-form-item>
<el-form-item prop="code">
<el-row style="width: 100%" :gutter="10">
<el-col :span="18">
<el-input placeholder="请获取验证码" v-model="emailForm.code"/>
</el-col>
<el-col :span="6">
<el-button type="success" style="width: 100%" :disabled="!isEmailValid || coldTime > 0"
@click="sendEmailCode" plain>
{{ coldTime > 0 ? `请稍后 ${coldTime}` : '获取验证码' }}
</el-button>
</el-col>
</el-row>
</el-form-item>
<div>
<el-button :icon="Refresh" type="success" @click="modifyEmail">更新电子邮件</el-button>
</div>
</el-form>
</card>
</div>
<div class="settings-right">
<div style="position: sticky;top: 20px">
<card>
<div style="text-align: center;padding: 5px 15px 0 15px">
<el-avatar :size="70" :src="store.avatarUrl"/>
<div style="margin: 5px 0">
<el-upload
:action="axios.defaults.baseURL + '/api/image/avatar'"
:show-file-list="false"
:before-upload="beforeAvatarUpload"
:on-success="uploadSuccess"
:headers="accessHeader()">
<el-button size="small" round>修改头像</el-button>
</el-upload>
</div>
<div style="font-weight: bold">你好, {{ store.user.username }}</div>
</div>
<el-divider style="margin: 10px 0"/>
<div style="font-size: 14px;color: grey;padding: 10px">
{{ desc || '这个用户很懒,没有填写个人简介~' }}
</div>
</card>
<card style="margin-top: 10px;font-size: 14px">
<div>账号注册时间: {{ registerTime }}</div>
<div style="color: grey">欢迎加入我们的学习论坛</div>
</card>
</div>
</div>
</div>
<div class="settings-right">
<div style="position: sticky;top: 20px">
<card>
<div style="text-align: center;padding: 5px 15px 0 15px">
<el-avatar :size="70" src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"/>
<div style="font-weight: bold">你好, {{store.user.username}}</div>
</div>
<el-divider style="margin: 10px 0"/>
<div style="font-size: 14px;color: grey;padding: 10px">
{{ desc || '这个用户很懒,没有填写个人简介~'}}
</div>
</card>
<card style="margin-top: 10px;font-size: 14px">
<div>账号注册时间: {{registerTime}}</div>
<div style="color: grey">欢迎加入我们的学习论坛</div>
</card>
</div>
</div>
</div>
</template>
<style scoped>
.settings-left {
flex: 1;
margin: 20px;
flex: 1;
margin: 20px;
}
.settings-right {
width: 300px;
margin: 20px 30px 20px 0;
width: 300px;
margin: 20px 30px 20px 0;
}
</style>