Steam API与Halo插件开发的踩坑记录

SailTrack
2026-01-17
点 赞
0
热 度
6
评 论
0
  1. 首页
  2. 学习
  3. Steam API与Halo插件开发的踩坑记录

最近在开发一个用于 Halo 博客系统的 Steam 游戏库展示插件,过程中遇到了不少坑,也学到了很多。今天把整个过程整理一下,希望能给同样想开发 Halo 插件的朋友一些参考。

一、为什么做这个插件

其实想法很简单,我想在博客上展示自己的 Steam 游戏库,让访问我博客的朋友能看到我玩了哪些游戏、玩了多久。Steam 官方有 Web API,可以获取游戏数据,而 Halo 又有插件系统,理论上结合起来应该不难。

但真正动手后才发现,事情远比我想象的复杂。

二、项目搭建遇到的第一个坑

1. Halo 版本问题

刚开始我直接从 GitHub 上下载了 Halo 插件开发模板,结果导入项目后各种报错。后来仔细看文档才发现,Halo 2.x 的插件开发需要特定版本的开发工具。

解决方案:使用 Halo 官方提供的 run.halo.plugin.devtools 插件,版本要和 Halo 版本对应。我使用的是 Halo 2.22.0,所以 build.gradle 里这样配置:

plugins {
    id \"run.halo.plugin.devtools\" version \"0.6.1\"
}

halo {
    version = '2.22'
}

2. Java 版本要求

Halo 2.22.0 要求 Java 21,而我电脑上默认是 JDK 17。一开始没注意,编译一直失败。

解决方案:在 build.gradle 中明确指定 Java 版本:

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

tasks.withType(JavaCompile).configureEach {
    options.encoding = \"UTF-8\"
    options.release = 21
}

三、前端构建的坑

1. 双构建配置的必要性

Halo 插件需要两个前端构建输出:

  • 控制台管理界面(在 /console 路径下)

  • 前端展示页面(在 /steamview 路径下)

我一开始只配置了一个 Vite 配置文件,结果前端页面一直加载不出来。

解决方案:创建两个 Vite 配置文件:

// vite.config.ts - 控制台构建
import { viteConfig } from \"@halo-dev/ui-plugin-bundler-kit\";

export default viteConfig({
  vite: {
    plugins: [/* ... */],
  },
});
// vite.frontend.config.ts - 前端页面构建
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  mode: 'production',
  plugins: [vue()],
  build: {
    outDir: 'build/frontend',
    lib: {
      entry: './src/app.ts',
      name: 'steamview',
      formats: ['iife'],
      fileName: () => 'app.js',
    },
  },
});

然后在 package.json 中添加对应的构建命令:

{
  \"scripts\": {
    \"build\": \"vite build\",
    \"build:frontend\": \"vite build --config vite.frontend.config.ts\"
  }
}

2. 资源复制任务

前端构建完成后,需要把资源复制到正确的位置。我一开始手动复制,后来发现可以在 Gradle 里配置自动化任务:

tasks.register('copyStaticResources', Copy) {
    from project(':ui').layout.buildDirectory.dir('dist')
    into layout.buildDirectory.dir('resources/main/static')
    dependsOn project(':ui').tasks.named('assemble')
}

tasks.register('copyConsoleResources', Copy) {
    from project(':ui').layout.buildDirectory.dir('dist')
    into layout.buildDirectory.dir('resources/main/console')
    dependsOn project(':ui').tasks.named('assemble')
}

tasks.register('copyFrontendResources', Copy) {
    from project(':ui').layout.buildDirectory.dir('frontend')
    into layout.buildDirectory.dir('resources/main/static')
    dependsOn project(':ui').tasks.named('pnpmBuildFrontend')
}

tasks.named('classes') {
    dependsOn tasks.named('copyConsoleResources')
    dependsOn tasks.named('copyFrontendResources')
}

这样每次构建时,资源会自动复制到正确位置。

3. 前端入口文件的区别

控制台和前端的入口文件不同:

  • 控制台入口:src/index.ts(使用 Halo 的组件和路由)

  • 前端页面入口:src/app.ts(独立运行)

我一开始把两者搞混了,导致前端页面一直报错。后来才发现前端页面需要打包成 IIFE 格式,直接在 HTML 中引入。

四、Steam API 调用的坑

1. API 频率限制

Steam API 有频率限制,每分钟最多 100 次请求。我第一次没有做缓存,每次刷新页面都重新调用 API,结果很快就被限制了。

