Compare commits
2
Commits
73e404633d
...
d018567642
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d018567642 | ||
|
|
53b2fedb7a |
@@ -24,13 +24,16 @@ import org.springframework.beans.factory.annotation.Value;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.context.request.RequestContextHolder;
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
import javax.servlet.http.HttpServletResponse;
|
import javax.servlet.http.HttpServletResponse;
|
||||||
import javax.servlet.http.HttpSession;
|
import javax.servlet.http.HttpSession;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 开放接口(/oapi/**)调用日志切面。<br>
|
* 开放接口(/oapi/**)调用日志切面。<br>
|
||||||
@@ -199,6 +202,16 @@ public class OapiLogAspect {
|
|||||||
|| arg instanceof HttpSession) {
|
|| arg instanceof HttpSession) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// multipart 文件不序列化内容(避免读文件字节/写爆 request_params),仅记文件名元数据
|
||||||
|
if (arg instanceof MultipartFile) {
|
||||||
|
MultipartFile mf = (MultipartFile) arg;
|
||||||
|
Map<String, Object> meta = new LinkedHashMap<String, Object>();
|
||||||
|
meta.put("fileName", mf.getOriginalFilename());
|
||||||
|
meta.put("contentType", mf.getContentType());
|
||||||
|
meta.put("size", mf.getSize());
|
||||||
|
filtered.add(meta);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
filtered.add(arg);
|
filtered.add(arg);
|
||||||
}
|
}
|
||||||
if (filtered.isEmpty()) {
|
if (filtered.isEmpty()) {
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ public class MyWebAppConfigurer implements WebMvcConfigurer {
|
|||||||
String ICON_FILE_WEB_PATH = "/upload/icon/";
|
String ICON_FILE_WEB_PATH = "/upload/icon/";
|
||||||
String WORD_FILE_WEB_PATH = "/upload/word/";
|
String WORD_FILE_WEB_PATH = "/upload/word/";
|
||||||
String HTML_FILE_WEB_PATH = "/upload/html/";
|
String HTML_FILE_WEB_PATH = "/upload/html/";
|
||||||
|
String AVATAR_FILE_WEB_PATH = "/upload/avatar/";
|
||||||
registry.addResourceHandler(IMG_FILE_WEB_PATH + "**").addResourceLocations("file:" + FILE_PATH + "/image/");
|
registry.addResourceHandler(IMG_FILE_WEB_PATH + "**").addResourceLocations("file:" + FILE_PATH + "/image/");
|
||||||
registry.addResourceHandler(File_FILE_WEB_PATH + "**").addResourceLocations("file:" + FILE_PATH + "/file/");
|
registry.addResourceHandler(File_FILE_WEB_PATH + "**").addResourceLocations("file:" + FILE_PATH + "/file/");
|
||||||
registry.addResourceHandler(ICON_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/icon/");
|
registry.addResourceHandler(ICON_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/icon/");
|
||||||
registry.addResourceHandler(WORD_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/word/");
|
registry.addResourceHandler(WORD_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/word/");
|
||||||
registry.addResourceHandler(HTML_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/html/");
|
registry.addResourceHandler(HTML_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/html/");
|
||||||
|
registry.addResourceHandler(AVATAR_FILE_WEB_PATH+"**").addResourceLocations("file:"+FILE_PATH+"/avatar/");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -236,6 +237,17 @@ public class UserController {
|
|||||||
sysUserService.changePassword(request.getUserId(), request.getOldPassword(), request.getNewPassword());
|
sysUserService.changePassword(request.getUserId(), request.getOldPassword(), request.getNewPassword());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储并写入 profile_picture,<br>
|
||||||
|
* 返回新头像完整 Web URL。multipart 直传(form 字段 userId + 文件 part file)。<br>
|
||||||
|
* 与修改密码一致,仅由过滤器层强制应用签名;POST 自动记 OapiLog(文件内容不落库,仅记文件名元数据)。
|
||||||
|
*/
|
||||||
|
@PostMapping("profilePicture")
|
||||||
|
public String profilePicture(@RequestParam("userId") Integer userId,
|
||||||
|
@RequestParam("file") MultipartFile file) {
|
||||||
|
return sysUserService.changeProfilePicture(userId, file);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置用户在指定组上的状态(转出/恢复)。<br>
|
* 设置用户在指定组上的状态(转出/恢复)。<br>
|
||||||
* state=1 转出、state=0 恢复正常;仅作关系标记,不影响成员查询、组织链与下游推送。
|
* state=1 转出、state=0 恢复正常;仅作关系标记,不影响成员查询、组织链与下游推送。
|
||||||
|
|||||||
@@ -197,6 +197,16 @@ public interface ISysUserService {
|
|||||||
*/
|
*/
|
||||||
void changePassword(Integer userId, String oldPassword, String newPassword);
|
void changePassword(Integer userId, String oldPassword, String newPassword);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储至 avatar 子目录 →
|
||||||
|
* 写入 profile_picture。oapi 开放接口专用;返回新头像完整 Web URL。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param file 头像图片文件(multipart)
|
||||||
|
* @return 新头像完整可访问 URL
|
||||||
|
*/
|
||||||
|
String changeProfilePicture(Integer userId, MultipartFile file);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 开放接口专用:设置用户在指定组上的状态(转出/恢复)。<br>
|
* 开放接口专用:设置用户在指定组上的状态(转出/恢复)。<br>
|
||||||
* 仅作关系标记,不影响成员查询、组织链与下游推送;同值重复调用幂等。<br>
|
* 仅作关系标记,不影响成员查询、组织链与下游推送;同值重复调用幂等。<br>
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import cn.afterturn.easypoi.excel.entity.ExportParams;
|
|||||||
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
|
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
|
||||||
import com.yunhai.userbase.model.sysuser.ExcelSysUser;
|
import com.yunhai.userbase.model.sysuser.ExcelSysUser;
|
||||||
import com.yunhai.userbase.utils.EasyPoiUtils;
|
import com.yunhai.userbase.utils.EasyPoiUtils;
|
||||||
|
import com.yunhai.userbase.utils.FileUtil;
|
||||||
|
import com.yunhai.userbase.utils.ImageCompressUtil;
|
||||||
import com.yunhai.userbase.utils.ValidatorUtil;
|
import com.yunhai.userbase.utils.ValidatorUtil;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
@@ -1223,6 +1225,41 @@ public class SysUserService implements ISysUserService {
|
|||||||
sysUserRepository.save(entity);
|
sysUserRepository.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 头像大小上限(与前端限制一致:20MB) */
|
||||||
|
private static final long AVATAR_MAX_SIZE = 20L * 1024 * 1024;
|
||||||
|
/** 头像扩展名白名单(webp 由压缩工具原样兜底存储) */
|
||||||
|
private static final Set<String> AVATAR_ALLOWED_EXT = new HashSet<>(
|
||||||
|
Arrays.asList("jpg", "jpeg", "png", "gif", "bmp", "webp"));
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public String changeProfilePicture(Integer userId, MultipartFile file) {
|
||||||
|
SysUser entity = sysUserRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new DException("系统用户不存在"));
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new DException("请选择上传文件");
|
||||||
|
}
|
||||||
|
if (file.getSize() > AVATAR_MAX_SIZE) {
|
||||||
|
throw new DException("图片大小不能超过20MB");
|
||||||
|
}
|
||||||
|
String ext = StringUtils.lowerCase(cn.hutool.core.io.FileUtil.extName(file.getOriginalFilename()));
|
||||||
|
if (!AVATAR_ALLOWED_EXT.contains(ext)) {
|
||||||
|
throw new DException("仅支持 jpg/jpeg/png/gif/bmp/webp 格式图片");
|
||||||
|
}
|
||||||
|
byte[] raw;
|
||||||
|
try {
|
||||||
|
raw = file.getBytes();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new DException("读取上传文件失败");
|
||||||
|
}
|
||||||
|
byte[] data = ImageCompressUtil.compressAvatar(raw, ext);
|
||||||
|
String url = FileUtil.uploadBytes(data, "avatar", "." + ext);
|
||||||
|
entity.setProfilePicture(url);
|
||||||
|
entity.setUpdateUser(SYSTEM_USER_ID);
|
||||||
|
sysUserRepository.save(entity);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
/** 校验手机号与身份证(有值才校验)。 */
|
/** 校验手机号与身份证(有值才校验)。 */
|
||||||
private void validateProfile(SysUserRequest request) {
|
private void validateProfile(SysUserRequest request) {
|
||||||
if (StringUtils.isNotBlank(request.getPhone()) && !ValidatorUtil.isPhone(request.getPhone())) {
|
if (StringUtils.isNotBlank(request.getPhone()) && !ValidatorUtil.isPhone(request.getPhone())) {
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ public class FileUtil {
|
|||||||
* @param ext 固定后缀(带点)
|
* @param ext 固定后缀(带点)
|
||||||
* @return 完整可访问的 Web URL
|
* @return 完整可访问的 Web URL
|
||||||
*/
|
*/
|
||||||
private static String uploadBytes(byte[] data, String subDir, String ext) {
|
public static String uploadBytes(byte[] data, String subDir, String ext) {
|
||||||
if (data == null || data.length == 0 || subDir == null || subDir.trim().length() == 0) {
|
if (data == null || data.length == 0 || subDir == null || subDir.trim().length() == 0) {
|
||||||
throw new DException("上传内容为空");
|
throw new DException("上传内容为空");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.yunhai.userbase.utils;
|
||||||
|
|
||||||
|
import cn.hutool.core.img.Img;
|
||||||
|
import cn.hutool.core.img.ImgUtil;
|
||||||
|
import com.yunhai.platform.utils.exception.DException;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头像图片压缩工具(基于 hutool-core 的 Img,无额外依赖)。<br>
|
||||||
|
* <br>
|
||||||
|
* 压缩规则(按原图字节数分支):
|
||||||
|
* <ul>
|
||||||
|
* <li>gif:原样返回——ImageIO 重编码会丢失动画帧;</li>
|
||||||
|
* <li>解码失败(webp/heic/CMYK jpg 等 JDK 无解码器的格式):原样返回兜底,上传主流程不中断;</li>
|
||||||
|
* <li>≤5MB:quality 0.95 不缩放(近无损);</li>
|
||||||
|
* <li>>5MB:quality 0.75,且长边 >2000px 时等比缩至 2000px(手机照片体积大主要是分辨率大);</li>
|
||||||
|
* <li>jpg/jpeg 走 JPEG 有损质量压缩(透明区域铺白底);png/bmp 为无损格式无质量参数,
|
||||||
|
* 仅在需要缩放时才重编码;</li>
|
||||||
|
* <li>压缩结果不小于原大小时保留原字节(已优化过的小图等效无损直存)。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class ImageCompressUtil {
|
||||||
|
|
||||||
|
/** 超过该字节数走高压缩分支(5MB) */
|
||||||
|
private static final int BIG_THRESHOLD = 5 * 1024 * 1024;
|
||||||
|
/** 高压缩分支的最大长边(px) */
|
||||||
|
private static final int BIG_MAX_SIDE = 2000;
|
||||||
|
/** 近无损质量 */
|
||||||
|
private static final float QUALITY_NEAR_LOSSLESS = 0.95f;
|
||||||
|
/** 高压缩质量 */
|
||||||
|
private static final float QUALITY_HIGH = 0.75f;
|
||||||
|
|
||||||
|
private ImageCompressUtil() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 压缩头像,失败/无收益时一律返回原字节,保证调用方总能拿到可用数据。
|
||||||
|
*
|
||||||
|
* @param srcBytes 原图字节(非空)
|
||||||
|
* @param ext 小写扩展名(不含点),用于判定格式分支
|
||||||
|
* @return 压缩后的字节;无需/无法压缩时为原字节
|
||||||
|
*/
|
||||||
|
public static byte[] compressAvatar(byte[] srcBytes, String ext) {
|
||||||
|
if (srcBytes == null || srcBytes.length == 0) {
|
||||||
|
throw new DException("头像内容为空");
|
||||||
|
}
|
||||||
|
String type = ext == null ? "" : ext.toLowerCase().replaceFirst("^\\.", "");
|
||||||
|
// 动图重编码丢帧,原样返回
|
||||||
|
if ("gif".equals(type)) {
|
||||||
|
return srcBytes;
|
||||||
|
}
|
||||||
|
BufferedImage image;
|
||||||
|
try {
|
||||||
|
image = ImageIO.read(new ByteArrayInputStream(srcBytes));
|
||||||
|
} catch (Exception e) {
|
||||||
|
image = null;
|
||||||
|
}
|
||||||
|
// webp/heic/CMYK jpg 等解不动:原样返回(前置已校验大小上限)
|
||||||
|
if (image == null) {
|
||||||
|
return srcBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean big = srcBytes.length > BIG_THRESHOLD;
|
||||||
|
float quality = big ? QUALITY_HIGH : QUALITY_NEAR_LOSSLESS;
|
||||||
|
int maxSide = big ? BIG_MAX_SIDE : Integer.MAX_VALUE;
|
||||||
|
boolean lossyFormat = "jpg".equals(type) || "jpeg".equals(type);
|
||||||
|
int w = image.getWidth();
|
||||||
|
int h = image.getHeight();
|
||||||
|
|
||||||
|
// png/bmp 无质量参数:不需要缩放就不重编码(重编码无收益甚至变大)
|
||||||
|
if (!lossyFormat && Math.max(w, h) <= maxSide) {
|
||||||
|
return srcBytes;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String targetType = lossyFormat ? ImgUtil.IMAGE_TYPE_JPEG
|
||||||
|
: ("bmp".equals(type) ? ImgUtil.IMAGE_TYPE_BMP : ImgUtil.IMAGE_TYPE_PNG);
|
||||||
|
Img img = Img.from(image)
|
||||||
|
.setTargetImageType(targetType)
|
||||||
|
.setQuality(quality);
|
||||||
|
if (lossyFormat) {
|
||||||
|
// JPEG 无透明通道,透明区域统一铺白底
|
||||||
|
img.setBackgroundColor(Color.WHITE);
|
||||||
|
}
|
||||||
|
if (Math.max(w, h) > maxSide) {
|
||||||
|
double ratio = maxSide / (double) Math.max(w, h);
|
||||||
|
img.scale(Math.max(1, (int) Math.round(w * ratio)),
|
||||||
|
Math.max(1, (int) Math.round(h * ratio)));
|
||||||
|
}
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
img.write(out);
|
||||||
|
byte[] compressed = out.toByteArray();
|
||||||
|
// 无收益(压完更大)时保留原字节
|
||||||
|
return (compressed.length == 0 || compressed.length >= srcBytes.length) ? srcBytes : compressed;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return srcBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user