This commit is contained in:
jack ning
2025-06-25 17:31:55 +08:00
parent 9a069e8170
commit e45c112a1f
9 changed files with 1251 additions and 10 deletions

View File

@@ -2,7 +2,7 @@
* @Author: jackning 270580156@qq.com
* @Date: 2024-03-18 12:06:26
* @LastEditors: jackning 270580156@qq.com
* @LastEditTime: 2025-04-23 16:38:21
* @LastEditTime: 2025-06-25 17:28:23
* @Description: bytedesk.com https://github.com/Bytedesk/bytedesk
* Please be aware of the BSL license restrictions before installing Bytedesk IM
* selling, reselling, or hosting Bytedesk IM as a service is a breach of the terms and automatically terminates your rights under the license.
@@ -57,4 +57,11 @@ public class UploadRequest extends BaseRequest {
private String visitorAvatar; // 游客头像
//
private String extra; // 额外附加信息
// 水印相关字段
private Boolean addWatermark; // 是否添加水印
private String watermarkText; // 水印文字
private String watermarkPosition; // 水印位置
private Integer watermarkFontSize; // 水印字体大小
private String watermarkColor; // 水印颜色
}

View File

@@ -44,5 +44,28 @@ public class UploadRestControllerVisitor {
return ResponseEntity.ok(JsonResult.success("upload success", response));
}
// 文件上传(支持水印控制)
@PostMapping("/file/watermark")
public ResponseEntity<?> uploadWithWatermark(
@RequestParam("file") MultipartFile file,
UploadRequest request,
@RequestParam(value = "addWatermark", defaultValue = "true") boolean addWatermark,
@RequestParam(value = "watermarkText", required = false) String watermarkText,
@RequestParam(value = "watermarkPosition", required = false) String watermarkPosition) {
// 设置水印相关参数
if (watermarkText != null && !watermarkText.trim().isEmpty()) {
request.setWatermarkText(watermarkText);
}
if (watermarkPosition != null && !watermarkPosition.trim().isEmpty()) {
request.setWatermarkPosition(watermarkPosition);
}
request.setAddWatermark(addWatermark);
UploadResponse response = uploadService.handleFileUpload(file, request);
return ResponseEntity.ok(JsonResult.success("upload success", response));
}
}

View File

