334 lines
11 KiB
Java
334 lines
11 KiB
Java
/*
|
||
* 自然写教学资源管理与内容分发系统软件 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);
|
||
}
|
||
}
|