解决方案:实现数据缓存机制,使用 Halo 的 ConfigMap 存储游戏数据:

@Service
public class GameCacheService {
    private final ReactiveExtensionClient extensionClient;
    private static final String CACHE_RESOURCE_NAME = \"game-cache\";

    public Mono<Map<String, Object>> getCachedGames(int refreshInterval) {
        return extensionClient.fetch(ConfigMap.class, CACHE_RESOURCE_NAME)
            .flatMap(configMap -> {
                // 检查缓存是否过期
                String lastUpdatedStr = (String) data.get(\"lastUpdated\");
                Instant lastUpdated = Instant.parse(lastUpdatedStr);
                long hoursSinceUpdate = Duration.between(lastUpdated, Instant.now()).toHours();

                if (hoursSinceUpdate < refreshInterval) {
                    return Mono.just(data);
                }
                return Mono.empty();
            });
    }

    public Mono<Void> saveCachedGames(Map<String, Object> gamesData) {
        // 保存到 ConfigMap
    }
}

2. 游戏名称本地化

Steam API 返回的游戏名称默认是英文,我想要显示中文名称。Steam Store API 可以获取本地化名称,但需要为每个游戏单独调用。

一开始我直接循环调用,结果很慢而且容易触发频率限制。

解决方案:使用 Reactor 的并发控制,限制同时只有 10 个请求:

public Flux<Map<String, Object>> localizeGameNames(List<Map<String, Object>> games) {
    return Flux.fromIterable(games)
        .flatMap(game -> {
            String appId = (String) game.get(\"appId\");
            return steamApiService.getLocalizedGameName(appId)
                .map(name -> {
                    if (name != null) {
                        game.put(\"name\", name);
                    }
                    return game;
                })
                .onErrorResume(e -> Mono.just(game)); // 失败时保留原名
        }, 10); // 并发限制为 10
}

3. 家庭库游戏支持

Steam 的家庭共享功能允许家庭成员共享游戏,但 GetOwnedGames API 只返回自己拥有的游戏,不包括家庭库的游戏。

我一开始没注意到这个问题,导致有些在家庭库中玩过的游戏没显示出来。

解决方案:使用 GetRecentlyPlayedGames API 获取最近游玩的游戏(包括家庭库游戏),然后合并数据:

public Mono<List<Map<String, Object>>> getAllGames(String apiKey, String steamId) {
    return Mono.zip(
        steamApiService.getOwnedGames(apiKey, steamId),
        steamApiService.getRecentlyPlayedGames(apiKey, steamId)
    ).map(tuple -> {
        List<Map<String, Object>> ownedGames = tuple.getT1();
        List<Map<String, Object>> recentGames = tuple.getT2();

        // 合并游戏数据,去重
        Map<String, Map<String, Object>> gameMap = new HashMap<>();
        ownedGames.forEach(game -> gameMap.put((String) game.get(\"appId\"), game));
        recentGames.forEach(game -> {
            String appId = (String) game.get(\"appId\");
            if (!gameMap.containsKey(appId)) {
                gameMap.put(appId, game);
            }
        });

        return new ArrayList<>(gameMap.values());
    });
}

五、响应式编程的坑

Halo 使用 Spring WebFlux,需要使用响应式编程。我之前主要用 Spring MVC,一开始很不习惯。

1. Mono 和 Flux 的区别

Mono 表示 0 或 1 个元素,Flux 表示 0 到 N 个元素。一开始我总是用错,导致编译错误。

示例

// 获取单个游戏名称 - 使用 Mono
public Mono<String> getLocalizedGameName(String appId) {
    return webClient.get()
        .uri(url)
        .retrieve()
        .bodyToMono(String.class);
}

// 获取多个游戏 - 使用 Flux
public Flux<Map<String, Object>> getAllGames() {
    return Flux.fromIterable(games)
        .flatMap(game -> fetchGameData(game));
}

2. 异常处理

响应式编程的异常处理和传统编程不同,不能直接 try-catch。

解决方案:使用 onErrorResumedoOnError 等操作符:

public Mono<String> getSteamId(String apiKey, String username) {
    return webClient.get()
        .uri(url)
        .retrieve()
        .bodyToMono(String.class)
        .map(response -> parseResponse(response))
        .doOnError(e -> log.error(\"获取 Steam ID 失败: {}\", e.getMessage()))
        .onErrorResume(e -> {
            log.error(\"处理异常,返回默认值\");
            return Mono.just(\"default-value\");
        });
}

3. 阻塞代码的问题

响应式编程中不能使用阻塞代码,否则会阻塞事件循环。我一开始在 Mono 里直接调用阻塞方法,导致整个应用卡死。

解决方案:使用 subscribeOnpublishOn 切换到不同的调度器:

public Mono<String> processData(String input) {
    return Mono.fromCallable(() -> {
        // 阻塞操作
        return blockingOperation(input);
    })
    .subscribeOn(Schedulers.boundedElastic()); // 使用弹性调度器处理阻塞操作
}

六、UI 和样式的坑

1. 响应式设计

插件需要在桌面端、平板和移动端都能正常显示。我一开始只考虑了桌面端,导致在手机上布局乱七八糟。

解决方案:使用 CSS Grid 和 Flexbox 实现响应式布局:

.game-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 20px;

  @media (max-width: 768px) {
    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
    gap: 15px;
  }

  @media (max-width: 480px) {
    grid-template-columns: 1fr;
    gap: 10px;
  }
}

2. 加载状态反馈

从 Steam API 获取数据需要时间,如果没有加载提示,用户体验会很差。

解决方案:添加加载动画和状态提示:

<template>
  <div v-if=\"loading\" class=\"loading-container\">
    <div class=\"spinner\"></div>
    <p>正在加载游戏数据...</p>
  </div>
  <div v-else-if=\"error\" class=\"error-container\">
    <p>{{ error }}</p>
  </div>
  <div v-else class=\"game-list\">
    <!-- 游戏列表 -->
  </div>
</template>

3. 图片加载优化

游戏封面图片来自 Steam CDN,有时候加载很慢。我一开始没有做优化,导致页面加载时间很长。

解决方案:使用懒加载和占位图:

<template>
  <img
    :src=\"game.coverUrl\"
    :alt=\"game.name\"
    loading=\"lazy\"
    @error=\"handleImageError\"
  />
</template>

<script setup>
const handleImageError = (e) => {
  e.target.src = '/placeholder.png'; // 占位图
};
</script>

七、开发流程的坑

1. 热更新问题

开发时想要前端热更新,但 Halo 插件的开发环境配置比较复杂。我一开始每次修改都要重新构建,效率很低。

解决方案:使用 pnpm dev 命令监听文件变化并自动构建:

cd ui
pnpm dev

同时在另一个终端运行 Halo 开发服务器:

./gradlew.bat haloServer

2. 调试困难

Halo 插件的调试不像普通 Web 应用那么方便,后端日志和前端日志混在一起。

解决方案

  • 后端:使用 @Slf4j 注解和 log 对象输出详细日志

  • 前端:使用浏览器开发者工具的 Console 和 Network 面板

  • 使用 console.log 输出调试信息

@Slf4j
@Service
public class SteamApiService {
    public Mono<String> getSteamId(String apiKey, String username) {
        log.info(\"请求 Steam ID: URL={}\", url);
        // ...
        log.info(\"Steam ID: {}\", steamId);
    }
}

3. 构建产物位置

我一开始找不到构建后的 jar 文件在哪里,浪费了很多时间。

解决方案:构建产物在 build/libs/ 目录下:

./gradlew.bat build
# 产物位置: build/libs/pluginsteamview-1.0.0-SNAPSHOT.jar

八、总结

这次开发 Steam View 插件的经历让我学到了很多:

  1. 版本兼容性很重要:Halo 版本、Java 版本、依赖版本都要匹配好

  2. 响应式编程需要适应:Mono 和 Flux 的使用、异常处理、调度器选择都需要仔细考虑

  3. API 调用要有限制:频率限制、并发控制、缓存机制都不能少

  4. 用户体验要重视:加载状态、错误处理、响应式设计都要做好

  5. 开发流程要优化:热更新、调试工具、构建配置都要配置好

虽然过程中遇到了很多坑,但最终看到插件正常运行,在博客上展示出自己的游戏库,还是挺有成就感的。

如果你也想开发 Halo 插件,希望这些经验能帮到你。有问题欢迎交流!


dashboard.png
games.png


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

具有版权性

请您在转载、复制时注明本文 作者、链接及内容来源信息。 若涉及转载第三方内容,还需一同注明。

具有时效性

文章目录

欢迎来到SailTrack的站点,为您导航全站动态

21 文章数
8 分类数
1 评论数
11标签数