Skip to content

Blog

如何使用 GitHub Actions + image-syncer 实现 Docker Hub 到 Azure ACR 的自动化镜像同步

实现 Docker Hub 到 Azure ACR 的自动化镜像同步

Section titled “实现 Docker Hub 到 Azure ACR 的自动化镜像同步”

本文介绍了如何使用 GitHub Actions 和 image-syncer 工具,实现 Docker Hub 镜像到 Azure Container Registry 的自动化同步,解决了国内及部分 Azure 区域访问 Docker Hub 速度慢的问题,提升了镜像的可用性和 Azure 环境的部署效率。

HagiCode 项目使用 Docker 镜像作为核心运行时组件,主要镜像托管在 Docker Hub。随着项目发展和 Azure 环境部署需求的增加,我们遇到了以下痛点:

  • 镜像拉取速度慢,Docker Hub 在国内及部分 Azure 区域访问受限
  • 依赖单一镜像源存在单点故障风险
  • Azure 环境下使用 Azure Container Registry 能获得更好的网络性能和集成体验

为解决这些问题,我们需要建立一个自动化的镜像同步机制,将 Docker Hub 的镜像定期同步到 Azure ACR,确保用户能够在 Azure 环境中获得更快的镜像拉取速度和更高的可用性。

我们正在开发 HagiCode——一款 AI 驱动的代码智能助手,让开发体验变得更智能、更便捷、更有趣。

智能——AI 全程辅助,从想法到代码,让编码效率提升数倍。便捷——多线程并发操作,充分利用资源,开发流程顺畅无阻。有趣——游戏化机制和成就系统,让编码不再枯燥,充满成就感。

项目正在快速迭代中,如果你对技术写作、知识管理或者 AI 辅助开发感兴趣,欢迎来 GitHub 看看。

在制定解决方案时,我们对比了多种技术方案:

  • 增量同步:仅同步变更的镜像层,显著减少网络传输
  • 断点续传:网络中断后可恢复同步
  • 并发控制:支持配置并发线程数,提升大镜像同步效率
  • 完善的错误处理:内置失败重试机制(默认 3 次)
  • 轻量级部署:单二进制文件,无依赖
  • 多仓库支持:兼容 Docker Hub、Azure ACR、Harbor 等
  • 不支持增量同步:每次都需要拉取完整的镜像内容
  • 效率较低:网络传输量大,时间长
  • 简单易用:使用熟悉的 docker pull/push 命令
  • 复杂度高:需要配置 Azure CLI 认证
  • 功能限制:az acr import 功能相对单一
  • 原生集成:与 Azure 服务集成良好

决策 1:同步频率设置为每日 UTC 00:00

Section titled “决策 1:同步频率设置为每日 UTC 00:00”
  • 平衡镜像新鲜度和资源消耗
  • 避开业务高峰期,减少对其他操作的影响
  • Docker Hub 镜像通常在每日构建后更新
  • 保持与 Docker Hub 的完全一致性
  • 为用户提供灵活的版本选择
  • 简化同步逻辑,避免复杂的标签过滤规则

决策 3:使用 GitHub Secrets 存储认证信息

Section titled “决策 3:使用 GitHub Secrets 存储认证信息”
  • GitHub Actions 原生支持,安全性高
  • 配置简单,易于管理和维护
  • 支持仓库级别的访问控制
  • 使用 GitHub Secrets 加密存储
  • 定期轮换 ACR 密码
  • 限制 ACR 用户权限为仅推送
  • 监控 ACR 访问日志

风险 2:同步失败导致镜像不一致

Section titled “风险 2:同步失败导致镜像不一致”
  • image-syncer 内置增量同步机制
  • 自动失败重试(默认 3 次)
  • 详细的错误日志和失败通知
  • 断点续传功能
  • 增量同步减少网络传输
  • 可配置并发线程数(当前设置为 10)
  • 监控同步的镜像数量和大小
  • 在非高峰时段运行同步

我们采用 GitHub Actions + image-syncer 的自动化方案,实现从 Docker Hub 到 Azure ACR 的镜像同步。

  • 在 Azure Portal 中创建或确认 Azure Container Registry
  • 创建 ACR 访问密钥(用户名和密码)
  • 确认 Docker Hub 镜像仓库访问权限

