383 lines
14 KiB
Java
383 lines
14 KiB
Java
/*
|
||
* 自然写教学资源管理与内容分发系统软件 V1.0
|
||
* service/ResourceService.java - 资源管理业务服务
|
||
*/
|
||
package com.writech.resource.service;
|
||
|
||
import java.util.*;
|
||
import java.util.logging.Logger;
|
||
import java.util.stream.Collectors;
|
||
|
||
/**
|
||
* 资源管理业务服务
|
||
*
|
||
* 负责资源的完整生命周期管理:
|
||
* - 资源元数据CRUD(MySQL)
|
||
* - 文件存储管理(OSS/MinIO对象存储)
|
||
* - 全文索引管理(Elasticsearch)
|
||
* - CDN缓存管理
|
||
* - 版本控制
|
||
* - 数字水印
|
||
*/
|
||
public class ResourceService {
|
||
|
||
private static final Logger logger =
|
||
Logger.getLogger(ResourceService.class.getName());
|
||
|
||
// ============================================================
|
||
// 配置常量
|
||
// ============================================================
|
||
|
||
/** 支持的文件类型及最大大小(MB) */
|
||
private static final Map<String, Integer> ALLOWED_FILE_TYPES = new HashMap<>();
|
||
static {
|
||
ALLOWED_FILE_TYPES.put("application/pdf", 100);
|
||
ALLOWED_FILE_TYPES.put("application/vnd.ms-powerpoint", 200);
|
||
ALLOWED_FILE_TYPES.put("application/vnd.openxmlformats-officedocument.presentationml.presentation", 200);
|
||
ALLOWED_FILE_TYPES.put("image/jpeg", 20);
|
||
ALLOWED_FILE_TYPES.put("image/png", 20);
|
||
ALLOWED_FILE_TYPES.put("image/svg+xml", 10);
|
||
ALLOWED_FILE_TYPES.put("video/mp4", 500);
|
||
ALLOWED_FILE_TYPES.put("audio/mpeg", 50);
|
||
}
|
||
|
||
/** OSS存储桶名称 */
|
||
private static final String OSS_BUCKET = "writech-resources";
|
||
|
||
/** 缩略图存储前缀 */
|
||
private static final String THUMBNAIL_PREFIX = "thumbnails/";
|
||
|
||
/** Elasticsearch索引名称 */
|
||
private static final String ES_INDEX = "writech_resources";
|
||
|
||
// ============================================================
|
||
// 数据模型
|
||
// ============================================================
|
||
|
||
/** 资源版本记录 */
|
||
public static class ResourceVersion {
|
||
private String id;
|
||
private String resourceId;
|
||
private int versionNumber;
|
||
private String fileKey;
|
||
private long fileSize;
|
||
private String changeLog;
|
||
private String operatorId;
|
||
private Date createdAt;
|
||
|
||
public String getId() { return id; }
|
||
public void setId(String id) { this.id = id; }
|
||
public String getResourceId() { return resourceId; }
|
||
public void setResourceId(String rid) { this.resourceId = rid; }
|
||
public int getVersionNumber() { return versionNumber; }
|
||
public void setVersionNumber(int v) { this.versionNumber = v; }
|
||
public String getFileKey() { return fileKey; }
|
||
public void setFileKey(String key) { this.fileKey = key; }
|
||
public String getChangeLog() { return changeLog; }
|
||
public void setChangeLog(String log) { this.changeLog = log; }
|
||
public Date getCreatedAt() { return createdAt; }
|
||
}
|
||
|
||
/** 数字水印配置 */
|
||
public static class WatermarkConfig {
|
||
private String text; // 水印文字(学校名+教师名)
|
||
private float opacity; // 透明度(0.0-1.0)
|
||
private int fontSize; // 字号
|
||
private float rotation; // 旋转角度
|
||
private String position; // 位置:center/bottom-right/tiled
|
||
|
||
public WatermarkConfig(String text) {
|
||
this.text = text;
|
||
this.opacity = 0.15f;
|
||
this.fontSize = 24;
|
||
this.rotation = -30.0f;
|
||
this.position = "tiled";
|
||
}
|
||
|
||
public String getText() { return text; }
|
||
public float getOpacity() { return opacity; }
|
||
public int getFontSize() { return fontSize; }
|
||
public float getRotation() { return rotation; }
|
||
public String getPosition() { return position; }
|
||
}
|
||
|
||
/** STS临时上传凭证 */
|
||
public static class UploadCredential {
|
||
private String accessKeyId;
|
||
private String accessKeySecret;
|
||
private String securityToken;
|
||
private String bucket;
|
||
private String objectKeyPrefix;
|
||
private long expireTimeSeconds;
|
||
|
||
public String getAccessKeyId() { return accessKeyId; }
|
||
public String getAccessKeySecret() { return accessKeySecret; }
|
||
public String getSecurityToken() { return securityToken; }
|
||
public String getBucket() { return bucket; }
|
||
public String getObjectKeyPrefix() { return objectKeyPrefix; }
|
||
public long getExpireTimeSeconds() { return expireTimeSeconds; }
|
||
}
|
||
|
||
// ============================================================
|
||
// 业务方法
|
||
// ============================================================
|
||
|
||
/**
|
||
* 获取STS临时上传凭证
|
||
*
|
||
* 前端使用STS凭证直接上传到OSS,避免文件经过应用服务器。
|
||
* STS凭证限制:仅允许PUT到指定前缀路径,有效期15分钟。
|
||
*
|
||
* @param uploaderId 上传者ID
|
||
* @param fileType 文件MIME类型
|
||
* @return STS临时凭证
|
||
*/
|
||
public UploadCredential getUploadCredential(String uploaderId, String fileType) {
|
||
logger.info(String.format("获取上传凭证: user=%s, type=%s", uploaderId, fileType));
|
||
|
||
// 校验文件类型
|
||
if (!ALLOWED_FILE_TYPES.containsKey(fileType)) {
|
||
throw new IllegalArgumentException("不支持的文件类型: " + fileType);
|
||
}
|
||
|
||
// 生成上传路径前缀:resources/{uploaderId}/{year}/{month}/
|
||
Calendar cal = Calendar.getInstance();
|
||
String prefix = String.format(
|
||
"resources/%s/%d/%02d/",
|
||
uploaderId,
|
||
cal.get(Calendar.YEAR),
|
||
cal.get(Calendar.MONTH) + 1
|
||
);
|
||
|
||
// 调用OSS STS服务获取临时凭证
|
||
// AssumeRoleResponse response = stsClient.assumeRole(
|
||
// roleArn, policy, sessionName, 900 // 15分钟
|
||
// );
|
||
|
||
UploadCredential credential = new UploadCredential();
|
||
// credential.accessKeyId = response.getCredentials().getAccessKeyId();
|
||
// credential.accessKeySecret = response.getCredentials().getAccessKeySecret();
|
||
// credential.securityToken = response.getCredentials().getSecurityToken();
|
||
// credential.bucket = OSS_BUCKET;
|
||
// credential.objectKeyPrefix = prefix;
|
||
// credential.expireTimeSeconds = 900;
|
||
|
||
return credential;
|
||
}
|
||
|
||
/**
|
||
* 创建资源记录(上传完成后调用)
|
||
*
|
||
* @param metadata 资源元数据
|
||
* @return 创建的资源ID
|
||
*/
|
||
public String createResource(Map<String, Object> metadata) {
|
||
String name = (String) metadata.get("name");
|
||
String fileKey = (String) metadata.get("file_key");
|
||
String mimeType = (String) metadata.get("mime_type");
|
||
|
||
logger.info(String.format("创建资源: name=%s, key=%s", name, fileKey));
|
||
|
||
// 生成资源ID
|
||
String resourceId = UUID.randomUUID().toString().replace("-", "");
|
||
|
||
// 自动生成缩略图
|
||
generateThumbnailAsync(resourceId, fileKey, mimeType);
|
||
|
||
// 插入MySQL元数据
|
||
// resourceMapper.insert(resource);
|
||
|
||
// 创建初始版本记录
|
||
createVersion(resourceId, fileKey, "初始版本", (String) metadata.get("uploader_id"));
|
||
|
||
// 同步索引到Elasticsearch
|
||
indexToElasticsearch(resourceId, metadata);
|
||
|
||
logger.info("资源创建成功: id=" + resourceId);
|
||
return resourceId;
|
||
}
|
||
|
||
/**
|
||
* 更新资源(新版本上传)
|
||
*
|
||
* 资源更新不删除旧版本,而是创建新版本记录,
|
||
* 支持版本回滚。更新后需刷新CDN缓存。
|
||
*/
|
||
public void updateResource(String resourceId, Map<String, Object> updateData) {
|
||
logger.info("更新资源: id=" + resourceId);
|
||
|
||
String newFileKey = (String) updateData.get("file_key");
|
||
String changeLog = (String) updateData.get("change_log");
|
||
String operatorId = (String) updateData.get("operator_id");
|
||
|
||
// 创建新版本
|
||
if (newFileKey != null) {
|
||
createVersion(resourceId, newFileKey, changeLog, operatorId);
|
||
}
|
||
|
||
// 更新MySQL元数据
|
||
// resourceMapper.update(resourceId, updateData);
|
||
|
||
// 更新Elasticsearch索引
|
||
updateElasticsearchIndex(resourceId, updateData);
|
||
|
||
// 刷新CDN缓存
|
||
refreshCdnCache(resourceId);
|
||
}
|
||
|
||
/**
|
||
* 创建版本记录
|
||
*/
|
||
private void createVersion(
|
||
String resourceId, String fileKey, String changeLog, String operatorId
|
||
) {
|
||
// 查询当前最大版本号
|
||
// int maxVersion = versionMapper.selectMaxVersion(resourceId);
|
||
|
||
ResourceVersion version = new ResourceVersion();
|
||
version.setId(UUID.randomUUID().toString().replace("-", ""));
|
||
version.setResourceId(resourceId);
|
||
version.setVersionNumber(1); // maxVersion + 1
|
||
version.setFileKey(fileKey);
|
||
version.setChangeLog(changeLog);
|
||
|
||
// versionMapper.insert(version);
|
||
logger.info(String.format(
|
||
"创建版本: resource=%s, version=%d", resourceId, version.getVersionNumber()
|
||
));
|
||
}
|
||
|
||
/**
|
||
* 异步生成缩略图
|
||
*
|
||
* 根据文件类型采用不同策略:
|
||
* - PDF: 渲染第一页为图片
|
||
* - PPT: 提取封面幻灯片
|
||
* - 图片: 直接缩放
|
||
* - 视频: 提取关键帧
|
||
*/
|
||
private void generateThumbnailAsync(String resourceId, String fileKey, String mimeType) {
|
||
// @Async 异步执行
|
||
logger.info(String.format(
|
||
"生成缩略图: resource=%s, type=%s", resourceId, mimeType
|
||
));
|
||
|
||
// 根据MIME类型选择缩略图生成策略
|
||
// if (mimeType.equals("application/pdf")) {
|
||
// PDDocument doc = PDDocument.load(ossClient.getObject(fileKey));
|
||
// PDFRenderer renderer = new PDFRenderer(doc);
|
||
// BufferedImage image = renderer.renderImageWithDPI(0, 150);
|
||
// // 缩放为缩略图尺寸(320x240)
|
||
// BufferedImage thumb = ImageUtils.resize(image, 320, 240);
|
||
// // 上传缩略图到OSS
|
||
// ossClient.putObject(THUMBNAIL_PREFIX + resourceId + ".jpg", thumb);
|
||
// }
|
||
}
|
||
|
||
/**
|
||
* 索引资源到Elasticsearch
|
||
*
|
||
* 索引字段:名称、描述、标签、学科、年级、出版社、类型
|
||
* 支持中文分词(IK分词器)
|
||
*/
|
||
private void indexToElasticsearch(String resourceId, Map<String, Object> metadata) {
|
||
logger.info("索引资源到ES: id=" + resourceId);
|
||
|
||
// Map<String, Object> document = new HashMap<>();
|
||
// document.put("id", resourceId);
|
||
// document.put("name", metadata.get("name"));
|
||
// document.put("description", metadata.get("description"));
|
||
// document.put("tags", metadata.get("tags"));
|
||
// document.put("subject", metadata.get("subject"));
|
||
// document.put("grade", metadata.get("grade"));
|
||
// document.put("publisher", metadata.get("publisher"));
|
||
// document.put("type", metadata.get("type"));
|
||
// document.put("school_id", metadata.get("school_id"));
|
||
// document.put("audit_status", "PENDING");
|
||
// document.put("created_at", new Date());
|
||
|
||
// IndexRequest request = new IndexRequest(ES_INDEX)
|
||
// .id(resourceId)
|
||
// .source(document);
|
||
// elasticsearchClient.index(request);
|
||
}
|
||
|
||
/**
|
||
* 更新Elasticsearch索引
|
||
*/
|
||
private void updateElasticsearchIndex(String resourceId, Map<String, Object> updateData) {
|
||
// UpdateRequest request = new UpdateRequest(ES_INDEX, resourceId)
|
||
// .doc(updateData);
|
||
// elasticsearchClient.update(request);
|
||
}
|
||
|
||
/**
|
||
* 刷新CDN缓存
|
||
*
|
||
* 资源更新后需要刷新CDN节点缓存,确保终端获取最新版本。
|
||
*/
|
||
private void refreshCdnCache(String resourceId) {
|
||
logger.info("刷新CDN缓存: resource=" + resourceId);
|
||
// String cdnUrl = String.format("https://cdn.writech.com/resources/%s", resourceId);
|
||
// cdnClient.refreshObjectCaches(Collections.singletonList(cdnUrl));
|
||
}
|
||
|
||
/**
|
||
* 添加数字水印
|
||
*
|
||
* 下载资源时可选添加数字水印,水印包含学校和教师标识,
|
||
* 用于版权保护和追踪。
|
||
*
|
||
* @param fileBytes 原始文件字节
|
||
* @param config 水印配置
|
||
* @return 添加水印后的文件字节
|
||
*/
|
||
public byte[] addWatermark(byte[] fileBytes, WatermarkConfig config) {
|
||
logger.info("添加数字水印: text=" + config.getText());
|
||
|
||
// PDF水印添加
|
||
// PDDocument doc = PDDocument.load(fileBytes);
|
||
// for (PDPage page : doc.getPages()) {
|
||
// PDPageContentStream cs = new PDPageContentStream(doc, page, APPEND, true);
|
||
// cs.setFont(PDType1Font.HELVETICA, config.getFontSize());
|
||
// cs.setNonStrokingColor(200, 200, 200); // 浅灰色
|
||
// // 平铺水印
|
||
// for (float y = 0; y < page.getMediaBox().getHeight(); y += 100) {
|
||
// for (float x = 0; x < page.getMediaBox().getWidth(); x += 200) {
|
||
// cs.beginText();
|
||
// Matrix matrix = Matrix.getRotateInstance(
|
||
// Math.toRadians(config.getRotation()), x, y
|
||
// );
|
||
// cs.setTextMatrix(matrix);
|
||
// cs.showText(config.getText());
|
||
// cs.endText();
|
||
// }
|
||
// }
|
||
// cs.close();
|
||
// }
|
||
|
||
return fileBytes;
|
||
}
|
||
|
||
/**
|
||
* 删除资源(软删除)
|
||
*
|
||
* 不物理删除文件,仅标记为已删除状态。
|
||
* OSS文件通过生命周期策略定期清理。
|
||
*/
|
||
public void deleteResource(String resourceId, String operatorId) {
|
||
logger.info(String.format(
|
||
"删除资源: id=%s, operator=%s", resourceId, operatorId
|
||
));
|
||
|
||
// 软删除:更新状态
|
||
// resourceMapper.updateStatus(resourceId, "DELETED");
|
||
|
||
// 从ES索引中移除
|
||
// elasticsearchClient.delete(new DeleteRequest(ES_INDEX, resourceId));
|
||
|
||
// 刷新CDN
|
||
refreshCdnCache(resourceId);
|
||
}
|
||
}
|