告别COS,用 GitHub + jsDelivr 搭建零成本图床

我的个人项目中有这么一个功能,就是个人作品页面需要显示详情页的预览截图,这样会显得更专业一点。

但是,截图之前是存在腾讯云COS中的,最近我的腾讯云 COS 到期了,我就开始寻找替代方案。

想换 Cloudflare R2 吧,听说很香零费用,结果还要信用卡验证,我这种没有国际信用卡的人直接被拒之门外。

经过一番调研和实践,我锁定了一个纯白嫖、零成本、高速度的方案:

GitHub 仓库存储 + jsDelivr CDN 加速

本文将介绍一下如何在 Spring Boot 项目中集成这一方案。

技术选型

维度 腾讯云 COS GitHub + jsDelivr
存储费用 按量计费/资源包(需续费) 完全免费 (建议单仓库 < 1GB,单文件 < 50MB,GitHub 有软限制)
流量费用 下行流量贵(容易被盗链刷爆) 完全免费 (jsDelivr 提供的全球加速)
支付门槛 需实名、绑定支付方式 仅需一个 GitHub 账号
国内访问 速度快,但费钱 通过 jsDelivr 加速,速度极快

准备工作

  1. 创建一个新的公开仓库(Public),例如 ObjectStorage
  2. 生成 Personal Access Token (PAT)

在Github账户进入【Settings】➡️【Developer settings】➡️【Personal access tokens】➡️【 Tokens (classic)】。

小贴士

创建Token时必须要勾选 repo 权限,并且创建的时候就得保存好生成的 Token,因为它只会出现一次。

图片截图上传成功后,访问链接格式为:
https://cdn.jsdelivr.net/gh/用户名/仓库名@分支名/文件路径

代码实现

引入依赖

需要 OkHttp 处理网络请求,FastJSON 处理 JSON 数据。

xml 复制代码
<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.10.0</version>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>2.0.32</version>
</dependency>

application-local

为了开源安全,将敏感信息放在 application-local.yml 中,并确保该文件在 .gitignore 中。

yaml 复制代码
# application-local.yml
github:
  owner: github用户名
  repo: ObjectStorage
  branch: main
  token: ghp_你的TOKEN

GithubConfig

将原CosConfig加上@Deprecated,新增GithubConfig

java 复制代码
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Data
@Configuration
@ConfigurationProperties(prefix = "github")
public class GithubConfig {
    private String owner;
    private String repo;
    private String branch;
    private String token;
}

GithubManager

为了模仿对象存储的 SDK,将文件转为 Base64 并调用 GitHub API 进行 PUT 上传。

讲原CosManager加上@Deprecated,新增GithubManager

java 复制代码
@Component
@Slf4j
public class GithubManager {

    @Resource
    private GithubConfig githubConfig;

    private final OkHttpClient client = new OkHttpClient.Builder()
            .connectTimeout(15, TimeUnit.SECONDS)
            .readTimeout(15, TimeUnit.SECONDS)
            .build();

    /**
     * 上传文件到 GitHub 仓库
     *
     * @param key  存储路径
     * @param file 本地文件
     * @return jsDelivr CDN 访问地址
     */
    public String uploadFile(String key, File file) {
        // 1. 读取文件并转为 Base64
        byte[] fileBytes = FileUtil.readBytes(file);
        String base64Content = Base64.getEncoder().encodeToString(fileBytes);

        // 2. 构建 GitHub API 请求体
        JSONObject json = new JSONObject();
        json.put("message", "upload screenshot: " + file.getName());
        json.put("content", base64Content);
        json.put("branch", githubConfig.getBranch());

        // 注意:GitHub API 路径开头不能有 /
        if (key.startsWith("/")) {
            key = key.substring(1);
        }

        String url = String.format("https://api.github.com/repos/%s/%s/contents/%s",
                githubConfig.getOwner(), githubConfig.getRepo(), key);

        RequestBody body = RequestBody.create(
                json.toJSONString(),
                MediaType.parse("application/json; charset=utf-8")
        );

        Request request = new Request.Builder()
                .url(url)
                .addHeader("Authorization", "token " + githubConfig.getToken())
                .put(body)
                .build();

        // 3. 发送请求
        try (Response response = client.newCall(request).execute()) {
            if (response.isSuccessful()) {
                // 返回 jsDelivr CDN 链接
                return String.format("https://cdn.jsdelivr.net/gh/%s/%s@%s/%s",
                        githubConfig.getOwner(),
                        githubConfig.getRepo(),
                        githubConfig.getBranch(),
                        key);
            } else {
                log.error("GitHub 上传失败, 错误码: {}, 详情: {}", response.code(), response.body().string());
                return null;
            }
        } catch (IOException e) {
            log.error("GitHub 上传网络异常", e);
            return null;
        }
    }
}

GithubScreenshotServiceImpl