在 GitHub 仓库设置中添加以下 Secrets:

  • AZURE_ACR_USERNAME: Azure ACR 用户名
  • AZURE_ACR_PASSWORD: Azure ACR 密码

在 .github/workflows/sync-docker-acr.yml 中配置工作流:

  • 定时触发:每天 UTC 00:00
  • 手动触发:支持 workflow_dispatch
  • 额外触发:publish 分支推送时触发(用于快速同步)
Azure ACRDocker Hubimage-syncerGitHub ActionsAzure ACRDocker Hubimage-syncerGitHub Actions触发工作流增量同步:仅传输变更的镜像层下载并执行 image-syncer获取镜像 manifest 和标签列表返回镜像元数据获取已存在的镜像信息返回目标镜像信息对比差异,识别变更的镜像层拉取变更的镜像层返回镜像层内容推送变更的镜像层到 ACR返回推送结果返回同步统计信息记录同步日志并上传 artifact

以下是实际运行的工作流配置(.github/workflows/sync-docker-acr.yml):

name: Sync Docker Image to Azure ACR
on:
schedule:
- cron: "0 0 * * *" # 每天 UTC 00:00
workflow_dispatch: # 手动触发
push:
branches: [publish]
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download image-syncer
run: |
# 下载 image-syncer 二进制文件
wget https://github.com/AliyunContainerService/image-syncer/releases/download/v1.5.5/image-syncer-v1.5.5-linux-amd64.tar.gz
tar -zxvf image-syncer-v1.5.5-linux-amd64.tar.gz
chmod +x image-syncer
- name: Create auth config
run: |
# 生成认证配置文件 (YAML 格式)
cat > auth.yaml <<EOF
hagicode.azurecr.io:
username: "${{ secrets.AZURE_ACR_USERNAME }}"
password: "${{ secrets.AZURE_ACR_PASSWORD }}"
EOF
- name: Create images config
run: |
# 生成镜像同步配置文件 (YAML 格式)
cat > images.yaml <<EOF
docker.io/newbe36524/hagicode: hagicode.azurecr.io/hagicode
EOF
- name: Run image-syncer
run: |
# 执行同步 (使用新版 --auth 和 --images 参数)
./image-syncer --auth=./auth.yaml --images=./images.yaml --proc=10 --retries=3
- name: Upload logs
if: always()
uses: actions/upload-artifact@v4
with:
name: sync-logs
path: image-syncer-*.log
retention-days: 7
  • 定时触发:cron: “0 0 * * *” - 每天 UTC 00:00 执行
  • 手动触发:workflow_dispatch - 允许用户在 GitHub UI 手动运行
  • 推送触发:push: branches: [publish] - 发布分支推送时触发(用于快速同步)
hagicode.azurecr.io:
username: "${{ secrets.AZURE_ACR_USERNAME }}"
password: "${{ secrets.AZURE_ACR_PASSWORD }}"
docker.io/newbe36524/hagicode: hagicode.azurecr.io/hagicode

此配置表示将 docker.io/newbe36524/hagicode 的所有标签同步到 hagicode.azurecr.io/hagicode

  • —auth=./auth.yaml: 认证配置文件路径
  • —images=./images.yaml: 镜像同步配置文件路径
  • —proc=10: 并发线程数为 10
  • —retries=3: 失败重试 3 次

在 GitHub 仓库的 Settings → Secrets and variables → Actions 中配置:

Secret 名称描述示例值获取方式
AZURE_ACR_USERNAMEAzure ACR 用户名hagicodeAzure Portal → ACR → Access keys
AZURE_ACR_PASSWORDAzure ACR 密码xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxAzure Portal → ACR → Access keys → Password
  1. 访问 GitHub 仓库的 Actions 标签页
  2. 选择 Sync Docker Image to Azure ACR 工作流
  3. 点击 Run workflow 按钮
  4. 选择分支并点击 Run workflow 确认
  1. 在 Actions 页面点击具体的工作流运行记录
  2. 查看各个步骤的执行日志
  3. 在页面底部的 Artifacts 区域下载 sync-logs 文件
