在开始分享我的实现之前,不得不先抨击一下当前技术文章领域的一个普遍现象:天下文章一般抄。在准备实现七牛云时间戳防盗链功能时,我搜索了一些中文资料,发现一个令人沮丧的事实:
90%的文章内容雷同,甚至连错误都一样
几乎没有验证过程,直接搬运官方文档或他人代码
代码示例漏洞百出,无法实际运行
关键细节缺失,如 URL 编码的特殊处理、时间戳的 16 进制转换等
最令人震惊的是,这些文章被不断转载,错误被不断放大,形成了一个"错误信息闭环"。开发者按照这些文章实现后,发现无法正常工作,却找不到原因。
七牛官方文档
https://developer.qiniu.com/fusion/3841/timestamp-hotlinking-prevention-fusion
一个经过验证的正确实现
与上述情况不同,今天我分享的是一个经过严格验证的 Java 实现。这个实现:
与七牛官方工具生成的签名进行逻辑对比,确保完全一致
处理了所有边界情况,如 URL 中的特殊字符、端口处理等
代码清晰可读,有完整的注释和文档
可直接用于生产环境,已在真实项目中验证
核心算法解析
1. URL 编码处理
public static String urlEncode(String s) {
try {
return URLEncoder.encode(s, StandardCharsets.UTF_8.name())
.replace("+", "%20")
.replace("%2F", "/");
} catch (Exception e) {
return s;
}
}
复制代码
关键点:
使用标准 URL 编码
将"+"替换为"%20"(七牛特殊要求)
保留"/"不编码(与一般 URL 编码不同)
2. 时间戳转换
public static String toHexLower(long timestamp) {
return Long.toHexString(timestamp);
}
复制代码
将十进制时间戳转为 16 进制小写字符串,这是七牛防盗链的特殊要求。
3. MD5 签名计算
public static String md5Hex(String s) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] bytes = md.digest(s.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b & 0xff));
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException("MD5计算失败", e);
}
}
复制代码
标准的 MD5 计算,确保结果为 32 位小写 16 进制字符串。
4. 签名生成
public static String sign(String key, String t, String path) {
String toSign = key + urlEncode(path) + t;
return "sign=" + md5Hex(toSign) + "&t=" + t;
}
复制代码
拼接密钥、编码后的路径和 16 进制时间戳,计算 MD5 作为签名。
5. 完整 URL 生成
public static String signUrl(String url, String key, long deadline) {
try {
String t = toHexLower(deadline);
URL u = new URL(url);
String path = u.getPath();
String query = u.getQuery();
String signPart = sign(key, t, path);
String newQuery = (query == null || query.isEmpty()) ? signPart : query + "&" + signPart;
StringBuilder sb = new StringBuilder();
sb.append(u.getProtocol()).append("://").append(u.getHost());
if (u.getPort() != -1) {
sb.append(":").append(u.getPort());
}
sb.append(urlEncode(path)).append("?").append(newQuery);
return sb.toString();
} catch (Exception e) {
throw new RuntimeException("签名URL生成失败", e);
}
}
复制代码
处理原始 URL 的各个部分,确保生成的签名 URL 格式正确。
验证方法
为确保实现的正确性,我采用了以下验证方法:
与七牛官方工具对比:使用相同参数生成签名 URL,确保完全一致
边界测试:
包含特殊字符的 URL
带端口号的 URL
带复杂查询参数的 URL
时间验证:检查不同时间戳生成的签名是否符合预期
编码验证:确保 URL 编码处理符合七牛要求
使用示例
public static void main(String[] args) {
String key = "你的七牛密钥";
String url = "http://xxx.yyy.com/DIR1/dir2/vodfile.mp4?v=1.1";
long deadline = getNextHourTimestamp();
System.out.println("过期时间:" + deadline);
String signedUrl = signUrl(url, key, deadline);
System.out.println("签名后的URL: " + signedUrl);
}
复制代码
总结
与网上大量未经验证的"搬运"文章不同,本文分享的实现:
完全可运行:代码完整,无缺失部分
经过严格验证:与官方工具对比一致
处理所有边界情况:考虑到了各种 URL 格式
可直接用于生产:已在真实项目中使用
希望这个实现能帮助开发者避免踩坑,也呼吁技术写作者:请验证你的代码再分享,不要成为错误信息的传播者。
注:完整代码如下所示,可直接复制使用。如有任何问题,欢迎讨论交流。
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.ZoneId;
import java.time.ZonedDateTime;
/**
* @author ZhangYou
* @description
* @date 2025/6/3
*/
public class QiniuUrlTimestampSigner {
// URL encode,斜杠不编码
public static String urlEncode(String s) {
try {
return URLEncoder.encode(s, StandardCharsets.UTF_8.name())
.replace("+", "%20")
.replace("%2F", "/");
} catch (Exception e) {
// 不应发生,直接返回原字符串
return s;
}
}
// 将十进制时间戳转16进制小写字符串
public static String toHexLower(long timestamp) {
return Long.toHexString(timestamp);
}
// MD5签名计算
public static String md5Hex(String s) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] bytes = md.digest(s.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b & 0xff));
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException("MD5计算失败", e);
}
}
// 生成签名参数部分 sign=xxx&t=xxx
public static String sign(String key, String t, String path) {
String toSign = key + urlEncode(path) + t;
return "sign=" + md5Hex(toSign) + "&t=" + t;
}
// 根据URL和过期时间生成带签名的URL
public static String signUrl(String url, String key, long deadline) {
try {
String t = toHexLower(deadline);
System.out.println("16进制时间格式:" + t);
URL u = new URL(url);
String path = u.getPath();
String query = u.getQuery();
String signPart = sign(key, t, path);
String newQuery = (query == null || query.isEmpty()) ? signPart : query + "&" + signPart;
// 拼接完整URL,包含端口判断
StringBuilder sb = new StringBuilder();
sb.append(u.getProtocol()).append("://").append(u.getHost());
if (u.getPort() != -1) {
sb.append(":").append(u.getPort());
}
sb.append(urlEncode(path)).append("?").append(newQuery);
return sb.toString();
} catch (Exception e) {
throw new RuntimeException("签名URL生成失败", e);
}
}
public static long getNextHourTimestamp() {
ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));
ZonedDateTime nextHour = now.plusHours(1).withMinute(0).withSecond(0).withNano(0);
return nextHour.toEpochSecond();
}
// 简单调用示例
public static void main(String[] args) {
String key = "db883caa36aab4b82cbac7aac7b9efaba29908b9";
String url = "http://xxx.yyy.com/DIR1/dir2/vodfile.mp4?v=1.1";
long deadline = getNextHourTimestamp();
System.out.println("过期时间:" + deadline);
String signedUrl = signUrl(url, key, deadline);
System.out.println("签名后的URL: " + signedUrl);
}
}
复制代码
评论