/* * 自然写教学资源管理与内容分发系统软件 V1.0 * service/CdnService.java - CDN分发与缓存管理服务 */ package com.writech.resource.service; import java.util.*; import java.util.logging.Logger; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; /** * CDN分发与缓存管理服务 * * 负责教学资源的CDN加速分发,包括: * - 签名URL生成(防盗链) * - CDN缓存预热与刷新 * - 资源分发策略管理 * - 下载流量统计 */ public class CdnService { private static final Logger logger = Logger.getLogger(CdnService.class.getName()); // ============================================================ // CDN配置 // ============================================================ /** CDN域名 */ private static final String CDN_DOMAIN = "https://cdn.writech.com"; /** CDN签名密钥 */ private String cdnSignKey; /** 签名URL默认有效期(秒) */ private static final int DEFAULT_EXPIRE_SECONDS = 1800; /** Referer白名单 */ private static final Set REFERER_WHITELIST = new HashSet<>(Arrays.asList( "*.writech.com", "localhost", "127.0.0.1" )); /** CDN缓存策略(按资源类型配置TTL) */ private static final Map CACHE_TTL_MAP = new HashMap<>(); static { CACHE_TTL_MAP.put("pdf", 86400 * 30); // PDF资源缓存30天 CACHE_TTL_MAP.put("image", 86400 * 90); // 图片缓存90天 CACHE_TTL_MAP.put("video", 86400 * 7); // 视频缓存7天 CACHE_TTL_MAP.put("template", 86400 * 30); // 模板缓存30天 CACHE_TTL_MAP.put("dotcode", 86400 * 365); // 点阵码缓存1年(不变内容) } public CdnService(String signKey) { this.cdnSignKey = signKey; logger.info("CDN服务初始化: domain=" + CDN_DOMAIN); } // ============================================================ // 签名URL生成(防盗链核心) // ============================================================ /** * 生成CDN签名下载URL * * 签名算法(TypeA鉴权): * 1. 计算签名原文:path-timestamp-rand-uid * 2. HMAC-SHA256(原文, 密钥) * 3. 拼接签名URL:domain/path?auth_key=timestamp-rand-uid-signature * * @param objectKey OSS对象Key * @param expireSeconds 有效期(秒) * @return 签名后的CDN下载URL */ public String generateSignedUrl(String objectKey, int expireSeconds) { if (expireSeconds <= 0) { expireSeconds = DEFAULT_EXPIRE_SECONDS; } long timestamp = System.currentTimeMillis() / 1000 + expireSeconds; String rand = UUID.randomUUID().toString().replace("-", "").substring(0, 8); String uid = "0"; // 用户标识(可选) String path = "/" + objectKey; // 签名原文 String signContent = String.format("%s-%d-%s-%s", path, timestamp, rand, uid); // HMAC-SHA256计算签名 String signature = hmacSha256(signContent, cdnSignKey); // 拼接签名URL String authKey = String.format("%d-%s-%s-%s", timestamp, rand, uid, signature); String signedUrl = String.format("%s%s?auth_key=%s", CDN_DOMAIN, path, authKey); logger.info(String.format( "生成签名URL: key=%s, expire=%ds", objectKey, expireSeconds )); return signedUrl; } /** * 验证签名URL是否有效 * * @param url 待验证的URL * @return 验证结果 */ public boolean verifySignedUrl(String url) { try { // 解析auth_key参数 String authKey = extractParam(url, "auth_key"); if (authKey == null) return false; String[] parts = authKey.split("-", 4); if (parts.length != 4) return false; long timestamp = Long.parseLong(parts[0]); String rand = parts[1]; String uid = parts[2]; String receivedSignature = parts[3]; // 检查是否过期 if (System.currentTimeMillis() / 1000 > timestamp) { return false; } // 重新计算签名对比 String path = extractPath(url); String signContent = String.format("%s-%d-%s-%s", path, timestamp, rand, uid); String expectedSignature = hmacSha256(signContent, cdnSignKey); return expectedSignature.equals(receivedSignature); } catch (Exception e) { logger.warning("签名验证异常: " + e.getMessage()); return false; } } /** * HMAC-SHA256签名计算 */ private String hmacSha256(String data, String key) { try { Mac mac = Mac.getInstance("HmacSHA256"); SecretKeySpec secretKey = new SecretKeySpec( key.getBytes(StandardCharsets.UTF_8), "HmacSHA256" ); mac.init(secretKey); byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); // 转换为十六进制字符串 StringBuilder sb = new StringBuilder(); for (byte b : hash) { sb.append(String.format("%02x", b)); } return sb.toString(); } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new RuntimeException("HMAC-SHA256计算失败", e); } } // ============================================================ // CDN缓存管理 // ============================================================ /** * 预热CDN缓存 * * 将指定资源推送到CDN所有边缘节点,确保用户首次访问也能快速响应。 * 通常在资源审核通过后触发预热。 * * @param objectKeys 要预热的资源Key列表 */ public void preheatResources(List objectKeys) { logger.info(String.format("CDN缓存预热: %d个资源", objectKeys.size())); List urls = new ArrayList<>(); for (String key : objectKeys) { urls.add(CDN_DOMAIN + "/" + key); } // 调用CDN API预热 // PushObjectCacheRequest request = new PushObjectCacheRequest(); // request.setObjectPath(String.join("\n", urls)); // cdnClient.pushObjectCache(request); logger.info("CDN预热任务已提交"); } /** * 刷新CDN缓存 * * 资源更新或删除后,需要刷新CDN缓存使旧版本失效。 * * @param objectKeys 要刷新的资源Key列表 */ public void refreshCache(List objectKeys) { logger.info(String.format("CDN缓存刷新: %d个资源", objectKeys.size())); List urls = new ArrayList<>(); for (String key : objectKeys) { urls.add(CDN_DOMAIN + "/" + key); } // 调用CDN API刷新 // RefreshObjectCachesRequest request = new RefreshObjectCachesRequest(); // request.setObjectPath(String.join("\n", urls)); // cdnClient.refreshObjectCaches(request); logger.info("CDN刷新任务已提交"); } /** * 刷新目录缓存(用于整个类别的批量更新) */ public void refreshDirectoryCache(String directoryPath) { logger.info("CDN目录缓存刷新: " + directoryPath); // RefreshObjectCachesRequest request = new RefreshObjectCachesRequest(); // request.setObjectPath(CDN_DOMAIN + "/" + directoryPath); // request.setObjectType("Directory"); // cdnClient.refreshObjectCaches(request); } // ============================================================ // Referer防盗链校验 // ============================================================ /** * 校验请求Referer是否在白名单中 * * @param referer 请求头中的Referer * @return 是否允许访问 */ public boolean validateReferer(String referer) { if (referer == null || referer.isEmpty()) { return false; // 空Referer拒绝 } for (String pattern : REFERER_WHITELIST) { if (pattern.startsWith("*.")) { // 通配符匹配 String domain = pattern.substring(2); if (referer.contains(domain)) { return true; } } else { if (referer.contains(pattern)) { return true; } } } logger.warning("Referer校验失败: " + referer); return false; } // ============================================================ // 流量统计 // ============================================================ /** * 记录资源下载事件(异步写入ClickHouse) * * @param resourceId 资源ID * @param userId 下载用户ID * @param terminal 终端类型(pad/pc/mobile/board) * @param fileSize 文件大小(字节) */ public void recordDownloadEvent( String resourceId, String userId, String terminal, long fileSize ) { // 异步写入ClickHouse使用统计表 // Map event = new HashMap<>(); // event.put("resource_id", resourceId); // event.put("user_id", userId); // event.put("terminal", terminal); // event.put("file_size", fileSize); // event.put("download_at", new Date()); // event.put("cdn_node", getCdnNodeId()); // clickhouseClient.insert("usage_stat", event); } /** * 查询资源下载统计 */ public Map getDownloadStats( String resourceId, String startDate, String endDate ) { // 从ClickHouse查询聚合统计 // SELECT count() as downloads, sum(file_size) as total_bytes, // uniq(user_id) as unique_users // FROM usage_stat // WHERE resource_id = ? AND download_at BETWEEN ? AND ? Map stats = new HashMap<>(); stats.put("resource_id", resourceId); stats.put("total_downloads", 0); stats.put("total_bytes", 0L); stats.put("unique_users", 0); stats.put("by_terminal", new HashMap<>()); stats.put("daily_trend", new ArrayList<>()); return stats; } // ============================================================ // 辅助方法 // ============================================================ /** 从URL中提取指定参数 */ private String extractParam(String url, String paramName) { int start = url.indexOf(paramName + "="); if (start < 0) return null; start += paramName.length() + 1; int end = url.indexOf("&", start); return end > 0 ? url.substring(start, end) : url.substring(start); } /** 从URL中提取路径部分 */ private String extractPath(String url) { int start = url.indexOf("/", url.indexOf("//") + 2); int end = url.indexOf("?"); return end > 0 ? url.substring(start, end) : url.substring(start); } }