Terminal window
# 登录到 Azure ACR
az acr login --name hagicode
# 列出镜像及其标签
az acr repository show-tags --name hagicode --repository hagicode --output table
  • 定期轮换 Azure ACR 密码(建议每 90 天)
  • 使用专用的 ACR 服务账户,限制权限为仅推送
  • 监控 ACR 的访问日志,及时发现异常访问
  • 不要在日志中输出认证信息
  • 不要将认证信息提交到代码仓库
  • 调整 —proc 参数:根据网络带宽调整并发数(建议 5-20)
  • 监控同步时间:如果同步时间过长,考虑减少并发数
  • 定期清理日志:设置合理的 retention-days(当前为 7 天)
Error: failed to authenticate to hagicode.azurecr.io

解决方案:

  1. 检查 GitHub Secrets 是否正确配置
  2. 验证 Azure ACR 密码是否过期
  3. 确认 ACR 服务账户权限是否正确
Error: timeout waiting for response

解决方案:

  1. 检查网络连接
  2. 减少并发线程数(—proc 参数)
  3. 等待网络恢复后重新触发工作流
Warning: some tags failed to sync

解决方案:

  1. 检查同步日志,识别失败的标签
  2. 手动触发工作流重新同步
  3. 验证 Docker Hub 源镜像是否正常
  • 定期检查 Actions 页面,确认工作流运行状态
  • 设置 GitHub 通知,及时获取工作流失败通知
  • 监控 Azure ACR 的存储使用情况
  • 定期验证镜像标签一致性

Q1: 如何同步特定标签而不是所有标签?

Section titled “Q1: 如何同步特定标签而不是所有标签?”

修改 images.yaml 配置文件:

# 仅同步 latest 和 v1.0 标签
docker.io/newbe36524/hagicode:latest: hagicode.azurecr.io/hagicode:latest
docker.io/newbe36524/hagicode:v1.0: hagicode.azurecr.io/hagicode:v1.0

在 images.yaml 中添加多行配置:

docker.io/newbe36524/hagicode: hagicode.azurecr.io/hagicode
docker.io/newbe36524/another-image: hagicode.azurecr.io/another-image
  • 自动重试:image-syncer 内置重试机制(默认 3 次)
  • 手动重试:在 GitHub Actions 页面点击 Re-run all jobs
  • 在 Actions 页面查看实时日志
  • 下载 sync-logs artifact 查看完整日志文件
  • 日志文件包含每个标签的同步状态和传输速度
  • 首次全量同步:根据镜像大小,通常需要 10-30 分钟
  • 增量同步:如果镜像变更小,通常 2-5 分钟
  • 时间取决于网络带宽、镜像大小和并发设置

在工作流中添加通知步骤:

- name: Notify on success
if: success()
run: |
echo "Docker images synced successfully to Azure ACR"

在工作流中添加标签过滤逻辑:

- name: Filter tags
run: |
# 仅同步以 v 开头的标签
echo "docker.io/newbe36524/hagicode:v* : hagicode.azurecr.io/hagicode:v*" > images.yaml
- name: Generate report
if: always()
run: |
echo "## Sync Report" >> $GITHUB_STEP_SUMMARY
echo "- Total tags: $(grep -c 'synced' image-syncer-*.log)" >> $GITHUB_STEP_SUMMARY
echo "- Sync time: ${{ steps.sync.outputs.duration }}" >> $GITHUB_STEP_SUMMARY

通过本文介绍的方法,我们成功实现了从 Docker Hub 到 Azure ACR 的自动化镜像同步。这个方案利用 GitHub Actions 的定时触发和手动触发功能,结合 image-syncer 的增量同步和错误处理机制,确保了镜像的及时同步和一致性。

我们还讨论了安全最佳实践、性能优化、故障排查等方面的内容,帮助用户更好地管理和维护这个同步机制。希望本文能够为需要在 Azure 环境中部署 Docker 镜像的开发者提供有价值的参考。


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。

GitHub Issues 集成

从零构建 GitHub Issues 集成:HagiCode 的前端直连实践

Section titled “从零构建 GitHub Issues 集成:HagiCode 的前端直连实践”

