告别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 {
    // ... 原有的代码
}

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

相关推荐
嘉伟桑26 分钟前
Python调用电价API返回JSON示例:分时电价、现货电价和字段解析
后端
武子康42 分钟前
Shippy:确定性工具、会话级 Sandbox 与 Live-Data Eval(4 类收敛 + 7 步实现方案 + 6 类评测指标)
前端·人工智能·后端
我叫黑大帅1 小时前
我为什么单一消费者的场景下,要用 Redis List 当消息队列?
redis·后端·面试
AskHarries1 小时前
文件上传系统
后端
止语Lab2 小时前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活2 小时前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
程序员天天困2 小时前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn2 小时前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端
用户298698530142 小时前
Python 实现 Excel 与 Markdown 互转的实用指南
后端·python·excel
用户77283104908402 小时前
krono-job:零侵入、单二进制交付的分布式任务调度平台(开源)
后端