software copyright
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
/*
|
||||
* 自然写教学资源管理与内容分发系统软件 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<String> REFERER_WHITELIST = new HashSet<>(Arrays.asList(
|
||||
"*.writech.com",
|
||||
"localhost",
|
||||
"127.0.0.1"
|
||||
));
|
||||
|
||||
/** CDN缓存策略(按资源类型配置TTL) */
|
||||
private static final Map<String, Integer> 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<String> objectKeys) {
|
||||
logger.info(String.format("CDN缓存预热: %d个资源", objectKeys.size()));
|
||||
|
||||
List<String> 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<String> objectKeys) {
|
||||
logger.info(String.format("CDN缓存刷新: %d个资源", objectKeys.size()));
|
||||
|
||||
List<String> 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<String, Object> 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<String, Object> 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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user