本文记录了在 HagiCode 平台中集成 GitHub Issues 的全过程。我们将探讨如何通过”前端直连 + 后端最小化”的架构,在保持后端轻量的同时,实现安全的 OAuth 认证与高效的 Issues 同步。

HagiCode 作为一个 AI 辅助开发平台,核心价值在于连接想法与实现。但在实际使用中,我们发现用户在 HagiCode 中完成了 Proposal(提案)后,往往需要手动将内容复制到 GitHub Issues 中进行项目跟踪。

这带来了几个明显的痛点:

  1. 工作流割裂:用户需要在两个系统之间来回切换,体验不仅不流畅,还容易导致关键信息在复制粘贴的过程中丢失。
  2. 协作不便:团队其他成员习惯在 GitHub 上查看任务,无法直接看到 HagiCode 中的提案进展。
  3. 重复劳动:每当提案更新,就要人工去 GitHub 更新对应的 Issue,增加不必要的维护成本。

为了解决这个问题,我们决定引入 GitHub Issues Integration 功能,打通 HagiCode 会话与 GitHub 仓库的连接,实现”一键同步”。

嘿,介绍一下我们正在做的东西

我们正在开发 HagiCode —— 一款 AI 驱动的代码智能助手,让开发体验变得更智能、更便捷、更有趣。

智能 —— AI 全程辅助,从想法到代码,让编码效率提升数倍。便捷 —— 多线程并发操作,充分利用资源,开发流程顺畅无阻。有趣 —— 游戏化机制和成就系统,让编码不再枯燥,充满成就感。

项目正在快速迭代中,如果你对技术写作、知识管理或者 AI 辅助开发感兴趣,欢迎来 GitHub 看看~


技术选型:前端直连 vs 后端代理

Section titled “技术选型:前端直连 vs 后端代理”

在设计集成方案时,摆在我们面前的有两条路:传统的”后端代理模式”和更激进的”前端直连模式”。

在传统的后端代理模式中,前端所有的请求都要先经过我们的后端,再由后端去调用 GitHub API。这虽然逻辑集中,但给后端带来了不小的负担:

  1. 后端臃肿:需要编写专门的 GitHub API 客户端封装,还要处理 OAuth 的复杂状态机。
  2. Token 风险:用户的 GitHub Token 必须存储在后端数据库中,虽然可以加密,但毕竟增加了安全风险面。
  3. 开发成本:需要数据库迁移来存储 Token,还需要维护一套额外的同步服务。

前端直连模式则要轻量得多。在这个方案中,我们只利用后端来处理最敏感的”密钥交换”环节(OAuth callback),获取到 Token 后,直接存在浏览器的 localStorage 里。后续创建 Issue、更新评论等操作,直接由前端发 HTTP 请求到 GitHub。

对比维度后端代理模式前端直连模式
后端复杂度需要完整的 OAuth 服务和 GitHub API 客户端仅需一个 OAuth 回调端点
Token 管理需加密存储在数据库,有泄露风险存储在浏览器,仅用户自己可见
实施成本需数据库迁移、多服务开发主要是前端工作量
用户体验逻辑统一,但服务器延迟可能稍高响应极快,直接与 GitHub 交互

考虑到我们要的是快速集成和最小化后端改动,最终我们采用了”前端直连模式”。这就像给浏览器发了一张”临时通行证”,拿到证之后,浏览器就可以自己去 GitHub 办事了,不需要每次都找后端管理员批准。


在确定架构后,我们需要设计具体的数据流。整个同步流程的核心在于如何安全地获取 Token 并高效地利用它。

整个系统可以抽象为三个角色:浏览器(前端)、HagiCode 后端、GitHub。

+--------------+ +--------------+ +--------------+
| 前端 React | | 后端 | | GitHub |
| | | ASP.NET | | REST API |
| +--------+ | | | | |
| | OAuth |--+--------> /callback | | |
| | 流程 | | | | | |
| +--------+ | | | | |
| | | | | |
| +--------+ | | +--------+ | | +--------+ |
| |GitHub | +------------>Session | +----------> Issues | |
| |API | | | |Metadata| | | | | |
| |直连 | | | +--------+ | | +--------+ |
| +--------+ | | | | |
+--------------+ +--------------+ +--------------+

