/* * 自然写教学资源管理与内容分发系统软件 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 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 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 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 metadata) { logger.info("索引资源到ES: id=" + resourceId); // Map 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 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); } }