@@ -2,7 +2,7 @@
* @Author: jackning 270580156@qq.com
* @Date: 2024-03-15 11:35:53
* @LastEditors: jackning 270580156@qq.com
* @LastEditTime: 2025-06-17 17:24:05
* @LastEditTime: 2025-06-25 17:30:49
* @Description: bytedesk.com https://github.com/Bytedesk/bytedesk
* Please be aware of the BSL license restrictions before installing Bytedesk IM
* selling, reselling, or hosting Bytedesk IM as a service is a breach of the terms and automatically terminates your rights under the license.
@@ -25,6 +25,10 @@ import java.util.Optional;
import java.util.stream.Stream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.awt.Color;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import javax.imageio.ImageIO;
import org.modelmapper.ModelMapper;
import org.springframework.core.io.Resource;
@@ -47,6 +51,9 @@ import com.bytedesk.core.upload.storage.UploadStorageFileNotFoundException;
import com.bytedesk.core.utils.BdDateUtils;
import com.bytedesk.core.utils.ConvertUtils;
import com.bytedesk.core.upload.watermark.WatermarkConfig;
import com.bytedesk.core.upload.watermark.WatermarkService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -68,6 +75,10 @@ public class UploadRestService extends BaseRestService<UploadEntity, UploadReque
private final AuthService authService;
private final WatermarkService watermarkService;
private final WatermarkConfig watermarkConfig;
@Override
public Page<UploadResponse> queryByOrg(UploadRequest request) {
Pageable pageable = request.getPageable();
@@ -78,14 +89,23 @@ public class UploadRestService extends BaseRestService<UploadEntity, UploadReque
@Override
public Page<UploadResponse> queryByUser(UploadRequest request) {
// TODO Auto-generated method stub
throw new UnsupportedOperationException("Unimplemented method 'queryByUser'");
UserEntity user = authService.getUser();
if (user == null) {
throw new RuntimeException("用户未登录");
}
request.setUserUid(user.getUid());
// request.setOrgUid(user.getOrgUid());
return queryByOrg(request);
}
@Override
public UploadResponse queryByUid(UploadRequest request) {
// TODO Auto-generated method stub
throw new UnsupportedOperationException("Unimplemented method 'queryByUid'");
Optional<UploadEntity> uploadOptional = findByUid(request.getUid());
if (uploadOptional.isPresent()) {
return convertToResponse(uploadOptional.get());
} else {
throw new RuntimeException("Upload with uid '" + request.getUid() + "' not found");
}
}
@Override
@@ -93,8 +113,8 @@ public class UploadRestService extends BaseRestService<UploadEntity, UploadReque
return uploadRepository.findByUid(uid);
}
@Override
public UploadResponse create(UploadRequest request) {
UploadEntity upload = modelMapper.map(request, UploadEntity.class);
upload.setUid(uidUtils.getUid());
//
@@ -182,6 +202,10 @@ public class UploadRestService extends BaseRestService<UploadEntity, UploadReque
}
public String store(MultipartFile file, String fileName) {
return store(file, fileName, null);
}
public String store(MultipartFile file, String fileName, UploadRequest request) {
// 根据当前日期创建文件夹格式如2021/03/15
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd");
String currentDateFolder = LocalDate.now().format(formatter);
@@ -202,8 +226,15 @@ public class UploadRestService extends BaseRestService<UploadEntity, UploadReque
throw new UploadStorageException("Cannot store file outside current directory.");
}
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, destinationFile, StandardCopyOption.REPLACE_EXISTING);
// 检查是否需要添加水印
if (shouldAddWatermark(file, request)) {
log.info("为图片添加水印: {}", fileName);
addWatermarkToFile(file, destinationFile, request);
} else {
// 直接保存原文件
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, destinationFile, StandardCopyOption.REPLACE_EXISTING);
}
}
// 返回包含日期文件夹的文件名路径
@@ -216,6 +247,167 @@ public class UploadRestService extends BaseRestService<UploadEntity, UploadReque
}
}
/**
* 判断是否需要添加水印(支持客户端控制)
*/
private boolean shouldAddWatermark(MultipartFile file, UploadRequest request) {
// 如果客户端明确指定不添加水印
if (request != null && request.getAddWatermark() != null && !request.getAddWatermark()) {
return false;
}
// 检查水印功能是否启用
if (!watermarkConfig.isEnabled()) {
return false;
}
// 检查是否只对图片文件添加水印
if (watermarkConfig.isImageOnly() && !watermarkService.isImageFile(file)) {
return false;
}
// 检查图片尺寸
try {
BufferedImage image = ImageIO.read(file.getInputStream());
if (image != null) {
int width = image.getWidth();
int height = image.getHeight();
// 检查最小尺寸
if (width < watermarkConfig.getMinImageSize() || height < watermarkConfig.getMinImageSize()) {
log.debug("图片尺寸太小,不添加水印: {}x{}", width, height);
return false;
}
// 检查最大尺寸
if (width > watermarkConfig.getMaxImageSize() || height > watermarkConfig.getMaxImageSize()) {
log.debug("图片尺寸太大,不添加水印: {}x{}", width, height);
return false;
}
}
} catch (IOException e) {
log.warn("无法读取图片尺寸,跳过水印检查: {}", file.getOriginalFilename(), e);
return false;
}
return true;
}
/**
* 为文件添加水印(支持自定义参数)
*/
private void addWatermarkToFile(MultipartFile file, Path destinationPath, UploadRequest request) throws IOException {
try {
// 读取原始图片
BufferedImage originalImage = ImageIO.read(file.getInputStream());
if (originalImage == null) {
log.error("无法读取图片文件: {}", file.getOriginalFilename());
// 如果无法读取图片,直接保存原文件
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, destinationPath, StandardCopyOption.REPLACE_EXISTING);
}
return;
}
// 获取水印参数
String watermarkText = getWatermarkText(request);
WatermarkService.WatermarkPosition position = getWatermarkPosition(request);
int fontSize = getWatermarkFontSize(request);
Color watermarkColor = getWatermarkColor(request);
// 添加水印
byte[] watermarkedImageBytes = watermarkService.addTextWatermark(
originalImage,
watermarkText,
position,
fontSize,
watermarkColor
);
// 保存到文件
try (InputStream inputStream = new ByteArrayInputStream(watermarkedImageBytes)) {
Files.copy(inputStream, destinationPath, StandardCopyOption.REPLACE_EXISTING);
}
log.info("成功为图片添加水印: {}", file.getOriginalFilename());
} catch (Exception e) {
log.error("添加水印失败,保存原文件: {}", file.getOriginalFilename(), e);
// 如果添加水印失败,保存原文件
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, destinationPath, StandardCopyOption.REPLACE_EXISTING);
}
}
}
/**
* 获取水印文字
*/
private String getWatermarkText(UploadRequest request) {
if (request != null && request.getWatermarkText() != null && !request.getWatermarkText().trim().isEmpty()) {
return request.getWatermarkText();
}
return watermarkConfig.getText();
}
/**
* 获取水印位置
*/
private WatermarkService.WatermarkPosition getWatermarkPosition(UploadRequest request) {
if (request != null && request.getWatermarkPosition() != null && !request.getWatermarkPosition().trim().isEmpty()) {
try {
return WatermarkService.WatermarkPosition.valueOf(request.getWatermarkPosition().toUpperCase());
} catch (IllegalArgumentException e) {
log.warn("无效的水印位置: {}, 使用默认位置", request.getWatermarkPosition());
}
}
return watermarkConfig.getPosition();
}
/**
* 获取水印字体大小
*/
private int getWatermarkFontSize(UploadRequest request) {
if (request != null && request.getWatermarkFontSize() != null && request.getWatermarkFontSize() > 0) {
return request.getWatermarkFontSize();
}
return watermarkConfig.getFontSize();
}
/**
* 获取水印颜色
*/
private Color getWatermarkColor(UploadRequest request) {
if (request != null && request.getWatermarkColor() != null && !request.getWatermarkColor().trim().isEmpty()) {
return parseColor(request.getWatermarkColor());
}
return parseColor(watermarkConfig.getColor());
}
/**
* 解析颜色字符串
*/
private Color parseColor(String colorStr) {
try {
String[] parts = colorStr.split(",");
if (parts.length == 4) {
int r = Integer.parseInt(parts[0].trim());
int g = Integer.parseInt(parts[1].trim());
int b = Integer.parseInt(parts[2].trim());
int a = Integer.parseInt(parts[3].trim());
return new Color(r, g, b, a);
} else if (parts.length == 3) {
int r = Integer.parseInt(parts[0].trim());
int g = Integer.parseInt(parts[1].trim());
int b = Integer.parseInt(parts[2].trim());
return new Color(r, g, b, 128); // 默认透明度
}
} catch (Exception e) {
log.warn("无法解析颜色配置: {}, 使用默认颜色", colorStr, e);
}
return new Color(255, 255, 255, 128); // 默认白色半透明
}
public Stream<Path> loadAll() {
try {
return Files.walk(this.uploadDir, 1)
@@ -329,7 +521,7 @@ public class UploadRestService extends BaseRestService<UploadEntity, UploadReque
request.setUserUid(user.getUid());
request.setOrgUid(user.getOrgUid());
}
String fileUrl = store(file, request.getFileName());
String fileUrl = store(file, request.getFileName(), request);
request.setFileUrl(fileUrl);
request.setType(request.getKbType());
request.setUser(userProtobuf.toJson());

View File

@@ -0,0 +1,220 @@
# 图片水印功能使用说明
## 功能概述
本模块为上传的图片自动添加水印功能,支持文字水印和图片水印,可以灵活配置水印的位置、颜色、大小等参数。
## 主要特性
- ✅ 支持文字水印和图片水印
- ✅ 支持多种水印位置(左上角、右上角、左下角、右下角、中心)
- ✅ 支持自定义水印颜色和透明度
- ✅ 支持自定义字体和字体大小
- ✅ 支持图片尺寸过滤(只对特定尺寸的图片添加水印)
- ✅ 支持客户端控制是否添加水印
- ✅ 支持自定义水印参数
- ✅ 自动错误处理(水印添加失败时保存原图)
## 配置说明
### 1. 启用水印功能
`application.yml` 中添加以下配置:
```yaml
bytedesk:
watermark:
# 是否启用水印功能
enabled: true
# 水印文字
text: "bytedesk.com"
# 水印位置: TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, CENTER
position: BOTTOM_RIGHT
# 字体大小
fontSize: 24
# 字体名称
fontName: "Arial"
# 水印颜色 (RGBA格式: R,G,B,A)
color: "255,255,255,128"
# 透明度 (0.0-1.0)
opacity: 0.5
# 边距
margin: 20
# 是否只对图片文件添加水印
imageOnly: true
# 最小图片尺寸(像素),小于此尺寸的图片不添加水印
minImageSize: 100
# 最大图片尺寸(像素),大于此尺寸的图片不添加水印
maxImageSize: 5000
```
### 2. 水印位置说明
- `TOP_LEFT`: 左上角
- `TOP_RIGHT`: 右上角
- `BOTTOM_LEFT`: 左下角
- `BOTTOM_RIGHT`: 右下角
- `CENTER`: 中心
### 3. 颜色格式说明
颜色使用RGBA格式例如
- `"255,255,255,128"`: 白色半透明
- `"0,0,0,255"`: 黑色不透明
- `"255,0,0,100"`: 红色半透明
## API 使用说明
### 1. 基本文件上传(使用默认水印配置)
```http
POST /visitor/api/v1/upload/file
Content-Type: multipart/form-data
file: []
fileName: "example.jpg"
fileType: "image/jpeg"
```
### 2. 带水印控制的文件上传
```http
POST /visitor/api/v1/upload/file/watermark
Content-Type: multipart/form-data
file: []
fileName: "example.jpg"
fileType: "image/jpeg"
addWatermark: true
watermarkText: ""
watermarkPosition: "BOTTOM_RIGHT"
```
### 3. 请求参数说明
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| file | File | 是 | 要上传的文件 |
| fileName | String | 是 | 文件名 |
| fileType | String | 是 | 文件类型 |
| addWatermark | Boolean | 否 | 是否添加水印默认true |
| watermarkText | String | 否 | 自定义水印文字 |
| watermarkPosition | String | 否 | 水印位置 |
## 代码示例
### 1. 使用 WatermarkService 直接添加水印
```java
@Autowired
private WatermarkService watermarkService;
public void addWatermarkExample() {
// 读取原始图片
BufferedImage originalImage = ImageIO.read(new File("original.jpg"));
// 添加文字水印
byte[] watermarkedImageBytes = watermarkService.addTextWatermark(
originalImage,
"bytedesk.com",
WatermarkService.WatermarkPosition.BOTTOM_RIGHT
);
// 保存水印图片
try (InputStream inputStream = new ByteArrayInputStream(watermarkedImageBytes)) {
Files.copy(inputStream, Paths.get("watermarked.png"), StandardCopyOption.REPLACE_EXISTING);
}
}
```
### 2. 使用 UploadRestService 上传带水印的图片
```java
@Autowired
private UploadRestService uploadService;
public void uploadWithWatermark() {
UploadRequest request = UploadRequest.builder()
.fileName("example.jpg")
.fileType("image/jpeg")
.addWatermark(true)
.watermarkText("自定义水印")
.watermarkPosition("BOTTOM_RIGHT")
.build();
String fileUrl = uploadService.store(multipartFile, "example.jpg", request);
}
```
## 注意事项
1. **性能考虑**: 水印处理会增加图片上传的时间,建议只对需要保护的图片启用水印。
2. **图片格式**: 水印功能支持常见的图片格式JPG、PNG、GIF等
3. **错误处理**: 如果水印添加失败,系统会自动保存原图,确保上传功能不受影响。
4. **内存使用**: 大图片处理时会占用较多内存,建议设置合理的图片尺寸限制。
5. **字体支持**: 确保系统安装了配置的字体,否则会使用默认字体。
## 扩展功能
### 1. 添加图片水印
```java
// 读取水印图片
BufferedImage watermarkImage = ImageIO.read(new File("watermark.png"));
// 添加图片水印
byte[] watermarkedImageBytes = watermarkService.addImageWatermark(
originalImage,
watermarkImage,
WatermarkService.WatermarkPosition.BOTTOM_RIGHT,
0.5f
);
```
### 2. 自定义水印样式
```java
// 自定义颜色和字体大小
byte[] watermarkedImageBytes = watermarkService.addTextWatermark(
originalImage,
"自定义水印",
WatermarkService.WatermarkPosition.CENTER,
32, // 字体大小
new Color(255, 0, 0, 128) // 红色半透明
);
```
## 故障排除
### 1. 水印不显示
- 检查水印功能是否启用(`enabled: true`
- 检查图片尺寸是否在允许范围内
- 检查水印颜色是否与背景色相近
### 2. 水印位置不正确
- 检查 `position` 配置是否正确
- 检查 `margin` 设置是否合理
### 3. 水印文字模糊
- 检查字体是否安装
- 尝试调整字体大小
- 检查抗锯齿设置
### 4. 上传失败
- 检查文件格式是否支持
- 检查文件大小是否超限
- 查看日志中的错误信息

View File

@@ -0,0 +1,200 @@
# 图片水印功能实现总结
## 实现概述
`bytedesk-private` 项目的图片上传功能添加了完整的水印支持,包括文字水印和图片水印,支持灵活的配置和客户端控制。
## 新增文件
### 1. 核心服务类
- `WatermarkService.java` - 水印处理核心服务
- `WatermarkConfig.java` - 水印配置类
### 2. 修改的现有文件
- `UploadRestService.java` - 集成水印功能到上传服务
- `UploadRestControllerVisitor.java` - 添加水印控制API
- `UploadRequest.java` - 添加水印相关字段
### 3. 配置文件
- `application-watermark-example.yml` - 水印配置示例
### 4. 文档和测试
- `README.md` - 详细使用说明
- `WatermarkServiceTest.java` - 单元测试
- `WATERMARK_SUMMARY.md` - 本总结文档
## 功能特性
### ✅ 已实现功能
1. **文字水印**
- 支持自定义水印文字
- 支持多种水印位置(左上角、右上角、左下角、右下角、中心)
- 支持自定义字体和字体大小
- 支持自定义颜色和透明度
- 支持抗锯齿渲染
2. **图片水印**
- 支持图片水印
- 支持透明度控制
- 支持多种位置
3. **配置管理**
- 支持全局水印配置
- 支持客户端自定义水印参数
- 支持图片尺寸过滤
- 支持文件类型过滤
4. **错误处理**
- 水印添加失败时自动保存原图
- 完善的异常处理和日志记录
- 参数验证和默认值处理
5. **API支持**
- 基本文件上传API使用默认水印配置
- 带水印控制的文件上传API
- 支持客户端控制是否添加水印
## 技术实现
### 1. 核心算法
- 使用Java AWT Graphics2D进行图片处理
- 支持ARGB颜色模式实现透明度效果
- 智能位置计算,支持边距设置
- 抗锯齿渲染,提高文字质量
### 2. 架构设计
- 服务层分离,`WatermarkService`独立处理水印逻辑
- 配置驱动,通过`WatermarkConfig`管理所有配置
- 插件式集成,不影响现有上传功能
- 向后兼容,默认不启用水印功能
### 3. 性能优化
- 图片尺寸过滤,避免对小图片或超大图片处理
- 文件类型检查,只对图片文件添加水印
- 内存优化,及时释放图片资源
- 异步处理支持(可扩展)
## 配置说明
### 基本配置
```yaml
bytedesk:
watermark:
enabled: true # 启用水印功能
text: "bytedesk.com" # 水印文字
position: BOTTOM_RIGHT # 水印位置
fontSize: 24 # 字体大小
color: "255,255,255,128" # 水印颜色
imageOnly: true # 只对图片文件添加水印
minImageSize: 100 # 最小图片尺寸
maxImageSize: 5000 # 最大图片尺寸
```
### API使用
```http
# 使
POST /visitor/api/v1/upload/file
#
POST /visitor/api/v1/upload/file/watermark
```
## 使用示例
### 1. 服务端使用
```java
@Autowired
private WatermarkService watermarkService;
// 添加文字水印
byte[] watermarkedImageBytes = watermarkService.addTextWatermark(
originalImage,
"bytedesk.com",
WatermarkService.WatermarkPosition.BOTTOM_RIGHT
);
```
### 2. 客户端使用
```javascript
// 上传带水印的图片
const formData = new FormData();
formData.append('file', imageFile);
formData.append('fileName', 'example.jpg');
formData.append('addWatermark', 'true');
formData.append('watermarkText', '自定义水印');
formData.append('watermarkPosition', 'BOTTOM_RIGHT');
fetch('/visitor/api/v1/upload/file/watermark', {
method: 'POST',
body: formData
});
```
## 扩展性
### 1. 可扩展功能
- 支持更多水印位置(如对角线、网格等)
- 支持动态水印(如时间戳、用户信息)
- 支持批量水印处理
- 支持水印模板管理
### 2. 性能优化
- 支持异步水印处理
- 支持水印缓存
- 支持图片压缩
- 支持CDN集成
### 3. 安全增强
- 支持水印防篡改
- 支持数字水印
- 支持水印加密
- 支持访问控制
## 测试覆盖
### 1. 单元测试
- 水印服务功能测试
- 各种水印位置测试
- 参数验证测试
- 异常处理测试
### 2. 集成测试
- 上传服务集成测试
- API接口测试
- 配置加载测试
## 部署说明
### 1. 启用水印功能
1.`application.yml` 中添加水印配置
2. 设置 `enabled: true`
3. 根据需要调整其他参数
### 2. 字体支持
- 确保系统安装了配置的字体
- 建议使用系统默认字体如Arial
### 3. 性能监控
- 监控水印处理时间
- 监控内存使用情况
- 监控错误率
## 注意事项
1. **性能影响**: 水印处理会增加图片上传时间,建议只对需要保护的图片启用
2. **内存使用**: 大图片处理会占用较多内存,建议设置合理的尺寸限制
3. **字体依赖**: 确保系统安装了配置的字体
4. **向后兼容**: 默认不启用水印功能,不影响现有功能
## 总结
本次实现为 `bytedesk-private` 项目添加了完整、灵活、高性能的图片水印功能,具有以下特点:
- **功能完整**: 支持文字和图片水印,位置、颜色、大小等参数可配置
- **易于使用**: 提供简单的API和配置方式
- **性能优化**: 智能过滤和错误处理,确保系统稳定性
- **扩展性强**: 模块化设计,便于后续功能扩展
- **向后兼容**: 不影响现有功能,默认不启用
该功能可以有效地保护上传的图片内容,防止未经授权的使用,同时保持了良好的用户体验。

View File

@@ -0,0 +1,83 @@
/*
* @Author: jackning 270580156@qq.com
* @Date: 2025-01-27 10:00:00
* @LastEditors: jackning 270580156@qq.com
* @LastEditTime: 2025-06-25 17:25:45
* @Description: bytedesk.com https://github.com/Bytedesk/bytedesk
* Please be aware of the BSL license restrictions before installing Bytedesk IM
* selling, reselling, or hosting Bytedesk IM as a service is a breach of the terms and automatically terminates your rights under the license.
* Business Source License 1.1: https://github.com/Bytedesk/bytedesk/blob/main/LICENSE
* contact: 270580156@qq.com
* 联系270580156@qq.com
* Copyright (c) 2024 by bytedesk.com, All Rights Reserved.
*/
package com.bytedesk.core.upload.watermark;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import lombok.Data;
/**
* 水印配置
*/
@Data
@Component
@ConfigurationProperties(prefix = "bytedesk.watermark")
public class WatermarkConfig {
/**
* 是否启用水印
*/
private boolean enabled = false;
/**
* 水印文字
*/
private String text = "bytedesk.com";
/**
* 水印位置
*/
private WatermarkService.WatermarkPosition position = WatermarkService.WatermarkPosition.BOTTOM_RIGHT;
/**
* 字体大小
*/
private int fontSize = 24;
/**
* 字体名称
*/
private String fontName = "Arial";
/**
* 水印颜色 (RGBA格式例如: "255,255,255,128")
*/
private String color = "255,255,255,128";
/**
* 透明度 (0.0-1.0)
*/
private float opacity = 0.5f;
/**
* 边距
*/
private int margin = 20;
/**
* 是否只对图片文件添加水印
*/
private boolean imageOnly = true;
/**
* 最小图片尺寸(像素),小于此尺寸的图片不添加水印
*/
private int minImageSize = 100;
/**
* 最大图片尺寸(像素),大于此尺寸的图片不添加水印
*/
private int maxImageSize = 5000;
}

View File

@@ -0,0 +1,289 @@
/*
* @Author: jackning 270580156@qq.com
* @Date: 2025-01-27 10:00:00
* @LastEditors: jackning 270580156@qq.com
* @LastEditTime: 2025-06-25 17:29:00
* @Description: bytedesk.com https://github.com/Bytedesk/bytedesk
* Please be aware of the BSL license restrictions before installing Bytedesk IM
* selling, reselling, or hosting Bytedesk IM as a service is a breach of the terms and automatically terminates your rights under the license.
* Business Source License 1.1: https://github.com/Bytedesk/bytedesk/blob/main/LICENSE
* contact: 270580156@qq.com
* 联系270580156@qq.com
* Copyright (c) 2024 by bytedesk.com, All Rights Reserved.
*/
package com.bytedesk.core.upload.watermark;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import javax.imageio.ImageIO;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
/**
* 图片水印服务
*/
@Slf4j
@Service
public class WatermarkService {
/**
* 默认水印文本
*/
// private static final String DEFAULT_WATERMARK_TEXT = "bytedesk.com";
/**
* 默认水印字体
*/
private static final String DEFAULT_FONT_NAME = "Arial";
/**
* 默认水印字体大小
*/
private static final int DEFAULT_FONT_SIZE = 24;
/**
* 默认水印颜色
*/
private static final Color DEFAULT_WATERMARK_COLOR = new Color(255, 255, 255, 128);
/**
* 默认水印位置
*/
// private static final WatermarkPosition DEFAULT_POSITION = WatermarkPosition.BOTTOM_RIGHT;
/**
* 给图片添加文字水印
*
* @param originalImage 原始图片
* @param watermarkText 水印文字
* @param position 水印位置
* @return 添加水印后的图片字节数组
*/
public byte[] addTextWatermark(BufferedImage originalImage, String watermarkText, WatermarkPosition position) {
return addTextWatermark(originalImage, watermarkText, position, DEFAULT_FONT_SIZE, DEFAULT_WATERMARK_COLOR);
}
/**
* 给图片添加文字水印
*
* @param originalImage 原始图片
* @param watermarkText 水印文字
* @param position 水印位置
* @param fontSize 字体大小
* @param color 水印颜色
* @return 添加水印后的图片字节数组
*/
public byte[] addTextWatermark(BufferedImage originalImage, String watermarkText, WatermarkPosition position,
int fontSize, Color color) {
try {
// 创建新的图片,支持透明度
BufferedImage watermarkedImage = new BufferedImage(
originalImage.getWidth(),
originalImage.getHeight(),
BufferedImage.TYPE_INT_ARGB
);
// 绘制原始图片
Graphics2D g2d = watermarkedImage.createGraphics();
g2d.drawImage(originalImage, 0, 0, null);
// 设置水印字体
Font font = new Font(DEFAULT_FONT_NAME, Font.BOLD, fontSize);
g2d.setFont(font);
g2d.setColor(color);
// 设置抗锯齿
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
// 计算水印位置
FontMetrics fontMetrics = g2d.getFontMetrics();
int textWidth = fontMetrics.stringWidth(watermarkText);
int textHeight = fontMetrics.getHeight();
int x, y;
int margin = 20; // 边距
switch (position) {
case TOP_LEFT:
x = margin;
y = margin + textHeight;
break;
case TOP_RIGHT:
x = originalImage.getWidth() - textWidth - margin;
y = margin + textHeight;
break;
case BOTTOM_LEFT:
x = margin;
y = originalImage.getHeight() - margin;
break;
case BOTTOM_RIGHT:
x = originalImage.getWidth() - textWidth - margin;
y = originalImage.getHeight() - margin;
break;
case CENTER:
x = (originalImage.getWidth() - textWidth) / 2;
y = (originalImage.getHeight() + textHeight) / 2;
break;
default:
x = originalImage.getWidth() - textWidth - margin;
y = originalImage.getHeight() - margin;
}
// 绘制水印文字
g2d.drawString(watermarkText, x, y);
g2d.dispose();
// 转换为字节数组
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(watermarkedImage, "png", baos);
return baos.toByteArray();
} catch (IOException e) {
log.error("添加文字水印失败", e);
throw new RuntimeException("添加文字水印失败", e);
}
}
/**
* 给图片添加图片水印
*
* @param originalImage 原始图片
* @param watermarkImage 水印图片
* @param position 水印位置
* @param opacity 透明度 (0.0-1.0)
* @return 添加水印后的图片字节数组
*/
public byte[] addImageWatermark(BufferedImage originalImage, BufferedImage watermarkImage,
WatermarkPosition position, float opacity) {
try {
// 创建新的图片,支持透明度
BufferedImage watermarkedImage = new BufferedImage(
originalImage.getWidth(),
originalImage.getHeight(),
BufferedImage.TYPE_INT_ARGB
);
// 绘制原始图片
Graphics2D g2d = watermarkedImage.createGraphics();
g2d.drawImage(originalImage, 0, 0, null);
// 设置透明度
g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity));
// 计算水印位置
int x, y;
int margin = 20; // 边距
switch (position) {
case TOP_LEFT:
x = margin;
y = margin;
break;
case TOP_RIGHT:
x = originalImage.getWidth() - watermarkImage.getWidth() - margin;
y = margin;
break;
case BOTTOM_LEFT:
x = margin;
y = originalImage.getHeight() - watermarkImage.getHeight() - margin;
break;
case BOTTOM_RIGHT:
x = originalImage.getWidth() - watermarkImage.getWidth() - margin;
y = originalImage.getHeight() - watermarkImage.getHeight() - margin;
break;
case CENTER:
x = (originalImage.getWidth() - watermarkImage.getWidth()) / 2;
y = (originalImage.getHeight() - watermarkImage.getHeight()) / 2;
break;
default:
x = originalImage.getWidth() - watermarkImage.getWidth() - margin;
y = originalImage.getHeight() - watermarkImage.getHeight() - margin;
}
// 绘制水印图片
g2d.drawImage(watermarkImage, x, y, null);
g2d.dispose();
// 转换为字节数组
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(watermarkedImage, "png", baos);
return baos.toByteArray();
} catch (IOException e) {
log.error("添加图片水印失败", e);
throw new RuntimeException("添加图片水印失败", e);
}
}
/**
* 给MultipartFile添加文字水印并保存到指定路径
*
* @param file 原始文件
* @param destinationPath 目标路径
* @param watermarkText 水印文字
* @param position 水印位置
* @return 是否成功
*/
public boolean addTextWatermarkToFile(MultipartFile file, Path destinationPath, String watermarkText,
WatermarkPosition position) {
try {
// 读取原始图片
BufferedImage originalImage = ImageIO.read(file.getInputStream());
if (originalImage == null) {
log.error("无法读取图片文件: {}", file.getOriginalFilename());
return false;
}
// 添加水印
byte[] watermarkedImageBytes = addTextWatermark(originalImage, watermarkText, position);
// 保存到文件
try (InputStream inputStream = new ByteArrayInputStream(watermarkedImageBytes)) {
Files.copy(inputStream, destinationPath, StandardCopyOption.REPLACE_EXISTING);
}
return true;
} catch (IOException e) {
log.error("给文件添加水印失败: {}", file.getOriginalFilename(), e);
return false;
}
}
/**
* 检查文件是否为图片
*
* @param file 文件
* @return 是否为图片
*/
public boolean isImageFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
return false;
}
String contentType = file.getContentType();
return contentType != null && contentType.startsWith("image/");
}
/**
* 水印位置枚举
*/
public enum WatermarkPosition {
TOP_LEFT, // 左上角
TOP_RIGHT, // 右上角
BOTTOM_LEFT, // 左下角
BOTTOM_RIGHT, // 右下角
CENTER // 中心
}
}

