博客

  • Flutter + OpenHarmony 构建镜像命名规范

    Flutter + OpenHarmony 构建镜像命名规范

    用途:Flutter 打包 OpenHarmony/HarmonyOS 应用,同时支持 GitHub Actions 调用与本地 Docker 拉取打包。
    免责:本项目为社区开源工具,与华为技术有限公司无任何隶属或合作关系。"OpenHarmony" 为开放原子开源基金会项目名称。


    一、总体结构

    📦 2 个 GitHub 仓库(必需),不建文档仓库、不建 SDK 镜像仓库。

    <your-github-username>/flutter-ohos-builder              # 主仓库(Action + Docker 双入口)
    ├── action.yml                               # GitHub Actions 入口
    ├── Dockerfile                               # 镜像定义(Action + 本地共用)
    ├── entrypoint.sh                           # 容器入口:环境→构建→签名→产出
    ├── README.md                               # 用法 + 徽章 + 免责声明
    └── .github/workflows/
        ├── release.yml                          # tag 触发:多架构镜像推送 + 发布 v1
        └── selftest.yml                        # 拉模板仓库跑 E2E
    
    <your-github-username>/flutter-ohos-app-template        # 示例模板(必需,兼作 E2E 靶)
    ├── lib/main.dart
    ├── ohos/                                    # 鸿蒙平台目录
    └── pubspec.yaml
    

    核心原则:

    • 一个主仓库,多个 Tag,双入口(Action + 本地 Docker)
    • 模板仓库必需——Action 无法自测,必须由独立消费者跑 E2E
    • 不按版本拆多仓库,版本锁进 Tag

    二、命名规范

    仓库命名

    类型 命名 必需性 说明
    主仓库 flutter-ohos-builder ✅ 必需 Action + 本地 Docker 双入口
    模板仓库 flutter-ohos-app-template ✅ 必需 示例工程 + E2E 靶子
    文档仓库 — ❌ 不建 放主仓库 README + GitHub Pages
    SDK 镜像仓库 — ❌ 不建 许可风险,运行时下载+缓存

    命名规则

    • 全小写 + 连字符(kebab-case)
    • 用 ohos,不用 harmony / harmonyos(后者是华为商标)
    • 用 builder 而非 action(兼容本地 Docker 用法)
    • 模板仓库用 app-template 而非 builder-template,避免与主仓库混淆

    禁用词

    action、ci、mirror、harmony、harmonyos、hms


    三、Docker 镜像 / Tag 命名

    完整格式

    flutter<Flutter版本>-ohos-api<API版本>-<架构>
    

    标签体系

    Tag 示例 用途 说明
    latest 跟随最新稳定版 便捷拉取
    v1 / v1.2.3 语义版本 Action 引用用 @v1
    flutter3.22.0-ohos-api12-amd64 锁定工具链 生产可复现(Intel/AMD)
    flutter3.22.0-ohos-api12-arm64 锁定工具链 Apple Silicon
    flutter3.24.0-ohos-api12-amd64 锁定工具链 新版 Flutter

    命名细则

    • 全小写,点号用于版本,连字符用于分隔
    • 架构用 amd64 / arm64(Docker buildx 平台标准值 linux/amd64、linux/arm64);禁用 x64,会导致多架构 manifest 构建失败
    • API 版本用 api12(对应 DevEco 5.0),而非 DevEco 市场版本号 5.0.0——真正影响构建兼容性的是 API level
    • Flutter 版本须标 fork:openharmony-sig 的 flutter_flutter,版本形如 3.22.0-ohos;官方 3.22.0 不支持鸿蒙
    • 一个仓库,CI 矩阵自动产出所有 Tag

    四、镜像地址

    # GHCR(GitHub Container Registry)
    ghcr.io/<your-github-username>/flutter-ohos-builder:latest
    ghcr.io/<your-github-username>/flutter-ohos-builder:flutter3.22.0-ohos-api12-amd64
    
    # Action 引用
    <your-github-username>/flutter-ohos-builder@v1
    

    ⚠️ GHCR / Docker 要求全小写。CI 中 ${{ github.repository_owner }} 若含大写,需先转小写,否则推送报错。


    五、两种调用方式

    1. GitHub Actions

    - name: Build HarmonyOS App
      uses: <your-github-username>/flutter-ohos-builder@v1
      with:
        flutter-version: '3.22.0-ohos'      # 注意:是 openharmony-sig fork 版本
        ohos-api: '12'
        build-mode: 'release'
    

    2. 本地 Docker

    docker pull ghcr.io/<your-github-username>/flutter-ohos-builder:flutter3.22.0-ohos-api12-amd64
    
    docker run --rm -v "$PWD":/workspace \
      ghcr.io/<your-github-username>/flutter-ohos-builder:flutter3.22.0-ohos-api12-amd64 \
      flutter build hap --release
    

    ⚠️ 不提供"本地裸脚本"入口——鸿蒙 SDK 无法自由下载分发(需华为账号登录 DevEco),本地用户仍需自备 SDK;要免装环境请走 Docker。


    六、版本号与 Tag 对应

    场景 引用方式 稳定性
    跟随稳定版 @v1 或 :latest 随版本迁移
    生产可复现 @flutter3.22.0-ohos-api12-amd64 永久锁定
    主版本升级 @v2 破坏性变更时新建

    七、避坑清单

    坑 后果 正确做法
    名字带 harmony/harmonyos 商标风险 用 ohos
    名字带 action/ci/mirror 误解用途 用 builder
    架构用 x64 buildx 构建失败 用 amd64
    Tag 用 DevEco 版本号 5.0.0 随 IDE 漂移 用 API level api12
    Tag 不标 fork 版本 装到官方 Flutter,构建失败 标 3.22.0-ohos
    按版本拆多仓库 维护地狱 一仓库 + 多 Tag
    提供本地裸脚本 SDK 无法分发,死路 只保留 Docker 入口
    不建模板仓库 Action 无法自测 模板仓库必需
    建文档仓库 过度设计 README + Pages
    大小写混用 GHCR 报错 全小写

    八、README 免责声明(顶部固定)

    本项目为社区开源工具,用于 Flutter 构建 OpenHarmony 应用,
    与华为技术有限公司无任何隶属或合作关系。
    "OpenHarmony" 为开放原子开源基金会项目名称。
    "Flutter" 为 Google LLC 商标,本项目仅作为构建工具使用其社区适配版本。
    

    九、最终定稿(可直接抄)

    ┌──────────────────────────────────────────────────────────────┐
    │  仓库名:    flutter-ohos-builder                            │
    │  模板仓库:  flutter-ohos-app-template(必需)                │
    │  镜像名:    ghcr.io/<your-github-username>/flutter-ohos-builder  │
    │  Action:    <your-github-username>/flutter-ohos-builder@v1  │
    │  Tag 格式:  flutter<Flutter版本>-ohos-api<API版本>-<架构>    │
    │  架构取值:  amd64 / arm64                                    │
    │  版本策略:  latest + v1 + 具体工具链组合                     │
    │  免责声明:  社区工具,与华为/Google 无隶属关系               │
    │  不建:      文档仓库、SDK 镜像仓库、本地裸脚本入口           │
    └──────────────────────────────────────────────────────────────┘
    

    一句话记忆:

    主仓库 flutter-ohos-builder + 模板 flutter-ohos-app-template,Tag 用 flutter<版本>-ohos-api<API>-<架构>,架构用 amd64/arm64,Action 用 @v1,本地用 :具体Tag,全程避开 harmony/harmonyos 商标。


    十、建仓清单

    仓库 1:flutter-ohos-builder

    项 值
    Description GitHub Action to build Flutter apps into OpenHarmony .hap/.app and publish to AppGallery
    Visibility Public(Marketplace 硬性要求)
    License MIT
    Topics flutter openharmony harmonyos github-actions appgallery ci-cd flutter-ohos
    Initialize ✅ README、✅ .gitignore(Node)

    建完开:Settings → Actions → General → Workflow permissions → Read and write contents

    仓库 2:flutter-ohos-app-template

    项 值
    Description Minimal Flutter app for OpenHarmony, used to test flutter-ohos-builder end to end
    Visibility Public
    Initialize ✅ README、✅ .gitignore

    建完勾:Settings → General → Template repository(让它出现在 "Use this template" 列表)

    不建

    • ❌ 文档仓库(用主仓库 README + GitHub Pages)
    • ❌ SDK 镜像仓库(许可风险 + 体积,运行时下载 + actions/cache 缓存)
    • ❌ 本地裸脚本入口(SDK 无法自由分发)
  • 没有 Mac 也能上架 iOS:GitHub Actions 全自动构建 + 签名 + TestFlight 送审实录

    手边只有一台 Windows,却要交付一个能装进 iPhone、最终上 App Store 的客户端。
    本文是这次把「写字台」iOS 客户端从零做到 TestFlight 的完整记录,命令与 workflow 都可以直接抄。
    文中不含任何密钥、Team ID、Issuer ID 等敏感值——这些只存在于仓库 Secrets 里。

    一、先说结论

    flutter build ios / xcodebuild 只能在 macOS 上跑,Linux runner 产不出 iOS 产物。
    所以没有 Mac 就只有一条路:

    GitHub Actions 的 macos-latest runner(同类还有 Codemagic、Xcode Cloud,但 GitHub 与代码仓库同源,最小摩擦)。

    整个链路拆成两级,刻意分开:

    级别 workflow 触发 产出
    ① 不签名构建 ios.yml push / PR / 手动 Runner-unsigned.ipa(artifact,证明链路通)
    ② 签名 + 发布 ios-release.yml 手动 / tag v* 直接上传 TestFlight

    分开的原因很实际:macOS runner 计费是 Linux 的 10 倍,日常提交不该烧这个钱。

    二、动手前的三个决策(先问再写代码)

    问题 为什么关键
    客户端开源吗? 决定仓库可见性。若后端仓库已是 public,闭源客户端根本放不进去,必须独立建库
    只做 iOS 还是双端? 一套 Flutter 出 iOS + Android,独立仓库更合适
    有 Apple Developer 账号吗? 没有就只能止步于「未签名构建」;有($99/年)才能接签名与 TestFlight

    必须先纠正的一个误解

    新建仓库不会多拿 Actions 额度。 GitHub Free 的额度是账号级共享的:

    仓库可见性 Actions 额度 macOS runner 倍率
    public 无限免费 免费
    private 2000 分钟/月(全部私有仓库共享) ×10

    私有仓库那 2000 分钟,换算到 macOS 上实际只有约 200 分钟。一次冷缓存构建 15~25 分钟,
    一个月只够跑十几次——开发期完全不够用。能公开就公开。

    三、生成骨架,然后把标识改对

    flutter create 是必须的(它生成 ios/、android/ 原生工程),不能手写:

    flutter create --platforms=ios,android \
      --org cn.example --project-name my_app \
      --description "..." \
      --no-pub .
    
    • --platforms=ios,android:不要 web/desktop,少一堆噪音目录
    • --no-pub:生成阶段别卡在网络上
    • --project-name 决定 Dart 包名(只能小写 + 下划线),和仓库名是两回事:
      仓库用连字符(my-app),Dart 包名用下划线(my_app)

    生成完必须显式改标识,一处一处来:

    位置 改什么
    ios/Runner.xcodeproj/project.pbxproj PRODUCT_BUNDLE_IDENTIFIER(3 处主 target + 3 处 RunnerTests,整串替换可一次覆盖)
    ios/Runner/Info.plist CFBundleDisplayName(中文名)、CFBundleName(建议 ASCII)
    android/app/build.gradle namespace 与 applicationId
    android/app/src/main/AndroidManifest.xml android:label
    Kotlin 包目录 目录搬家 + package 声明

    ⚠️ Bundle ID 近乎不可逆:一旦在 App Store Connect 建了 App 记录就不建议再改。
    所以生成骨架后、建 App 记录前,确认一次。
    ⚠️ 另外注意:flutter create 给 iOS 生成的是驼峰 id(下划线在 iOS 侧不合法)。

    四、第一阶段:不签名构建(零 Apple 配置就能绿)

    第一里程碑只做 --no-codesign,把「环境 + 编译」的问题先隔离出来:

    name: iOS Build
    
    on:
      push:
        branches: [main]
      pull_request:
      workflow_dispatch:
    
    concurrency:
      group: ios-build-${{ github.ref }}
      cancel-in-progress: true
    
    env:
      FLUTTER_VERSION: '3.47.6'   # 钉住,别用 latest
    
    jobs:
      build-ios:
        runs-on: macos-latest
        steps:
          - uses: actions/checkout@v4
          - uses: subosito/flutter-action@v2
            with:
              flutter-version: ${{ env.FLUTTER_VERSION }}
              channel: stable
              cache: true
          - name: 环境信息(排错用)
            run: |
              flutter --version
              xcodebuild -version
              pod --version || true
          - run: flutter pub get
          - run: flutter analyze
          - run: flutter test
          - name: 构建 iOS(不签名)
            run: flutter build ios --release --no-codesign
          - name: 核对产物信息
            run: |
              set -euo pipefail
              APP=build/ios/iphoneos/Runner.app
              test -d "$APP" || { echo "::error::没有找到 $APP"; exit 1; }
              du -sh "$APP"
              for k in CFBundleIdentifier CFBundleName CFBundleShortVersionString MinimumOSVersion; do
                printf '%-30s = ' "$k"
                /usr/libexec/PlistBuddy -c "Print :$k" "$APP/Info.plist" 2>/dev/null || echo "(无)"
              done
          - name: 打包成未签名 ipa
            run: |
              set -euo pipefail
              rm -rf Payload Runner-unsigned.ipa
              mkdir -p Payload
              cp -R build/ios/iphoneos/Runner.app Payload/
              zip -qry Runner-unsigned.ipa Payload
              ls -lh Runner-unsigned.ipa
          - uses: actions/upload-artifact@v4
            with:
              name: ios-unsigned-ipa
              path: Runner-unsigned.ipa
              if-no-files-found: error
    

    几个要点:

    • ipa 本质就是个目录结构:Payload/Runner.app 打包改名 .ipa。未签名的包不能装机,
      它的价值是证明「环境 + 编译 + 打包」这条链路是通的。
    • if-no-files-found: error:产物没生成必须让这一步红,否则会「绿着但什么都没产出」。
    • flutter analyze / flutter test 放在构建前面:编译要几分钟,静态问题早失败早省时间。
    • concurrency + cancel-in-progress:连续推送时取消旧构建,省 macOS 分钟。

    绿灯不等于对了

    退出码 0 只说明进程没崩。产物里的标识必须读日志核对,比如:

    CFBundleIdentifier = cn.xiezitai.app
    

    看到这一行,才能说「bundle id 改对了」。

    五、第二阶段:签名 + 直传 TestFlight

    关键认知:不需要 Mac,也不需要在本机导出证书。走 App Store Connect API Key,
    让 Xcode 自己向苹果申请证书与描述文件,用完即弃。

    前提(不满足会卡住)

    前提 说明
    App Store Connect 里已建好 App 记录 只有注册了 bundle id 不够,必须有 App 记录,否则 TestFlight 没有落点
    已同意最新版协议 协议、税务和银行业务里若有黄色横幅未点 → 上传被拒
    Apple ID 是 Account Holder 或 Admin 只有这两个角色能建 API Key

    四个 Secret(值只放仓库里)

    Secret 从哪来
    APP_STORE_CONNECT_ISSUER_ID App Store Connect → 用户和访问 → 集成 → App Store Connect API,页面顶部的 UUID
    APP_STORE_CONNECT_KEY_ID 同页,新建 Key 那行的 Key ID
    APP_STORE_CONNECT_API_KEY 该 Key 的 .p8 文件全文(含 BEGIN/END 两行),只能下载一次
    APPLE_TEAM_ID developer.apple.com/account → Membership details

    ⚠️ Team ID ≠ Issuer ID:一个在 Apple Developer,一个在 App Store Connect,填反了报认证失败。
    ⚠️ 必须建 Team Key:苹果明确限制 Individual Key 无法访问 Provisioning 接口,自动签名会直接失败。
    角色选择:要自动签证书 → Admin;只上传构建 → App Manager 也够。

    三个硬事实(苹果改过多次,2026 年核实)

    1. ExportOptions.plist 的 method 用 app-store-connect(旧的 app-store 已废弃)
    2. destination 设成 upload 时,-exportArchive 导出即上传,不必再单独调 xcrun altool(altool 也已进入废弃流程)
    3. -allowProvisioningUpdates 要在 archive 和 -exportArchive 两条命令上都带,并同时给
      -authenticationKeyPath / -authenticationKeyID / -authenticationKeyIssuerID
    # ① 先跑一遍不签名构建:目的让 Flutter 生成 Generated.xcconfig 并执行 pod install,
    #    否则后面的 xcodebuild archive 用 workspace 归档会找不到 Pods
    flutter build ios --release --no-codesign --build-number=${{ github.run_number }}
    
    # ② 归档
    xcodebuild archive \
      -workspace ios/Runner.xcworkspace -scheme Runner \
      -configuration Release -destination 'generic/platform=iOS' \
      -archivePath "$RUNNER_TEMP/Runner.xcarchive" \
      -allowProvisioningUpdates \
      -authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8" \
      -authenticationKeyID "$ASC_KEY_ID" -authenticationKeyIssuerID "$ASC_ISSUER_ID" \
      DEVELOPMENT_TEAM="$TEAM_ID" CODE_SIGN_STYLE=Automatic
    
    # ③ 导出 + 直接上传(method=app-store-connect, destination=upload)
    xcodebuild -exportArchive \
      -archivePath "$RUNNER_TEMP/Runner.xcarchive" \
      -exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" \
      -exportPath "$RUNNER_TEMP/export" \
      -allowProvisioningUpdates \
      -authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8" \
      -authenticationKeyID "$ASC_KEY_ID" -authenticationKeyIssuerID "$ASC_ISSUER_ID"
    

    ExportOptions.plist 在 CI 里用 heredoc 现生成(免得把 teamID 写死在仓库里):

    <key>method</key><string>app-store-connect</string>
    <key>destination</key><string>upload</string>
    <key>teamID</key><string>${TEAM_ID}</string>
    <key>signingStyle</key><string>automatic</string>
    <key>uploadSymbols</key><true/>
    <key>manageAppVersionAndBuildNumber</key><false/>
    

    ⚠️ YAML 的 run: | 块标量会剥掉公共缩进,heredoc 终止符必须顶格,否则会把后面所有内容吃掉。

    构建号必须唯一

    App Store Connect 拒绝重复的 CFBundleVersion。用
    --build-number=${{ github.run_number }} 最省心(run number 每个 workflow 各自自增,天然唯一),
    配合 Info.plist 里的 CFBundleVersion = $(FLUTTER_BUILD_NUMBER) 生效。

    .p8 一定要自检

    手工把 .p8 粘进 Secret,最常见的错误是漏了 BEGIN/END 两行或前后带别的文本,
    结果报一个看不懂的认证失败。上传前加一步自检,把错误提前成一句人话:

    head -1 "$KEY" | grep -q 'BEGIN PRIVATE KEY' || { echo "::error::.p8 首行不完整"; exit 1; }
    tail -1 "$KEY" | grep -q 'END PRIVATE KEY'   || { echo "::error::.p8 末行不完整"; exit 1; }
    

    key 放到 ~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8(xcodebuild 认这个路径),
    最后加一步 if: always() 的清理把它删掉。

    不要在 push 到 main 时跑发布

    ios-release.yml 只挂 workflow_dispatch + tag v*。发新版本 = 打一个 tag:

    git tag -a v1.1.2 -m "v1.1.2: 仅 iPhone 支持"
    git push origin v1.1.2
    

    ⚠️ 只推 main 不打 tag,TestFlight 永远看不到新版本——这是最容易踩的坑:
    代码推上去了、构建也是绿的,但发布流程根本没被触发。

    六、踩过的坑(都是真金白银换的)

    坑 1:新版 Flutter 会就地迁移你的 iOS 工程

    构建日志里会出现:

    Updating minimum iOS deployment target to 15.0.
    Upgrading project.pbxproj / AppFrameworkInfo.plist / Runner.xcscheme
    Finished migration to UIScene lifecycle.
    

    这是 Flutter 3.47 在当场升级旧模板(部署目标抬到 15.0、切 UIScene 生命周期)。
    它只作用于 CI 的临时工作区,不提交回仓库,所以每次构建都重做一遍。

    • 别当成报错;
    • 但要把仓库里的 IPHONEOS_DEPLOYMENT_TARGET、AppFrameworkInfo.plist 的
      MinimumOSVersion 对齐到 Flutter 的下限
      ,否则仓库值与「实际构建的东西」长期不一致。

    坑 2:本地 Flutter 与 CI 版本不一致,会「本地能跑 CI 挂」

    开发机上装的是定制分支(本例是鸿蒙分支),上游 stable 又是另一个版本,
    它生成的 iOS/Android 模板与 CI 不是一套。

    处理:CI 里钉死版本(flutter-version: '3.47.6'),README 写明本地该用哪个,
    别用 channel: stable 漂移;版本号用官方 release 清单核对,别猜。

    坑 3:macOS runner 排队,别误判成卡死

    macos-latest 机器池比 Linux 紧张得多,run 会先 status: queued 一段时间(实测十几分钟还没起)。

    • 排队期间 job 的 steps 数组是空的,只按 step 状态轮询会「一行都不输出」,看着像脚本挂了;
      要把 job 级 status 也纳入判断。
    • 排队超过 ~30 分钟才值得怀疑;单纯慢不用重跑(重跑要重新排队)。
    • 两条 macOS 任务同时排队时,超过 15 分钟可能被自动取消,隔几分钟重试即可。

    坑 4:读 job 日志 401 —— 302 到别的主机还带着 Authorization

    GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs 会 302 到预签名存储地址,
    urllib/requests 默认把 Authorization 头一起带过去,存储端不认 → 401。

    解法:重定向跨主机时剥掉 Authorization。

    class NoAuthRedirect(urllib.request.HTTPRedirectHandler):
        def redirect_request(self, req, fp, code, msg, headers, newurl):
            new = super().redirect_request(req, fp, code, msg, headers, newurl)
            if new is not None and urllib.parse.urlsplit(newurl).netloc != urllib.parse.urlsplit(req.full_url).netloc:
                new.headers.pop('Authorization', None)
            return new
    
    op = urllib.request.build_opener(NoAuthRedirect)
    

    坑 5:Windows 本机侧的杂音

    坑 现象 处理
    Git Bash 缺 coreutils mkdir/head/grep 全 command not found 用 Python 的 os.makedirs 等替代
    后台命令拿不到 stdout 只留一句「completed」 命令里重定向到磁盘文件,再读文件
    git push 无输出挂住 HTTPS + GCM 凭据助手 指定 wincred 助手,并设 GIT_TERMINAL_PROMPT=0、GCM_INTERACTIVE=never
    flutter test 报 Invalid WebSocket upgrade request 本机代理拦了 flutter_tester 的 localhost WebSocket 清代理变量 + NO_PROXY=*;系统代理也会拦,此时别再折腾本地,验证交给 CI
    CRLF/LF 混用 project.pbxproj 出现整文件 diff 加 .gitattributes:* text=auto eol=lf,工程文件显式 text eol=lf

    七、没有 Mac,怎么在本地验证 workflow?

    没有 Mac 也能把大半错误挡在提交前(实测有效):

    1. 缩进里不能有 TAB(YAML 明令禁止);
    2. 把每个 run: | 块剥掉公共缩进拆成 .sh,用 bash -n 做语法检查(${{ }} 先替换成占位符);
    3. heredoc 内容真跑一遍,再用 XML 解析器验证产出的 plist 合法;
    4. 检查 heredoc 终止符是否顶格。

    但 macos-latest 上的 Xcode 版本、签名行为只能靠真跑一次。
    所以顺序是:本地静态校验 → 推送 → 立刻 workflow_dispatch 跑一次看日志,不要「推上去就当完成」。

    八、构建通了之后:提审还要补的东西

    这部分和 CI 无关,但迟早要面对,一并记下:

    事项 要点
    出口合规 只用系统 TLS(HTTPS)→ 属豁免项;Info.plist 加 ITSAppUsesNonExemptEncryption = false,此后所有版本都不再弹加密问答
    截图 若 App 声明支持 iPad,就必须交 13 寸 iPad 截图。不需要 iPad 支持就把 target 改成仅 iPhone(TARGETED_DEVICE_FAMILY = 1),要求随之消失
    应用内删账号 有账号体系就必须提供 App 内注销入口(境外审核尤其看重)
    UGC 合规 先审后发 + 举报 + 管理员删除,是社区类 App 的标配;Review Notes 里写清机制
    隐私标签 逐项如实勾选,且都不勾「追踪」,最终显示「未收集用于追踪你的数据」最干净
    演示账号 App 内无法注册时,必须在 Review Notes 给一个可用的测试账号

    九、最终成型的两段式流水线

    push / PR ──▶ ios.yml(macos-latest)
                    ├─ flutter analyze / test
                    ├─ flutter build ios --no-codesign
                    └─ artifact: Runner-unsigned.ipa
    
    tag v* / 手动 ──▶ ios-release.yml(macos-latest)
                    ├─ 落 .p8 到 ~/.appstoreconnect/private_keys(并自检 BEGIN/END)
                    ├─ flutter build ios --no-codesign --build-number=<run_number>
                    ├─ xcodebuild archive -allowProvisioningUpdates
                    ├─ 核对归档 Info.plist(identifier / version / minOS)
                    └─ xcodebuild -exportArchive(method=app-store-connect,
                       destination=upload)→ 直达 TestFlight
    

    实测一次 tag 发布:约 8 分钟跑完(含排队),日志里能看到
    ARCHIVE SUCCEEDED / Upload succeeded / EXPORT SUCCEEDED,
    之后 App Store Connect 处理几分钟,TestFlight 就能看到新构建。

    十、交付前检查清单

    • 仓库可见性已按「是否开源」确认,并清楚 macOS runner 的 ×10 计费
    • Dart 包名(下划线)与仓库名(连字符)都对
    • bundle id 已显式改成目标值,且 pbxproj / Info.plist / build.gradle / Kotlin 包目录四处一致
    • CI 钉住 Flutter 版本,README 写明本地版本
    • 第一里程碑是 --no-codesign,产物 ipa 上传 artifact
    • 读过构建日志核对 CFBundleIdentifier / MinimumOSVersion,不只是看绿灯
    • 仓库里的 IPHONEOS_DEPLOYMENT_TARGET 已对齐 Flutter 下限
    • .gitattributes 已统一 LF
    • Secrets 齐了再动签名部分,不做未验证的 workflow
    • 发布走 tag 或手动,别挂 push
    • 记住:只推 main 不打 tag,新版本不会进 TestFlight

    整条链路走通之后,这台 Windows 电脑就再没碰过任何证书文件——
    archive、签名、上传全部在 GitHub 的一次性 macOS runner 上完成,密钥只在运行期存在于 runner 的内存与临时目录里,跑完即删。
    对个人开发者来说,这就是「没有 Mac 也能上架」的现实解法。

  • 文章分发新增「写字台」渠道:一键发到另一台写字台账号

    之前写完「分发」功能,只能把文章推到关联的 WordPress 站点 和 博客园账号。这次补上第三个渠道:另一台写字台。

    需求

    1. 文章列表界面点「分发」按钮,目标里可以选写字台账号;
    2. 要记住哪些写字台账号已经分发过;已发过的,用户能决定是「更新之前分发的文章」还是「分发一个新文章」;
    3. 分发时可选原文分发或转载分发;转载的话,发出去的文章尾部要带上转载链接。

    为什么写字台渠道不能照抄 WordPress

    写字台之间本来就是靠一套开放 API 互通的(/api/v1/articles 读、/api/v1/publish 写),所以「分发」这一步天然就能复用关联账号时填好的接口地址与对接密钥。但有三个地方必须单独处理。

    一、正文格式要按渠道归一

    分发的「正文格式」是个全局选项:WordPress 站点没装 Markdown 插件时要选「转成 HTML」,博客园则要送 Markdown 原文(并带上 [Markdown] 分类)。

    问题是,写字台的开放 API 收的正文就是 Markdown——对方入库后由前台按 Markdown 渲染。如果一次分发里既有 WordPress 又有写字台,用户把格式选成 HTML,那发到写字台的就是一堆 <p> 标签,代码块和表格全废。

    所以写字台渠道忽略请求里的格式选项,一律按 Markdown 原文发,并在结果里回一条说明(⚠ 写字台正文一律按 Markdown 发送,已忽略「转成 HTML」),让用户知道选项被无视了、而不是悄悄失效。

    二、往同一个站发第二篇,不能带 slug

    「分发一个新文章」意味着同一篇原文会在同一台写字台上出现第二份。如果照 WP 的做法把本地 slug 传过去,对方的 articles.slug 唯一索引当场就撞了——过去的表现是对方接口 500,而调用方只看到一句「对方接口返回 HTTP 500」,根本猜不到是 slug 冲突。

    处理办法是发写字台时干脆不传 slug,让对端按标题自己生成(对端本来就有「同前缀自动加序号」的逻辑)。因为「更新」走的是远端文章 id,跟 slug 没有任何关系,所以不传 slug 不影响后续更新。

    顺带把对方的发布接口自身也加固了:显式传了 slug 且已存在时,自动退化成 xxx-2、xxx-3,不再是 500。

    三、对端原来只能「发」,不能「改」

    要支持「更新之前分发的文章」,对端必须有个更新接口——而原来只有 POST /api/v1/publish(只会新建)。于是补了一个:

    PUT /api/v1/articles/{id}
    

    权限上做了收敛:只认「Token 所属账号 == 文章作者」的那一篇,别人的文章一律 403;没有 Token 401;不改 slug、不发通知。否则任何一个持有 Token 的账号都能篡改全站文章,那是比功能缺失严重得多的问题。

    没传的字段(摘要、封面、SEO 关键词)保持原值——分发时本地摘要为空,不该把对方已有的摘要抹掉。

    数据结构:还是那一张表

    分发记录表本来就以 文章 × 目标 为粒度,记录远端 id、远端链接、分发次数与时间。写字台渠道只是多了一个 channel 取值(xz),行里顺手存下对端的接口地址与账号名快照——账号被删或被改名后,历史记录仍然显示成人看得懂的样子,而不是一条光秃秃的 id。

    删掉一个写字台账号时,指向它的分发记录也一并清掉,免得列表上留下一堆点不开的「已分发」徽标。

    前端

    分发弹窗的目标清单按渠道分组:写字台账号 / WordPress 站点 / 博客园账号。已分发过的目标会自动勾上,并带出「处理方式」下拉(默认「更新之前分发的文章」)与「查看已发文章」链接;文章列表行上用「已分发 · 账号名」徽标一眼看出这篇发过哪些地方。

    写字台面板上也加了一句提示:同一个账号既是导入源,也是分发目标。

    验证

    • 单测/集成测试 60/60 通过:新增两组——开放 API 的更新接口(越权 403、无 Token 401、缺字段 400、不存在 404、显式 slug 撞车自动去重),以及分发到写字台(目标清单、Markdown 归一、转载尾注、更新复用远端 id、徽标、删账号清记录)。
    • 端到端 24/24 通过:新写了一个脚本,用本地 mock 的「对方写字台」把整条链路真跑一遍——分组渲染、未分发过时不显示「处理方式」、转载分发后对方收到的是 Markdown 原文且尾部带「本文由写字台首发 + 原文链接」、列表出现「已分发」徽标、再开弹窗显示「已分发过 1 次」并默认「更新」、选更新时对方只收到 PUT 且远端 id 不变、选「另发新篇」时才又走 POST 并拿到新 id。
    • 回归:原有的 WordPress / 博客园分发脚本 25/25、写字台跨站导入脚本 29/29 仍全绿。

    一个细节:对方接口的链接回填必须用返回值拼绝对地址(url 字段是根级路径),否则列表里点开的「查看已发文章」会指到本站域名上、变成 404。

    小结

    分发这件事的核心从来不是「把字符串 POST 出去」,而是三件配套的事:记住发过谁(幂等的前提)、能更新而不是重复创建(内容演进的前提)、让对方站点收到的是它真正能渲染的形态(不然功能「成功」了但页面是坏的)。这三点在三个渠道上表现各不相同,得逐个想清楚。

  • 文章分发新增「写字台」渠道:一键发到另一台写字台账号

    之前写完「分发」功能,只能把文章推到关联的 **WordPress 站点** 和 **博客园账号**。这次补上第三个渠道:**另一台写字台**。

    ## 需求

    1. 文章列表界面点「分发」按钮,目标里可以选写字台账号;
    2. 要**记住哪些写字台账号已经分发过**;已发过的,用户能决定是「更新之前分发的文章」还是「分发一个新文章」;
    3. 分发时可选**原文分发**或**转载分发**;转载的话,发出去的文章尾部要带上转载链接。

    ## 为什么写字台渠道不能照抄 WordPress

    写字台之间本来就是靠一套开放 API 互通的(`/api/v1/articles` 读、`/api/v1/publish` 写),所以「分发」这一步天然就能复用关联账号时填好的接口地址与对接密钥。但有三个地方必须单独处理。

    ### 一、正文格式要按渠道归一

    分发的「正文格式」是个全局选项:WordPress 站点没装 Markdown 插件时要选「转成 HTML」,博客园则要送 Markdown 原文(并带上 `[Markdown]` 分类)。

    问题是,**写字台的开放 API 收的正文就是 Markdown**——对方入库后由前台按 Markdown 渲染。如果一次分发里既有 WordPress 又有写字台,用户把格式选成 HTML,那发到写字台的就是一堆 `

    ` 标签,代码块和表格全废。

    所以写字台渠道**忽略请求里的格式选项**,一律按 Markdown 原文发,并在结果里回一条说明(`⚠ 写字台正文一律按 Markdown 发送,已忽略「转成 HTML」`),让用户知道选项被无视了、而不是悄悄失效。

    ### 二、往同一个站发第二篇,不能带 slug

    「分发一个新文章」意味着同一篇原文会在同一台写字台上出现第二份。如果照 WP 的做法把本地 slug 传过去,对方的 `articles.slug` 唯一索引当场就撞了——过去的表现是对方接口 500,而调用方只看到一句「对方接口返回 HTTP 500」,根本猜不到是 slug 冲突。

    处理办法是**发写字台时干脆不传 slug**,让对端按标题自己生成(对端本来就有「同前缀自动加序号」的逻辑)。因为「更新」走的是远端文章 id,跟 slug 没有任何关系,所以不传 slug 不影响后续更新。

    顺带把**对方的发布接口自身也加固了**:显式传了 slug 且已存在时,自动退化成 `xxx-2`、`xxx-3`,不再是 500。

    ### 三、对端原来只能「发」,不能「改」

    要支持「更新之前分发的文章」,对端必须有个更新接口——而原来只有 `POST /api/v1/publish`(只会新建)。于是补了一个:

    “`
    PUT /api/v1/articles/{id}
    “`

    权限上做了收敛:**只认「Token 所属账号 == 文章作者」的那一篇**,别人的文章一律 403;没有 Token 401;不改 slug、不发通知。否则任何一个持有 Token 的账号都能篡改全站文章,那是比功能缺失严重得多的问题。

    没传的字段(摘要、封面、SEO 关键词)保持原值——分发时本地摘要为空,不该把对方已有的摘要抹掉。

    ## 数据结构:还是那一张表

    分发记录表本来就以 **文章 × 目标** 为粒度,记录远端 id、远端链接、分发次数与时间。写字台渠道只是多了一个 `channel` 取值(`xz`),行里顺手存下对端的接口地址与账号名快照——**账号被删或被改名后,历史记录仍然显示成人看得懂的样子**,而不是一条光秃秃的 id。

    删掉一个写字台账号时,指向它的分发记录也一并清掉,免得列表上留下一堆点不开的「已分发」徽标。

    ## 前端

    分发弹窗的目标清单按渠道分组:**写字台账号 / WordPress 站点 / 博客园账号**。已分发过的目标会自动勾上,并带出「处理方式」下拉(默认「更新之前分发的文章」)与「查看已发文章」链接;文章列表行上用「已分发 · 账号名」徽标一眼看出这篇发过哪些地方。

    写字台面板上也加了一句提示:同一个账号既是**导入源**,也是**分发目标**。

    ## 验证

    – **单测/集成测试 60/60 通过**:新增两组——开放 API 的更新接口(越权 403、无 Token 401、缺字段 400、不存在 404、显式 slug 撞车自动去重),以及分发到写字台(目标清单、Markdown 归一、转载尾注、更新复用远端 id、徽标、删账号清记录)。
    – **端到端 24/24 通过**:新写了一个脚本,用本地 mock 的「对方写字台」把整条链路真跑一遍——分组渲染、未分发过时不显示「处理方式」、转载分发后对方收到的是 Markdown 原文且尾部带「本文由写字台首发 + 原文链接」、列表出现「已分发」徽标、再开弹窗显示「已分发过 1 次」并默认「更新」、选更新时对方只收到 `PUT` 且远端 id 不变、选「另发新篇」时才又走 `POST` 并拿到新 id。
    – **回归**:原有的 WordPress / 博客园分发脚本 25/25、写字台跨站导入脚本 29/29 仍全绿。

    一个细节:对方接口的**链接回填**必须用返回值拼绝对地址(`url` 字段是根级路径),否则列表里点开的「查看已发文章」会指到本站域名上、变成 404。

    ## 小结

    分发这件事的核心从来不是「把字符串 POST 出去」,而是三件配套的事:**记住发过谁**(幂等的前提)、**能更新而不是重复创建**(内容演进的前提)、**让对方站点收到的是它真正能渲染的形态**(不然功能「成功」了但页面是坏的)。这三点在三个渠道上表现各不相同,得逐个想清楚。

  • 写字台(xiezitai)项目总结:一个自托管、SEO 与 AI 友好的博客系统

    写字台(xiezitai)项目总结

    一、一句话介绍

    写字台 是一套自托管博客系统,主打两件事:对 SEO 友好、对 AI 友好。

    它既是一个能直接部署上线的个人博客,也是一个对外开放的内容底座——外部系统可以通过 REST API 发布文章,AI 可以通过 MCP 直接写文章、查文章。

    二、技术栈

    层次 选型
    语言 / 框架 Java 17 · Spring Boot 3
    持久层 Spring Data JPA
    数据库 MySQL 8.4 / MariaDB 11.4 / H2(lite 精简模式)
    认证 JWT + TOTP 两步验证
    页面渲染 Thymeleaf(服务端渲染,利于 SEO)
    内容编辑器 ByteMD(Markdown)
    前端依赖 github-markdown-css / Mermaid / highlight.js 全部本地内置,不走任何 CDN,可离线或内网部署
    端到端测试 Playwright + 真实 Chrome

    三、功能一览

    模块 说明
    文章 Markdown 写作(ByteMD)、slug、SEO 字段、浏览计数;媒体库弹窗上传/挑选,图片入 Markdown、视频入 video 标签;支持粘贴截图与拖拽文件自动入库;媒体输出支持 HTTP Range(视频可拖动进度条);代码块自动语法高亮(46 种语言)+ 语言标签 + 一键复制
    页面 自定义页面(关于、友链等),顶部导航直接可点
    评论 仅限登录用户评论(禁止匿名),作者名取自登录账号、不可伪造;提交后待管理员审核,通过后公开;文章页内嵌登录/注册弹窗
    用户 注册需审核:新账号为「待审核」,站长通过后才能登录,驳回可填原因;ADMIN / USER 角色;可按用户限制上传类型
    文件 上传默认仅图片/视频;媒体不直接对外,统一经 Spring 输出并记录访问日志
    安全 TOTP 两步验证;密码错误 3 次锁 5 分钟、5 次锁 10 分钟、10 次锁 1 小时;全量请求日志;文件魔数扫描 + 孤立文件检测;程序防篡改基线校验
    机器人通知 企业微信 webhook 推送登录、文章发布、访问来源、注册、评论、上传等事件,可逐项开关
    AI 接入任意 OpenAI 兼容接口:文章润色纠错、自动摘要、生成公众号尺寸(900×383)封面图、请求日志安全风险分析
    文章分发 把文章一键发到关联的 WordPress 站点 / 博客园账号(可多选);站内媒体自动补成绝对地址;可选原文分发或转载分发(文末附首发链接);正文可按 Markdown 原文或转 HTML 发出;已分发过的目标会被记住,下次可选「更新原文章」或「发新文章」
    开放 API POST /api/v1/publish,通过 X-API-Token 鉴权
    MCP POST /api/v1/mcp(JSON-RPC 2.0),内置 publish_article / list_articles / get_article 三个工具
    站内搜索 首页 /?q= 搜索:标题 / 摘要 / 正文 / 标签四处命中,只搜已发布内容;纯 GET 表单 + 服务端渲染,链接可分享、可被爬虫抓取;关键词带进翻页与 canonical
    SEO 服务端渲染、robots.txt、sitemap.xml、OG 标签、canonical、rel prev/next
    后台 列表行内操作统一为图标按钮,带中文悬浮提示与无障碍名称

    四、安全设计

    把「自托管」当回事,安全能力是按真实攻击面一项项加的:

    1. 两步验证:管理员可开启 TOTP,扫码或手填密钥绑定。
    2. 登录失败阶梯锁定:连续错 3 次锁 5 分钟、5 次锁 10 分钟、10 次锁 1 小时。
    3. 全量请求日志:所有请求留痕,可按来源、路径、状态回看。
    4. 媒体不直出:上传的图片/视频不交给 Web 服务器直接暴露,而是经应用输出并记录访问日志,谁在什么时候访问了什么一目了然。
    5. 上传白名单 + 魔数扫描:普通用户默认只能传图片、视频,按文件头识别真实类型,防止改名的木马;管理员不受限,并可给用户配置类型限制。
    6. 文件安全扫描:扫描全部上传文件,既能发现恶意文件,也能揪出「没经过系统上传」的散落文件。
    7. 防篡改:对程序自身做基线校验,识别是否被改动。

    部署时的关键一条:JWT 密钥必须自己设置。不设会落到内置默认值,任何人都能伪造登录令牌。数据库密码、JWT 密钥、AI Key 等一律通过环境变量注入,镜像里不写死任何生产配置。

    五、AI 能力

    • 后台可配置任意 OpenAI 兼容的大模型接口(对话 + 文生图)。
    • 已落地四个场景:文章润色纠错、自动写摘要、生成封面图(公众号 900×383 尺寸)、请求日志风险分析(用模型辅助识别异常访问)。
    • 默认对接魔搭 ModelScope,其文生图是异步任务协议(先返回 task_id 再轮询),项目已自动适配,同时兼容 OpenAI 的同步返回形式。

    六、开放 API 与 MCP:让外部系统和 AI 都能写博客

    这是这个项目比较有意思的一块——博客不只是给人看的,也是给程序用的。

    REST 发布

    curl -X POST https://xiezitai.cn/api/v1/publish \
      -H "Content-Type: application/json" \
      -H "X-API-Token: <你的 Token>" \
      -d '{"title":"标题","content":"# Markdown 正文"}'
    

    MCP 服务(JSON-RPC 2.0,工具:publish_article / list_articles / get_article):

    curl -X POST https://xiezitai.cn/api/v1/mcp \
      -H "Content-Type: application/json" \
      -H "X-API-Token: <你的 Token>" \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
    

    支持远程 HTTP MCP 的客户端(Cursor / VS Code / Claude Code / 支持自定义连接器的 AI 客户端):

    {
      "mcpServers": {
        "xiezitai": {
          "type": "http",
          "url": "https://xiezitai.cn/api/v1/mcp",
          "headers": { "X-API-Token": "<你的 Token>" }
        }
      }
    }
    

    后台「设置 → 开放 API / MCP」会按当前登录账号把这几份配置实时生成好并支持一键复制,Token 和站点地址都替你填好,不用手抄。

    Token 等同于账号(可发布文章),请勿贴到公开场合;怀疑泄露时在后台改一次密码即可让它失效。

    七、五种部署方式,从云服务器到 1 核 1G 小机器

    场景 用哪个 命令
    服务器部署(数据库也一起起,推荐) 官方镜像 + MySQL 容器 docker compose -f docker-compose.hub.yml up -d
    1 核 1G 小机器(甲骨文免费实例 / 低配 VPS) 官方镜像 + MariaDB 11.4(已按 1G 调优) docker compose -f docker-compose.mariadb.yml up -d
    数据库在云端 / 已有 MySQL 官方镜像 + 你自己的库 docker compose -f docker-compose.external-db.yml up -d
    个人 / NAS / 内网(连数据库都不要) 官方镜像单容器(lite,内置 H2 文件库) docker compose -f docker-compose.lite.yml up -d
    自己改代码 源码 compose(容器内编译) docker compose up -d --build
    无 Docker JAR 直跑 java -jar target/xiezitai.jar

    低配机那一版是认真测算过的:MariaDB 关掉 performance_schema、各 buffer pool 按几十 MB 的库调小;JVM 换 SerialGC、压栈到 512k、Tomcat 线程数降到 20、连接池降到 4 条。实测系统 + 数据库 + 应用三部分合计空闲约 510MB、压测后约 540MB,连打 300 次首页 + 60 次 API 全程无 OOM、零重启。

    八、项目结构

    src/main/java/cn/xiezitai/
     ├─ controller/   接口(含开放 API / MCP / 媒体输出)
     ├─ service/      业务(Markdown、通知、AI、安全扫描、防篡改、分发)
     ├─ security/     JWT、TOTP、登录失败锁定、请求日志过滤器
     ├─ repository/   Spring Data JPA
     ├─ entity/       实体
     └─ config/       默认数据初始化
    src/main/resources/
     ├─ templates/    SEO 服务端渲染模板
     └─ static/       admin.html(ByteMD 管理后台)+ vendor/(本地内置前端依赖)
    e2e/             Playwright 端到端脚本
    tools/           CI 与联调小工具
    

    九、质量保障与 CI/CD

    • 集成测试:覆盖 SEO 页面、登录失败锁定(3/5/10 次)、TOTP、文章发布与草稿隔离、登录用户评论待审与审核、页面上线、上传类型限制与魔数扫描、媒体访问留痕、开放 API 与 MCP、首页搜索、请求日志、管理端权限。
    • 端到端测试:Playwright + 真实 Chrome,覆盖后台建文发布、评论两级与审核、首页分页、媒体上传、视频插入、改密、记住登录、TOTP 绑定、示例内容种子、文章分发等场景。
    • CI/CD:GitHub Actions 双 job —— 先原生跑一次 Maven 打好 JAR,再分别装进 amd64 与 arm64 基础镜像(避免在 QEMU 里跑 Maven,慢 5~10 倍),推送 Docker Hub;镜像构建成功后自动 SSH 到服务器执行部署脚本。

    十、写在最后

    写字台本来的出发点只是「想要一个自己能完全掌控的博客」,做着做着往三个方向长了出去:安全(自托管的数据和账号得自己守住)、SEO(内容得能被搜到)、开放(内容得能被程序与 AI 用起来)。

    如果你也在找一个能自己部署、又能被 AI 直接调用的博客系统,可以去仓库看看;部署文档按场景分了五套,照着选一套就行。

  • 玩转Oracle免费云主机

    ![甲骨文免费云主机.png](https://xiezitai.cn/media/222b4a649b4aa6e4.png)
    最近把Google邮箱Gmaile恢复了,找回了Oralce Cloud账号。之前注册完Oracle云主机账号,感觉网络不稳定。现在想想,跑java程序还是得有一个云主机。国内备案,说麻烦也不麻烦,但是域名比较多,国内一个一个备案,就不太想折腾。有免费的国外云主机,就先用着,国内再慢慢备案。

    # 组合
    国外ip在国内访问,访问效果很差。与cloudflare或者腾讯云的edgeOne组合在一起,源站放云主机,前面放一个CDN,有加速效果。cloudflare或者腾讯云的edgeOne都提供免费CDN服务,能用腾讯云,就尽量使用腾讯云,腾讯云国内节点要求域名备案。腾讯还提供国际节点。
    1. [cloudflare](https://www.cloudflare.com)
    2. [腾讯云EdgeOne](https://curl.qcloud.com/hchSvZX3)

    # oracle 免费云主机组合
    之前使用甲骨文云主机,没有搞明白怎么个免费法子,就建了一个“VM.Standard.E2.1.Micro”主机,现在才搞明白免费配额。
    >
    > VM.Standard.E2.1.Micro 2台 每个实际配置 1 OCPU ,1G内存
    > VM.Standard.A1.Flex 配额 2 OCPU , 12G内存,可以分配给一台主机或者两台主机
    >

    我打算配置3台云主机

    – M.Standard.E2.1.Micro 2个
    – VM.Standard.A1.Flex 1台

    # VM.Standard.A1.Flex问题
    > 可用性域 VM.Standard.A1.Flex 中配置 AD-1 的容量不足。请在其他可用性域中创建实例,或稍后重试。如果指定了容错域,请尝试在不指定容错域的情况下创建实例。如果这样不起作用,请稍后重试。了解有关主机容量的更多信息。

    ## 解决思路 用脚本抢名额
    [Oracle云主机脚本](https://github.com/zhongdaiqi/oci_auto)
    > 脚本中通知可以让ai修改为企业微信通知,或者让ai去掉通知。
    > 可以将脚本跑在M.Standard.E2.1.Micro云主机上

    ### 脚本收集

    “`bash
    sudo apt-get update
    sudo apt-get upgrade
    sudo apt install python3-pip

    pip3 install –upgrade pip //因为ubuntu系统已经默认安装pip3,这边要更新一下!
    pip3 install requests //亲测过程,只使用命令:pip install requests
    pip3 install oci
    python3 oci_auto.py //该命令必须到当前文件夹下打开终端后输入!
    tail -f oci_auto.log
    “`

    # 我的主机创建与面板安装
    ## 主机配置
    – oracle linux 10
    – ipv4 自动分配ip
    – 使用密钥登陆,下载私钥、公钥
    – VM.Standard.E2.1.Micro
    ## 工具
    – putty 远程登陆linux服务器的ssh工具
    – puttygen 将私钥转成putty能使用的版本
    ## 命令行
    `sudo -i //切换到管理员权限`
    `yum update -y //更新`
    ## 安装可视化面板
    我觉得宝塔不错,进入[宝塔面板](https://www.bt.cn/u/eyrxi0),复制安装命令。

    # 云主机回收问题
    长期闲置会被官方收回,可以在云主机上跑一个keep busy脚本,适当使用云主机。