关键点在于:只有 OAuth 的一小步(获取 code 换 token)需要经过后端,之后的粗活累活(创建 Issue)都是前端直接跟 GitHub 打交道。

当用户点击 HagiCode 界面上的”Sync to GitHub”按钮时,会发生一系列复杂的动作:

用户点击 "Sync to GitHub"
1. 前端检查 localStorage 获取 GitHub Token
2. 格式化 Issue 内容(将 Proposal 转换为 Markdown)
3. 前端直接调用 GitHub API 创建/更新 Issue
4. 调用 HagiCode 后端 API 更新 Session.metadata (存储 Issue URL 等信息)
5. 后端通过 SignalR 广播 SessionUpdated 事件
6. 前端接收事件,更新 UI 显示"已同步"状态

安全问题始终是集成第三方服务的重中之重。我们做了以下考量:

  1. 防 CSRF 攻击:在 OAuth 跳转时,生成随机的 state 参数并存入 sessionStorage。回调时严格验证 state,防止请求被伪造。
  2. Token 存储隔离:Token 仅存储在浏览器的 localStorage 中,利用同源策略(Same-Origin Policy),只有 HagiCode 的脚本才能读取,避免了服务器端数据库泄露波及用户。
  3. 错误边界:针对 GitHub API 常见的错误(如 401 Token 过期、422 验证失败、429 速率限制),设计了专门的错误处理逻辑,给用户以友好的提示。

纸上得来终觉浅,咱们来看看具体的代码是怎么实现的。

后端只需要做两件事:存储同步信息、处理 OAuth 回调。

数据库变更 我们只需要在 Sessions 表增加一个 Metadata 列,用来存储 JSON 格式的扩展信息。

-- 添加 metadata 列到 Sessions 表
ALTER TABLE "Sessions" ADD COLUMN "Metadata" text NULL;

实体与 DTO 定义

src/HagiCode.DomainServices.Contracts/Entities/Session.cs
public class Session : AuditedAggregateRoot<SessionId>
{
// ... 其他属性 ...
/// <summary>
/// JSON metadata for storing extension data like GitHub integration
/// </summary>
public string? Metadata { get; set; }
}
// DTO 定义,方便前端序列化
public class GitHubIssueMetadata
{
public required string Owner { get; set; }
public required string Repo { get; set; }
public int IssueNumber { get; set; }
public required string IssueUrl { get; set; }
public DateTime SyncedAt { get; set; }
public string LastSyncStatus { get; set; } = "success";
}
public class SessionMetadata
{
public GitHubIssueMetadata? GitHubIssue { get; set; }
}

这是连接的入口。我们使用标准的 Authorization Code Flow。

src/HagiCode.Client/src/services/githubOAuth.ts
// 生成授权 URL 并跳转
export async function generateAuthUrl(): Promise<string> {
const state = generateRandomString(); // 生成防 CSRF 的随机串
sessionStorage.setItem('hagicode_github_state', state);
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: window.location.origin + '/settings?tab=github&oauth=callback',
scope: ['repo', 'public_repo'].join(' '),
state: state,
});
return `https://github.com/login/oauth/authorize?${params.toString()}`;
}
// 在回调页面处理 Code 换取 Token
export async function exchangeCodeForToken(code: string, state: string): Promise<GitHubToken> {
// 1. 验证 State 防止 CSRF
const savedState = sessionStorage.getItem('hagicode_github_state');
if (state !== savedState) throw new Error('Invalid state parameter');
// 2. 调用后端 API 进行 Token 交换
// 注意:这里必须经过后端,因为需要 ClientSecret,不能暴露在前端
const response = await fetch('/api/GitHubOAuth/callback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, state, redirectUri: window.location.origin + '/settings?tab=github&oauth=callback' }),
});
if (!response.ok) throw new Error('Failed to exchange token');
const token = await response.json();
// 3. 存入 LocalStorage
saveToken(token);
return token;
}

有了 Token 之后,我们就需要一个强有力的工具来调 GitHub API。