为了保留原ScreenshotServiceImpl的逻辑,新增一个GithubScreenshotServiceImpl同样也实现ScreenshotService 接口。

java 复制代码
/**
 * GithubScreenshotServiceImpl类实现了ScreenshotService接口,提供网页截图生成并上传到 GitHub 的服务
 */
@Service("githubScreenshotService") // 指定 Bean 名称,防止和原 COS 实现冲突
@Slf4j
public class GithubScreenshotServiceImpl implements ScreenshotService {

    @Resource
    private GithubManager githubManager; // 注入新写的 GithubManager

    @Override
    public String generateAndUploadScreenshot(String webUrl) {
        ThrowUtils.throwIf(StrUtil.isBlank(webUrl), ErrorCode.PARAMS_ERROR, "网页URL不能为空");
        log.info("开始生成网页截图(GitHub方案),URL:{}", webUrl);

        // 1. 调用工具方法保存网页截图到本地
        String localScreenshotPath = WebScreenshotUtils.saveWebPageScreenshot(webUrl);
        ThrowUtils.throwIf(StrUtil.isBlank(localScreenshotPath), ErrorCode.OPERATION_ERROR, "本地截图生成失败");

        try {
            // 2. 调用方法将本地截图上传到 GitHub
            String githubUrl = uploadScreenshotToGithub(localScreenshotPath);
            ThrowUtils.throwIf(StrUtil.isBlank(githubUrl), ErrorCode.OPERATION_ERROR, "截图上传 GitHub 失败");

            log.info("网页截图生成并上传 GitHub 成功: {} -> {}", webUrl, githubUrl);
            return githubUrl;
        } finally {
            // 3. 清理本地文件
            cleanupLocalFile(localScreenshotPath);
        }
    }

    private String uploadScreenshotToGithub(String localScreenshotPath) {
        if (StrUtil.isBlank(localScreenshotPath)) {
            return null;
        }

        File screenshotFile = new File(localScreenshotPath);
        if (!screenshotFile.exists()) {
            log.error("截图文件不存在: {}", localScreenshotPath);
            return null;
        }

        // 生成文件名
        String fileName = UUID.randomUUID().toString().substring(0, 8) + "_compressed.jpg";
        // 生成 GitHub 存储路径
        String githubKey = generateScreenshotKey(fileName);

        // 调用 GitHub 管理器
        return githubManager.uploadFile(githubKey, screenshotFile);
    }

    private String generateScreenshotKey(String fileName) {
        String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
        // 注意:GitHub 路径不建议以 / 开头
        return String.format("screenshots/%s/%s", datePath, fileName);
    }

    private void cleanupLocalFile(String localFilePath) {
        File localFile = new File(localFilePath);
        if (localFile.exists()) {
            File parentDir = localFile.getParentFile();
            FileUtil.del(parentDir);
            log.info("本地截图临时文件已清理: {}", localFilePath);
        }
    }
}

小贴士

这里有一个问题需要注意,@Deprecated 只是给程序员看的"警告"标签,它并不能阻止 Spring 扫描并加载这个 Bean。

所以,为了避免Spring 在启动时发现有两个类都实现了 ScreenshotService 接口,并且都加上了 @Service 注解。当其他地方需要注入 ScreenshotService 时,Spring 不知道该选哪一个的问题。

需要在ScreenshotServiceImpl类中添加@Deprecated 的同时,把@Service 注解注释掉。

java 复制代码
// @Service  <-- 把这个注释掉,Spring 就不会把它当成一个 Bean 加载了
@Slf4j
@Deprecated
public class ScreenshotServiceImpl implements ScreenshotService {
    // ... 原有的代码
}

之后项目的预览界面就可以看到截图了:

相关推荐
武子康2 小时前
大数据-251 离线数仓 - Airflow 安装部署避坑指南:1.10.11 与 2.x 命令差异、MySQL 配置与错误排查
大数据·后端·apache hive
Memory_荒年2 小时前
自定义 Spring Boot Starter:手搓“轮子”,但要搓出兰博基尼!
java·后端
bugcome_com2 小时前
ASP 与ASP.NET核心解析:从经典 ASP 到ASP.NET的演进与实战
后端·asp.net
栈外2 小时前
我是IDEA重度用户,试了4款AI编程插件:有一款有并发Bug,有一款越用越香
java·后端
小陈同学呦2 小时前
关于如何使用CI/CD做自动化部署
前端·后端
架构师沉默2 小时前
为什么说 Go 做游戏服务器就有人皱眉?
java·后端·架构
echome8882 小时前
Go 语言并发编程实战:用 Goroutine 和 Channel 构建高性能任务调度器
开发语言·后端·golang
我还不赖3 小时前
Anthropic skill-creator 深度技术分析文档
后端
树獭叔叔3 小时前
PyTorch 总览:从工程视角重新认识深度学习框架
后端·aigc·openai