Files
system-design/software-copyright/13-writech-resource-platform/service/CdnService.java
T
2026-03-22 15:24:40 +08:00

334 lines
11 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 自然写教学资源管理与内容分发系统软件 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. 拼接签名URLdomain/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);
}
}