src/HagiCode.Client/src/services/githubApiClient.ts
const GITHUB_API_BASE = 'https://api.github.com';
// 核心请求封装
async function githubApi<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem('gh_token');
if (!token) throw new Error('Not connected to GitHub');
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json', // 指定 API 版本
},
});
// 错误处理逻辑
if (!response.ok) {
if (response.status === 401) throw new Error('GitHub Token 失效,请重新连接');
if (response.status === 403) throw new Error('无权访问该仓库或超出速率限制');
if (response.status === 422) throw new Error('Issue 验证失败,可能标题重复');
throw new Error(`GitHub API Error: ${response.statusText}`);
}
return response.json();
}
// 创建 Issue
export async function createIssue(owner: string, repo: string, data: { title: string, body: string, labels: string[] }) {
return githubApi(`/repos/${owner}/${repo}/issues`, {
method: 'POST',
body: JSON.stringify(data),
});
}

最后一步,就是把 HagiCode 的 Session 数据转换成 GitHub Issue 的格式。这有点像”翻译”工作。

// 将 Session 对象转换为 Markdown 字符串
function formatIssueForSession(session: Session): string {
let content = `# ${session.title}\n\n`;
content += `**> HagiCode Session:** #${session.code}\n`;
content += `**> Status:** ${session.status}\n\n`;
content += `## Description\n\n${session.description || 'No description provided.'}\n\n`;
// 如果是 Proposal 类型,添加额外字段
if (session.type === 'proposal') {
content += `## Chief Complaint\n\n${session.chiefComplaint || ''}\n\n`;
// 添加一个深链接,方便从 GitHub 跳回 HagiCode
content += `---\n\n**[View in HagiCode](hagicode://sessions/${session.id})**\n`;
}
return content;
}
// 点击同步按钮的主逻辑
const handleSync = async (session: Session) => {
try {
const repoInfo = parseRepositoryFromUrl(session.repoUrl); // 解析仓库 URL
if (!repoInfo) throw new Error('Invalid repository URL');
toast.loading('正在同步到 GitHub...');
// 1. 格式化内容
const issueBody = formatIssueForSession(session);
// 2. 调用 API
const issue = await githubApiClient.createIssue(repoInfo.owner, repoInfo.repo, {
title: `[HagiCode] ${session.title}`,
body: issueBody,
labels: ['hagicode', 'proposal', `status:${session.status}`],
});
// 3. 更新 Session Metadata (保存 Issue 链接)
await SessionsService.patchApiSessionsSessionId(session.id, {
metadata: {
githubIssue: {
owner: repoInfo.owner,
repo: repoInfo.repo,
issueNumber: issue.number,
issueUrl: issue.html_url,
syncedAt: new Date().toISOString(),
}
}
});
toast.success('同步成功!');
} catch (err) {
console.error(err);
toast.error('同步失败,请检查 Token 或网络');
}
};

通过这套”前端直连”方案,我们用最少的后端代码实现了 GitHub Issues 的无缝集成。

  1. 开发效率高:后端改动极小,主要是数据库加一个字段和一个简单的 OAuth 回调接口,大部分逻辑都在前端完成。
  2. 安全性好:Token 不经过服务器数据库,降低了泄露风险。
  3. 用户体验佳:直接从前端发起请求,响应速度快,不需要经过后端中转。

在实际部署时,有几个坑大家要注意:

  • OAuth App 设置:记得在 GitHub OAuth App 设置里填正确的 Authorization callback URL(通常是 http://localhost:3000/settings?tab=github&oauth=callback)。
  • 速率限制:GitHub API 对未认证请求限制较严,但用 Token 后通常足够(5000次/小时)。
  • URL 解析:用户输入的 Repo URL 千奇百怪,记得正则要匹配 .git 后缀、SSH 格式等情况。

目前的功能还是单向同步(HagiCode -> GitHub)。未来我们计划通过 GitHub Webhooks 实现双向同步,比如在 GitHub 里关闭 Issue,HagiCode 这边的会话状态也能自动更新。这需要我们在后端暴露一个 Webhook 接收端点,这也是下一步要做的有趣工作。

希望这篇文章能给你的第三方集成开发带来一点灵感!如果有问题,欢迎在 HagiCode GitHub 上提 Issue 讨论。