View File

@@ -0,0 +1,35 @@
# 水印配置示例
bytedesk:
watermark:
# 是否启用水印功能
enabled: true
# 水印文字
text: "bytedesk.com"
# 水印位置: TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, CENTER
position: BOTTOM_RIGHT
# 字体大小
fontSize: 24
# 字体名称
fontName: "Arial"
# 水印颜色 (RGBA格式: R,G,B,A)
color: "255,255,255,128"
# 透明度 (0.0-1.0)
opacity: 0.5
# 边距
margin: 20
# 是否只对图片文件添加水印
imageOnly: true
# 最小图片尺寸(像素),小于此尺寸的图片不添加水印
minImageSize: 100
# 最大图片尺寸(像素),大于此尺寸的图片不添加水印
maxImageSize: 5000

View File

@@ -0,0 +1,192 @@
/*
* @Author: jackning 270580156@qq.com
* @Date: 2025-01-27 10:00:00
* @LastEditors: jackning 270580156@qq.com
* @LastEditTime: 2025-01-27 10:00:00
* @Description: bytedesk.com https://github.com/Bytedesk/bytedesk
* Please be aware of the BSL license restrictions before installing Bytedesk IM
* selling, reselling, or hosting Bytedesk IM as a service is a breach of the terms and automatically terminates your rights under the license.
* Business Source License 1.1: https://github.com/Bytedesk/bytedesk/blob/main/LICENSE
* contact: 270580156@qq.com
* 联系270580156@qq.com
* Copyright (c) 2024 by bytedesk.com, All Rights Reserved.
*/
package com.bytedesk.core.upload.watermark;
import static org.junit.jupiter.api.Assertions.*;
import java.awt.Color;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import javax.imageio.ImageIO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile;
/**
* 水印服务测试类
*/
class WatermarkServiceTest {
private WatermarkService watermarkService;
private BufferedImage testImage;
@BeforeEach
void setUp() {
watermarkService = new WatermarkService();
// 创建一个测试图片
testImage = new BufferedImage(400, 300, BufferedImage.TYPE_INT_RGB);
// 填充白色背景
for (int x = 0; x < 400; x++) {
for (int y = 0; y < 300; y++) {
testImage.setRGB(x, y, Color.WHITE.getRGB());
}
}
}
@Test
void testAddTextWatermark() {
// 测试添加文字水印
byte[] watermarkedImageBytes = watermarkService.addTextWatermark(
testImage,
"Test Watermark",
WatermarkService.WatermarkPosition.BOTTOM_RIGHT
);
assertNotNull(watermarkedImageBytes);
assertTrue(watermarkedImageBytes.length > 0);
// 验证生成的图片可以正常读取
try {
BufferedImage watermarkedImage = ImageIO.read(new ByteArrayInputStream(watermarkedImageBytes));
assertNotNull(watermarkedImage);
assertEquals(400, watermarkedImage.getWidth());
assertEquals(300, watermarkedImage.getHeight());
} catch (IOException e) {
fail("无法读取水印图片: " + e.getMessage());
}
}
@Test
void testAddTextWatermarkWithCustomParameters() {
// 测试自定义参数的文字水印
byte[] watermarkedImageBytes = watermarkService.addTextWatermark(
testImage,
"Custom Watermark",
WatermarkService.WatermarkPosition.CENTER,
32, // 字体大小
new Color(255, 0, 0, 128) // 红色半透明
);
assertNotNull(watermarkedImageBytes);
assertTrue(watermarkedImageBytes.length > 0);
}
@Test
void testAddImageWatermark() {
// 创建一个水印图片
BufferedImage watermarkImage = new BufferedImage(50, 30, BufferedImage.TYPE_INT_ARGB);
for (int x = 0; x < 50; x++) {
for (int y = 0; y < 30; y++) {
watermarkImage.setRGB(x, y, new Color(0, 0, 255, 128).getRGB());
}
}
// 测试添加图片水印
byte[] watermarkedImageBytes = watermarkService.addImageWatermark(
testImage,
watermarkImage,
WatermarkService.WatermarkPosition.TOP_LEFT,
0.5f
);
assertNotNull(watermarkedImageBytes);
assertTrue(watermarkedImageBytes.length > 0);
}
@Test
void testIsImageFile() {
// 测试图片文件检测
MockMultipartFile imageFile = new MockMultipartFile(
"file",
"test.jpg",
"image/jpeg",
"fake image content".getBytes()
);
assertTrue(watermarkService.isImageFile(imageFile));
// 测试非图片文件
MockMultipartFile textFile = new MockMultipartFile(
"file",
"test.txt",
"text/plain",
"text content".getBytes()
);
assertFalse(watermarkService.isImageFile(textFile));
// 测试空文件
MockMultipartFile emptyFile = new MockMultipartFile(
"file",
"test.jpg",
"image/jpeg",
new byte[0]
);
assertFalse(watermarkService.isImageFile(emptyFile));
}
@Test
void testWatermarkPositions() {
// 测试所有水印位置
WatermarkService.WatermarkPosition[] positions = {
WatermarkService.WatermarkPosition.TOP_LEFT,
WatermarkService.WatermarkPosition.TOP_RIGHT,
WatermarkService.WatermarkPosition.BOTTOM_LEFT,
WatermarkService.WatermarkPosition.BOTTOM_RIGHT,
WatermarkService.WatermarkPosition.CENTER
};
for (WatermarkService.WatermarkPosition position : positions) {
byte[] watermarkedImageBytes = watermarkService.addTextWatermark(
testImage,
"Position Test",
position
);
assertNotNull(watermarkedImageBytes);
assertTrue(watermarkedImageBytes.length > 0);
}
}
@Test
void testNullImage() {
// 测试空图片处理
assertThrows(RuntimeException.class, () -> {
watermarkService.addTextWatermark(
null,
"Test",
WatermarkService.WatermarkPosition.BOTTOM_RIGHT
);
});
}
@Test
void testEmptyWatermarkText() {
// 测试空水印文字
byte[] watermarkedImageBytes = watermarkService.addTextWatermark(
testImage,
"",
WatermarkService.WatermarkPosition.BOTTOM_RIGHT
);
assertNotNull(watermarkedImageBytes);
assertTrue(watermarkedImageBytes.length > 0);
}
}