diff --git a/.cursor/rules/blockchain.mdc b/.cursor/rules/blockchain.mdc
index be5c295..6e313b8 100644
--- a/.cursor/rules/blockchain.mdc
+++ b/.cursor/rules/blockchain.mdc
@@ -21,6 +21,7 @@ alwaysApply: false
8. **攔截器機制**:使用 [BlockchainProviderInterceptor](mdc:src/chains/interceptors/blockchain-provider.interceptor.ts) 處理提供者選擇
9. **請求級提供者**:使用 [UseBlockchainProvider](mdc:src/chains/decorators/blockchain-provider.decorator.ts) 裝飾器設定提供者
10. **錯誤處理**:使用 [ErrorCode](mdc:src/common/constants/error-codes.ts) 和自定義異常提供統一錯誤處理
+11. **價格擴展**:使用 [PriceableChainService](mdc:src/chains/services/core/priceable-chain.service.ts) 與 [PriceableEvmChainService](mdc:src/chains/services/core/priceable-evm-chain.service.ts) 抽象類為鏈服務提供 USD 價格增強功能
## 目錄結構
@@ -41,6 +42,8 @@ src/chains/
│ │ ├── chain-service.factory.ts # 鏈服務工廠
│ │ ├── chain-router.service.ts # 鏈路由服務
│ │ ├── discovery.service.ts # 裝飾器發現服務
+│ │ ├── priceable-chain.service.ts # 可報價鏈服務抽象類
+│ │ ├── priceable-evm-chain.service.ts # 可報價EVM鏈服務抽象類
│ │ ├── blockchain.service.ts # 區塊鏈服務
│ │ └── request-context.service.ts # 請求上下文服務
│ ├── ethereum/ # 以太坊服務
@@ -103,6 +106,7 @@ src/chains/
- **抽象基類更新**:移除 `isTestnet` 標誌,改用 `currentChainId` 屬性
- **提供者工廠升級**:`ProviderFactory.getEvmProvider(chainId)` 基於 chainId 獲取提供者
- **元數據增強**:`CHAIN_INFO_MAP` 擴展包含所有主網和測試網資訊
+- **價格能力擴展**:[PriceableEvmChainService](mdc:src/chains/services/core/priceable-evm-chain.service.ts) 在 EVM 服務中提供代幣 USD 價格計算功能
### 4. API 端點增強
diff --git a/.github/workflows/gcp-deploy-unified.yml b/.github/workflows/gcp-deploy-unified.yml
index 35bc6e1..ee9b20c 100644
--- a/.github/workflows/gcp-deploy-unified.yml
+++ b/.github/workflows/gcp-deploy-unified.yml
@@ -1,11 +1,8 @@
-# GitHub Actions – Deploy to Google Cloud Run (4-job pipeline)
-# ─────────────────────────────────────────────────────────────
+# GitHub Actions – Deploy to Google Cloud Run (簡化版)
+# ───────────────────────────────────────────
# jobs:
-# build_push → 產生 & 推送映像,輸出 image_tag / image_env_tag
-# scan_image → Trivy 掃描,依賴 build_push
-# generate_manifest → 產生 Cloud Run manifest,依賴 build_push
-# deploy → 部署 Cloud Run,依賴 generate_manifest
-# ─────────────────────────────────────────────────────────────
+# cloud_build → 調用 Google Cloud Build 進行所有工作
+# ───────────────────────────────────────────
name: Deploy to Google Cloud Run
@@ -32,28 +29,14 @@ permissions:
jobs:
# =============================================================
- # 1) Build & Push Docker Image
+ # Cloud Build Job - 單一 Job 取代舊的 4-Job pipeline
# =============================================================
- build_push:
- name: 🏗️ Build & Push
+ cloud_build:
+ name: 🚀 Cloud Build & Deploy
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
- outputs:
- image_tag: ${{ steps.vars.outputs.IMAGE_TAG }}
- image_env: ${{ steps.vars.outputs.IMAGE_ENV_TAG }}
- image_path: ${{ steps.vars.outputs.IMAGE_PATH }}
- env_suffix: ${{ steps.vars.outputs.ENV_SUFFIX }}
- secret_prefix: ${{ steps.vars.outputs.SECRET_PREFIX }}
- max_instances: ${{ steps.vars.outputs.MAX_INSTANCES }}
- node_env: ${{ steps.vars.outputs.NODE_ENV }}
- log_level: ${{ steps.vars.outputs.LOG_LEVEL }}
- api_base_url: ${{ steps.vars.outputs.API_BASE_URL }}
- cors_origin: ${{ steps.vars.outputs.CORS_ORIGIN }}
- webhook_url: ${{ steps.vars.outputs.WEBHOOK_URL }}
- deploy_tag: ${{ steps.vars.outputs.DEPLOY_TAG }}
- environment: ${{ steps.vars.outputs.ENVIRONMENT }}
steps:
# --- Checkout ---------------------------------------------------------
- uses: actions/checkout@v4
@@ -65,57 +48,15 @@ jobs:
run: |
set -e
ENVIRONMENT="${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment || (github.ref == 'refs/heads/main' && 'production' || 'staging') }}"
+ echo "ENVIRONMENT=$ENVIRONMENT" >> $GITHUB_ENV
+ echo "_ENV=$ENVIRONMENT" >> $GITHUB_ENV
+
if [[ "$ENVIRONMENT" == "production" ]]; then
- ENV_SUFFIX=""
- IMAGE_SUFFIX=""
- NODE_ENV="production"
- MAX_INSTANCES=2
- LOG_LEVEL=""
- API_BASE_URL="${{ vars.PROD_API_BASE_URL || 'https://api-onekeybalance.sd0.tech' }}"
- CORS_ORIGIN="${{ vars.PROD_CORS_ORIGIN || 'https://onekeybalance.sd0.tech' }}"
- WEBHOOK_URL="${{ vars.PROD_WEBHOOK_URL || 'https://api-onekeybalance.sd0.tech/v1/api/webhook' }}"
- SECRET_PREFIX="production"
- SERVICE_NAME="one-key-balance-kit"
- CACHE_TTL=3600
- DEPLOY_TAG=""
- else
- ENV_SUFFIX="-dev"
- IMAGE_SUFFIX="-dev"
- NODE_ENV="development"
- MAX_INSTANCES=1
- LOG_LEVEL="debug"
- API_BASE_URL="${{ vars.DEV_API_BASE_URL || 'https://staging-api-onekeybalance.sd0.tech' }}"
- CORS_ORIGIN="${{ vars.DEV_CORS_ORIGIN || '*' }}"
- WEBHOOK_URL="${{ vars.DEV_WEBHOOK_URL || 'https://staging-api-onekeybalance.sd0.tech/v1/api/webhook' }}"
- SECRET_PREFIX="staging"
- SERVICE_NAME="one-key-balance-kit-dev"
- CACHE_TTL=1800
- DEPLOY_TAG="--tag=dev"
- fi
- IMAGE_TAG="${{ github.sha }}"
- IMAGE_ENV_TAG="$ENVIRONMENT"
- if [[ "$ENVIRONMENT" == "production" ]]; then
- IMAGE_PATH="${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/one-key-balance-kit/api"
+ echo "_MAX_INSTANCES=2" >> $GITHUB_ENV
else
- IMAGE_PATH="${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/one-key-balance-kit/api-dev"
+ echo "_MAX_INSTANCES=1" >> $GITHUB_ENV
fi
- # 設置輸出變數
- echo "ENVIRONMENT=$ENVIRONMENT" >> $GITHUB_OUTPUT
- echo "ENV_SUFFIX=$ENV_SUFFIX" >> $GITHUB_OUTPUT
- echo "IMAGE_SUFFIX=$IMAGE_SUFFIX" >> $GITHUB_OUTPUT
- echo "NODE_ENV=$NODE_ENV" >> $GITHUB_OUTPUT
- echo "LOG_LEVEL=$LOG_LEVEL" >> $GITHUB_OUTPUT
- echo "API_BASE_URL=$API_BASE_URL" >> $GITHUB_OUTPUT
- echo "CORS_ORIGIN=$CORS_ORIGIN" >> $GITHUB_OUTPUT
- echo "WEBHOOK_URL=$WEBHOOK_URL" >> $GITHUB_OUTPUT
- echo "SECRET_PREFIX=$SECRET_PREFIX" >> $GITHUB_OUTPUT
- echo "MAX_INSTANCES=$MAX_INSTANCES" >> $GITHUB_OUTPUT
- echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_OUTPUT
- echo "IMAGE_ENV_TAG=$IMAGE_ENV_TAG" >> $GITHUB_OUTPUT
- echo "IMAGE_PATH=$IMAGE_PATH" >> $GITHUB_OUTPUT
- echo "DEPLOY_TAG=$DEPLOY_TAG" >> $GITHUB_OUTPUT
-
# --- Auth -------------------------------------------------------------
- name: 🔑 Auth to GCP
uses: google-github-actions/auth@v2
@@ -127,204 +68,63 @@ jobs:
with:
project_id: ${{ env.PROJECT_ID }}
- - name: 🐳 Configure Artifact Registry
+ # --- 調用 Cloud Build --------------------------------------------------
+ - name: 🏗️ Cloud Build
+ id: build
shell: bash
- run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --project $PROJECT_ID
-
- # --- Build & push -----------------------------------------------------
- - uses: docker/setup-qemu-action@v3
- - uses: docker/setup-buildx-action@v3
-
- - name: 📦 Build & Push
- uses: docker/build-push-action@v5
- with:
- context: .
- push: true
- platforms: linux/amd64,linux/arm64
- tags: |
- ${{ steps.vars.outputs.IMAGE_PATH }}:${{ steps.vars.outputs.IMAGE_TAG }}
- ${{ steps.vars.outputs.IMAGE_PATH }}:${{ steps.vars.outputs.IMAGE_ENV_TAG }}
- ${{ steps.vars.outputs.IMAGE_PATH }}:latest
- build-args: |
- NODE_ENV=${{ steps.vars.outputs.NODE_ENV }}
- cache-from: type=gha
- cache-to: type=gha,mode=max
-
- # =============================================================
- # 2) Scan Image (depends on build)
- # =============================================================
- scan_image:
- name: 🔍 Scan Image
- runs-on: ubuntu-latest
- needs: build_push
- permissions:
- contents: read
- id-token: write
- steps:
- - name: 設定 GCR 映像路徑
- id: set-image
run: |
- ENV_SUFFIX="${{ needs.build_push.outputs.env_suffix }}"
- IMAGE_TAG="${{ needs.build_push.outputs.image_tag }}"
- if [ "$ENV_SUFFIX" == "-dev" ]; then
- IMAGE_PATH="${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/one-key-balance-kit/api-dev"
+ set -e
+ # 先設置環境變數,讓後續可以直接用 Bash 變數
+ if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
+ echo "_ENV=${{ github.event.inputs.environment }}" >> $GITHUB_ENV
+ elif [ "${{ github.ref }}" == "refs/heads/main" ]; then
+ echo "_ENV=production" >> $GITHUB_ENV
else
- IMAGE_PATH="${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/one-key-balance-kit/api"
+ echo "_ENV=staging" >> $GITHUB_ENV
fi
- # 輸出給後續步驟使用
- echo "image_path=${IMAGE_PATH}" >> $GITHUB_OUTPUT
- echo "image_tag=${IMAGE_TAG}" >> $GITHUB_OUTPUT
+ # 設置 REGION (避免重複使用 ${{ env.XXX }})
+ echo "REGION=${{ vars.GCP_REGION || 'asia-east1' }}" >> $GITHUB_ENV
- - name: 🔑 Auth to GCP
- uses: google-github-actions/auth@v2
- with:
- workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
- service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
-
- - name: 🐳 Configure Docker
- run: |
- gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet
- IMAGE_PATH="${{ steps.set-image.outputs.image_path }}"
- IMAGE_TAG="${{ steps.set-image.outputs.image_tag }}"
- if [ -z "$IMAGE_PATH" ]; then
- echo "錯誤: IMAGE_PATH 為空"
- exit 1
- fi
- if [ -z "$IMAGE_TAG" ]; then
- echo "錯誤: IMAGE_TAG 為空"
- exit 1
+ # 添加執行階段需要的變數
+ if [ "${{ env._ENV }}" = "production" ]; then
+ echo "_MAX_INSTANCES=2" >> $GITHUB_ENV
+ else
+ echo "_MAX_INSTANCES=1" >> $GITHUB_ENV
fi
- docker pull ${IMAGE_PATH}:${IMAGE_TAG}
-
- - name: Trivy scan
- uses: aquasecurity/trivy-action@master
- with:
- image-ref: '${{ steps.set-image.outputs.image_path }}:${{ steps.set-image.outputs.image_tag }}'
- format: sarif
- output: trivy-results.sarif
- severity: CRITICAL,HIGH
-
- # =============================================================
- # 3) Generate manifest (depends on build)
- # =============================================================
- generate_manifest:
- name: 📝 Generate Manifest
- runs-on: ubuntu-latest
- needs: build_push
- permissions:
- contents: read
- outputs:
- manifest: cloud-run-service.generated.yaml
- steps:
- - uses: actions/checkout@v4
- - name: Create manifest file
- shell: bash
- run: |
- set -e
- ENV_SUFFIX='${{ needs.build_push.outputs.env_suffix }}'
- ENVIRONMENT='${{ needs.build_push.outputs.environment }}'
- MAX_INSTANCES='${{ needs.build_push.outputs.max_instances }}'
- REGION='${{ env.REGION }}'
- PROJECT_ID='${{ env.PROJECT_ID }}'
- IMAGE_SUFFIX='${{ needs.build_push.outputs.image_suffix }}'
- IMAGE_TAG='${{ needs.build_push.outputs.image_tag }}'
- NODE_ENV='${{ needs.build_push.outputs.node_env }}'
- LOG_LEVEL='${{ needs.build_push.outputs.log_level }}'
- API_BASE_URL='${{ needs.build_push.outputs.api_base_url }}'
- CORS_ORIGIN='${{ needs.build_push.outputs.cors_origin }}'
- WEBHOOK_URL='${{ needs.build_push.outputs.webhook_url }}'
- SECRET_PREFIX='${{ needs.build_push.outputs.secret_prefix }}'
- if [[ "$CORS_ORIGIN" == "*" ]]; then CORS_ORIGIN='"*"'; fi
+ # 等待變數設置生效
+ sleep 1
- # 確保映像路徑正確
- if [[ "$ENVIRONMENT" == "production" ]]; then
- IMAGE_PATH="${REGION}-docker.pkg.dev/${PROJECT_ID}/one-key-balance-kit/api"
+ # 根據環境設置後綴(直接使用 Bash 變數,避免使用 ${} 格式)
+ if [ "$_ENV" = "production" ]; then
+ suffix=""
else
- IMAGE_PATH="${REGION}-docker.pkg.dev/${PROJECT_ID}/one-key-balance-kit/api-dev"
+ suffix="-dev"
fi
- # 顯示將用於部署的映像
- echo "部署使用的映像: ${IMAGE_PATH}:${IMAGE_TAG}"
-
- sed -e "s|\${ENV_SUFFIX}|$ENV_SUFFIX|g" \
- -e "s|\${ENVIRONMENT}|$ENVIRONMENT|g" \
- -e "s|\${MAX_INSTANCES}|$MAX_INSTANCES|g" \
- -e "s|\${REGION}|$REGION|g" \
- -e "s|\${PROJECT_ID}|$PROJECT_ID|g" \
- -e "s|\${IMAGE_SUFFIX}|$IMAGE_SUFFIX|g" \
- -e "s|\${IMAGE_TAG}|$IMAGE_TAG|g" \
- -e "s|\${NODE_ENV}|$NODE_ENV|g" \
- -e "s|\${LOG_LEVEL}|$LOG_LEVEL|g" \
- -e "s|\${API_BASE_URL}|$API_BASE_URL|g" \
- -e "s|\${CORS_ORIGIN}|$CORS_ORIGIN|g" \
- -e "s|\${WEBHOOK_URL}|$WEBHOOK_URL|g" \
- -e "s|\${SECRET_PREFIX}|$SECRET_PREFIX|g" \
- -e "s|\${IMAGE_PATH}|$IMAGE_PATH|g" \
- cloud-run-service.template.yaml > cloud-run-service.generated.yaml
-
- # 檢查生成的 manifest
- echo "生成 manifest 完成,檢查映像路徑:"
- grep -A 2 "containers:" cloud-run-service.generated.yaml
-
- # 上傳產生的 manifest 作為 artifact,供後續部署使用
- - name: 上傳 manifest 檔案
- uses: actions/upload-artifact@v4
- with:
- name: cloud-run-manifest
- path: cloud-run-service.generated.yaml
- retention-days: 1
-
- # =============================================================
- # 4) Deploy (depends on manifest & scan)
- # =============================================================
- deploy:
- name: 🚀 Deploy to Cloud Run
- runs-on: ubuntu-latest
- needs:
- - generate_manifest
- - scan_image
- permissions:
- contents: read
- id-token: write
- steps:
- - uses: actions/checkout@v4
+ # 手動構建完整映像路徑(使用純 Bash 變數,避免使用 ${} 格式)
+ FULL_IMAGE_PATH="$REGION-docker.pkg.dev/${{ env.PROJECT_ID }}/one-key-balance-kit/api$suffix"
- # 下載之前上傳的 manifest 檔案
- - name: 下載 manifest 檔案
- uses: actions/download-artifact@v4
- with:
- name: cloud-run-manifest
- path: .
+ # 調用 Cloud Build(使用純 Bash 變數)
+ # 確保使用引號包裹整個 substitutions 字符串
+ gcloud builds submit \
+ --project=${{ env.PROJECT_ID }} \
+ --config=cloudbuild.yaml \
+ --substitutions="_IMAGE_PATH=$FULL_IMAGE_PATH,_ENV=$_ENV,_MAX_INSTANCES=$_MAX_INSTANCES,_REGION=$REGION,_GIT_SHA=${{ github.sha }}"
- # 確認下載的檔案存在且可讀
- - name: 檢查 manifest 檔案
+ # --- 顯示部署 URL -----------------------------------------------------
+ - name: 🌐 Show Service URL
+ if: ${{ success() && !cancelled() }}
+ shell: bash
run: |
- if [ ! -f "cloud-run-service.generated.yaml" ]; then
- echo "❌ 錯誤: Manifest 檔案不存在"
- ls -la
- exit 1
- fi
+ ENV_SUFFIX=$([ "${{ env._ENV }}" = "production" ] && echo "" || echo "-dev")
+ SERVICE_NAME="one-key-balance-kit$ENV_SUFFIX"
- - name: Authenticate to GCP
- uses: google-github-actions/auth@v2
- with:
- workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
- service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
+ URL=$(gcloud run services describe $SERVICE_NAME \
+ --region=${{ env.REGION }} \
+ --project=${{ env.PROJECT_ID }} \
+ --format="value(status.url)")
- - uses: google-github-actions/setup-gcloud@v2
- with:
- project_id: ${{ env.PROJECT_ID }}
-
- - name: Cloud Run Deploy
- id: deploy
- uses: google-github-actions/deploy-cloudrun@v2
- with:
- metadata: cloud-run-service.generated.yaml
- region: ${{ env.REGION }}
- project_id: ${{ env.PROJECT_ID }}
-
- - name: 🌐 Show URL
- run: |
- echo "✅ ${{ needs.build_push.outputs.environment }} URL: ${{ steps.deploy.outputs.url }}"
+ echo "✅ ${{ env._ENV }} 環境部署完成"
+ echo "🔗 服務 URL: $URL"
diff --git a/README.md b/README.md
index fc128d0..de23564 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,12 @@
+
+
+
+
+
+
> 一個高性能、可擴展的多鏈資產餘額查詢服務,支持以太坊和 Solana 區塊鏈,提供統一的 API 接口來查詢地址的原生代幣、ERC-20/SPL 代幣和 NFT 資產。
## 📑 目錄
@@ -274,6 +280,9 @@ A: 參考[區塊鏈提供者](.cursor/rules/blockchain-providers.mdc)文檔,
- [x] ETH Mainnet MVP
- [x] 多鏈抽象(EVM/L2/BTC/Solana/Discovery)
- [x] 服務註冊 / 發現 / 路由
+ - [x] Price Provider
+ - [x] Mock Price Provider
+ - [ ] OKX Price Provider
- [x] EVM 鏈抽象
- [x] Base
- [x] Optimism
diff --git a/cloud-run-service.template.yaml b/cloud-run-service.template.yaml
index 68e1535..1806897 100644
--- a/cloud-run-service.template.yaml
+++ b/cloud-run-service.template.yaml
@@ -1,9 +1,9 @@
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
- name: one-key-balance-kit${ENV_SUFFIX}
+ name: one-key-balance-kit${env_suffix}
labels:
- environment: ${ENVIRONMENT}
+ environment: ${environment}
annotations:
run.googleapis.com/client-name: 'github-actions'
run.googleapis.com/ingress: 'all'
@@ -12,31 +12,31 @@ spec:
metadata:
annotations:
autoscaling.knative.dev/minScale: '0'
- autoscaling.knative.dev/maxScale: '${MAX_INSTANCES}'
+ autoscaling.knative.dev/maxScale: '${max_instances}'
run.googleapis.com/cpu-throttling: 'true'
run.googleapis.com/execution-environment: 'gen2'
spec:
containerConcurrency: 80
timeoutSeconds: 300
containers:
- - image: ${IMAGE_PATH}:${IMAGE_TAG}
+ - image: ${image_path}:${img_tag}
resources:
limits:
cpu: '1'
memory: '1Gi'
env:
- name: NODE_ENV
- value: ${NODE_ENV}
+ value: ${node_env}
- name: APP_NAME
- value: one-key-balance-kit${ENV_SUFFIX}
+ value: one-key-balance-kit${env_suffix}
- name: LOG_LEVEL
- value: '${LOG_LEVEL}'
+ value: '${log_level}'
- name: API_BASE_URL
- value: ${API_BASE_URL}
+ value: ${api_base_url}
- name: CORS_ORIGIN
- value: '${CORS_ORIGIN}'
+ value: '${cors_origin}'
- name: WEBHOOK_URL
- value: ${WEBHOOK_URL}
+ value: ${webhook_url}
- name: NETWORK_TIMEOUT
value: '30000'
- name: NETWORK_RETRIES
@@ -45,112 +45,122 @@ spec:
- name: MONGO_URL
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_MONGO_URL
+ name: ${secret_prefix}_MONGO_URL
key: latest
- name: MONGO_HOST
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_MONGO_HOST
+ name: ${secret_prefix}_MONGO_HOST
key: latest
- name: MONGO_PORT
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_MONGO_PORT
+ name: ${secret_prefix}_MONGO_PORT
key: latest
- name: MONGO_USERNAME
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_MONGO_USERNAME
+ name: ${secret_prefix}_MONGO_USERNAME
key: latest
- name: MONGO_PASSWORD
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_MONGO_PASSWORD
+ name: ${secret_prefix}_MONGO_PASSWORD
key: latest
- name: MONGO_DATABASE
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_MONGO_DATABASE
+ name: ${secret_prefix}_MONGO_DATABASE
key: latest
- name: REDIS_HOST
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_REDIS_HOST
+ name: ${secret_prefix}_REDIS_HOST
key: latest
- name: REDIS_PORT
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_REDIS_PORT
+ name: ${secret_prefix}_REDIS_PORT
key: latest
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_REDIS_PASSWORD
+ name: ${secret_prefix}_REDIS_PASSWORD
key: latest
- name: REDIS_DB
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_REDIS_DB
+ name: ${secret_prefix}_REDIS_DB
+ key: latest
+ - name: REDIS_URL
+ valueFrom:
+ secretKeyRef:
+ name: ${secret_prefix}_REDIS_URL
key: latest
- name: RPC_URL
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_RPC_URL
+ name: ${secret_prefix}_RPC_URL
key: latest
- name: CHAIN_ID
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_CHAIN_ID
+ name: ${secret_prefix}_CHAIN_ID
key: latest
- name: ALCHEMY_API_KEY_ETH
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_ALCHEMY_API_KEY_ETH
+ name: ${secret_prefix}_ALCHEMY_API_KEY_ETH
key: latest
- name: ALCHEMY_API_KEY_SOL
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_ALCHEMY_API_KEY_SOL
+ name: ${secret_prefix}_ALCHEMY_API_KEY_SOL
+ key: latest
+ - name: ALCHEMY_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: ${secret_prefix}_ALCHEMY_API_KEY
key: latest
- name: ALCHEMY_TOKEN
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_ALCHEMY_TOKEN
+ name: ${secret_prefix}_ALCHEMY_TOKEN
key: latest
- name: QUICKNODE_ETH_MAINNET_URL
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_QUICKNODE_ETH_MAINNET_URL
+ name: ${secret_prefix}_QUICKNODE_ETH_MAINNET_URL
key: latest
- name: QUICKNODE_ETH_TESTNET_URL
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_QUICKNODE_ETH_TESTNET_URL
+ name: ${secret_prefix}_QUICKNODE_ETH_TESTNET_URL
key: latest
- name: OKX_API_KEY
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_OKX_API_KEY
+ name: ${secret_prefix}_OKX_API_KEY
key: latest
- name: OKX_SECRET_KEY
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_OKX_SECRET_KEY
+ name: ${secret_prefix}_OKX_SECRET_KEY
key: latest
- name: OKX_API_PASSPHRASE
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_OKX_API_PASSPHRASE
+ name: ${secret_prefix}_OKX_API_PASSPHRASE
key: latest
- name: OKX_PROJECT_ID
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_OKX_PROJECT_ID
+ name: ${secret_prefix}_OKX_PROJECT_ID
key: latest
- name: API_KEY
valueFrom:
secretKeyRef:
- name: ${SECRET_PREFIX}_API_KEY
+ name: ${secret_prefix}_API_KEY
key: latest
# ---- Health checks ----
startupProbe:
diff --git a/cloudbuild.yaml b/cloudbuild.yaml
new file mode 100644
index 0000000..8db2fc4
--- /dev/null
+++ b/cloudbuild.yaml
@@ -0,0 +1,105 @@
+# cloudbuild.yaml ── Build → Deploy
+substitutions:
+ _IMAGE_PATH: '${_REGION}-docker.pkg.dev/$PROJECT_ID/one-key-balance-kit/api'
+ _ENV: 'staging' # GitHub Action 會覆寫
+ _MAX_INSTANCES: '1'
+ _REGION: 'asia-east1' # 預設值,GitHub Action 會覆寫
+ _GIT_SHA: 'latest' # 預設值,GitHub Action 會覆寫
+timeout: '1200s'
+options:
+ machineType: 'E2_HIGHCPU_8' # 免費額度同享
+ logging: 'CLOUD_LOGGING_ONLY'
+steps:
+ # --- 1) Build multi-arch image ------------------------------------------
+ - id: build-image
+ name: gcr.io/kaniko-project/executor:latest
+ entrypoint: ''
+ args:
+ # context 與 Dockerfile (預設為 ./Dockerfile)
+ - '--context=.'
+ - '--dockerfile=Dockerfile'
+
+ # 組合 tag ── 使用 _GIT_SHA,如空則 latest
+ - '--destination=${_IMAGE_PATH}:${_GIT_SHA}'
+ - '--destination=${_IMAGE_PATH}:${_ENV}'
+ - '--destination=${_IMAGE_PATH}:latest'
+
+ # 建置引數
+ - '--build-arg=NODE_ENV=$(_ENV==production?"production":"staging")'
+
+ # Kaniko layer cache(存放於同一 Registry)
+ - '--cache=true'
+ - '--cache-ttl=48h'
+
+ # --- 2) 產生 manifest & 部署 -------------------------------------------
+ - id: deploy
+ name: gcr.io/google.com/cloudsdktool/cloud-sdk
+ entrypoint: bash
+ args:
+ - -ceu
+ - |
+
+ # 使用條件語句決定後綴 (避免 Cloud Build 解析變數)
+ if [ "${_ENV}" = "production" ]; then
+ suffix=""
+ else
+ suffix="-dev"
+ fi
+
+ # 使用 _GIT_SHA 或 latest 作為映像標籤
+ tag="$_GIT_SHA"
+ if [ -z "$tag" ]; then
+ tag="latest"
+ fi
+
+ # 避免使用可能被 Cloud Build 誤解的變數名稱
+ export env_suffix=$([ "${_ENV}" = "production" ] && echo "" || echo "-dev")
+ export environment="${_ENV}"
+ export max_instances="${_MAX_INSTANCES}"
+ export region="${_REGION}"
+ export project_id="$PROJECT_ID"
+ export img_tag="$tag"
+ export node_env=$([ "${_ENV}" = "production" ] && echo "production" || echo "staging")
+ export log_level=$([ "${_ENV}" = "production" ] && echo "" || echo "debug")
+ export api_base_url=$([ "${_ENV}" = "production" ] && echo "https://api-onekeybalance.sd0.tech" || echo "https://staging-api-onekeybalance.sd0.tech")
+ export cors_origin=$([ "${_ENV}" = "production" ] && echo "https://onekeybalance.sd0.tech" || echo '"*"')
+ export webhook_url=$([ "${_ENV}" = "production" ] && echo "https://api-onekeybalance.sd0.tech/v1/api/webhook" || echo "https://staging-api-onekeybalance.sd0.tech/v1/api/webhook")
+ export secret_prefix=$([ "${_ENV}" = "production" ] && echo "production" || echo "staging")
+
+ # 構建映像路徑
+ export image_path="${_IMAGE_PATH}"
+
+ # 顯示將用於部署的映像
+ echo "部署使用的映像: $image_path:$tag"
+
+ # 使用 sed 替換環境變數,而不是 envsubst
+ cp cloud-run-service.template.yaml cloud-run-service.generated.yaml
+ sed -i "s|\${env_suffix}|$env_suffix|g" cloud-run-service.generated.yaml
+ sed -i "s|\${environment}|$environment|g" cloud-run-service.generated.yaml
+ sed -i "s|\${max_instances}|$max_instances|g" cloud-run-service.generated.yaml
+ sed -i "s|\${region}|$region|g" cloud-run-service.generated.yaml
+ sed -i "s|\${project_id}|$project_id|g" cloud-run-service.generated.yaml
+ sed -i "s|\${image_path}|$image_path|g" cloud-run-service.generated.yaml
+ sed -i "s|\${img_tag}|$img_tag|g" cloud-run-service.generated.yaml
+ sed -i "s|\${node_env}|$node_env|g" cloud-run-service.generated.yaml
+ sed -i "s|\${log_level}|$log_level|g" cloud-run-service.generated.yaml
+ sed -i "s|\${api_base_url}|$api_base_url|g" cloud-run-service.generated.yaml
+ sed -i "s|\${cors_origin}|$cors_origin|g" cloud-run-service.generated.yaml
+ sed -i "s|\${webhook_url}|$webhook_url|g" cloud-run-service.generated.yaml
+ sed -i "s|\${secret_prefix}|$secret_prefix|g" cloud-run-service.generated.yaml
+
+ # 檢查是否有未替換的變數
+ echo "檢查是否有未替換的變數 (應該不顯示任何內容):"
+ grep -o '\${[^}]*}' cloud-run-service.generated.yaml || echo "全部變數已成功替換!"
+
+ # 檢查生成的 manifest
+ echo "生成 manifest 完成,檢查映像路徑:"
+ grep -A 2 "containers:" cloud-run-service.generated.yaml
+
+ # 部署至 Cloud Run
+ gcloud run services replace cloud-run-service.generated.yaml \
+ --region=${_REGION} --project=$PROJECT_ID
+
+# 注意: 此處不使用獨立的 images 區段
+# 我們已經在 build-image 步驟中使用 docker buildx 構建並推送了映像
+# 這樣可以避免 Cloud Build 解析變數時出錯
diff --git a/docs/assets/demo.mp4 b/docs/assets/demo.mp4
new file mode 100644
index 0000000..265ded5
Binary files /dev/null and b/docs/assets/demo.mp4 differ
diff --git a/scripts/setup-gcp.sh b/scripts/setup-gcp.sh
index f6848bf..5575a47 100755
--- a/scripts/setup-gcp.sh
+++ b/scripts/setup-gcp.sh
@@ -17,6 +17,18 @@ YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
+# ---------- 自動檢查並加載 .env.gcp ----------
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+ROOT_DIR="$(dirname "$SCRIPT_DIR")"
+ENV_FILE="${ROOT_DIR}/.env.gcp"
+
+if [[ -f "$ENV_FILE" ]]; then
+ echo -e "${GREEN}發現 .env.gcp 文件,自動加載...${NC}"
+ source "$ENV_FILE"
+else
+ echo -e "${YELLOW}未找到 .env.gcp 文件,將使用當前環境變數${NC}"
+fi
+
# ---------- Environment checks ----------
if [[ -z "${PROJECT_ID:-}" ]]; then
echo -e "${RED}錯誤: 未設置 PROJECT_ID 環境變數${NC}"
@@ -64,6 +76,8 @@ gcloud services enable \
iam.googleapis.com \
iamcredentials.googleapis.com \
secretmanager.googleapis.com \
+ cloudbuild.googleapis.com \
+ storage.googleapis.com \
--project "$PROJECT_ID"
# ---------- Artifact Registry ----------
@@ -95,6 +109,12 @@ roles=(
roles/run.admin
roles/iam.serviceAccountUser
roles/secretmanager.secretAccessor
+ roles/cloudbuild.builds.editor
+ roles/storage.objectViewer
+ roles/serviceusage.serviceUsageConsumer
+ # 添加必要的最小權限來解決 Cloud Build 存儲桶訪問問題
+ roles/cloudbuild.builds.builder
+ roles/storage.objectCreator # 只添加建立物件權限,而非完整的 Admin
)
for r in "${roles[@]}"; do
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
@@ -104,6 +124,26 @@ done
echo -e "${GREEN}權限已授予${NC}"
+# ---------- Grant roles to Cloud Build Service Account ----------
+echo "授予 Cloud Build 服務帳號權限..."
+CB_SERVICE_ACCOUNT="${PROJECT_NUMBER}@cloudbuild.gserviceaccount.com"
+cb_roles=(
+ roles/artifactregistry.writer
+ roles/run.admin
+ roles/storage.objectViewer
+ roles/serviceusage.serviceUsageConsumer
+ # 添加必要的最小權限
+ roles/cloudbuild.builds.builder
+ roles/storage.objectCreator # 只添加建立物件權限
+)
+for r in "${cb_roles[@]}"; do
+ gcloud projects add-iam-policy-binding "$PROJECT_ID" \
+ --member="serviceAccount:${CB_SERVICE_ACCOUNT}" \
+ --role="$r" --condition=None 2>/dev/null || true
+done
+
+echo -e "${GREEN}Cloud Build 權限已授予${NC}"
+
# ---------- Workload Identity Pool ----------
if gcloud iam workload-identity-pools describe "$POOL_ID" --location=global --project "$PROJECT_ID" &>/dev/null; then
echo -e "${YELLOW}工作負載身份池 $POOL_ID 已存在${NC}"
@@ -158,4 +198,4 @@ echo -e "GCP_PROJECT_ID = $PROJECT_ID"
echo -e "GCP_PROJECT_NUMBER = $PROJECT_NUMBER"
echo -e "GCP_SERVICE_ACCOUNT = $SERVICE_ACCOUNT_EMAIL"
echo -e "GCP_WORKLOAD_IDENTITY_PROVIDER = $WORKLOAD_IDENTITY_PROVIDER"
-echo -e "GCP_REGION = $REGION"
\ No newline at end of file
+echo -e "GCP_REGION = $REGION"
diff --git a/scripts/setup-secrets.sh b/scripts/setup-secrets.sh
index 1b76cb1..71314b5 100755
--- a/scripts/setup-secrets.sh
+++ b/scripts/setup-secrets.sh
@@ -3,9 +3,10 @@
# set -e
# 此腳本用於將環境變數設置到 Google Cloud Secret Manager
-# 用法: ./scripts/setup-secrets.sh [env-file] [environment-name] [specific-key]
+# 用法: ./scripts/setup-secrets.sh [env-file] [environment-name] [specific-keys]
# 例如: ./scripts/setup-secrets.sh .env.staging staging
# 或指定特定密鑰: ./scripts/setup-secrets.sh .env.staging staging MONGO_URL
+# 或指定多個密鑰(用逗號分隔): ./scripts/setup-secrets.sh .env.staging staging REDIS_HOST,REDIS_PORT,REDIS_PASSWORD
# 顏色設置
GREEN='\033[0;32m'
@@ -22,14 +23,14 @@ else
DEFAULT_ENV_NAME="production"
fi
ENV_NAME=${2:-$DEFAULT_ENV_NAME} # 如果提供了第二個參數,使用該參數;否則使用從檔案名提取的環境名稱
-SPECIFIC_KEY=${3:-""} # 可選的特定密鑰參數
+SPECIFIC_KEYS=${3:-""} # 可選的特定密鑰參數,可以是逗號分隔的多個密鑰
PROJECT_ID="${PROJECT_ID:-$(gcloud config get-value project)}"
SERVICE_ACCOUNT_NAME="${SERVICE_ACCOUNT_NAME:-github-actions-runner}"
UPDATE_MODE=false # 預設非更新模式
# 白名單變數(這些變數將被跳過,不上傳到 Secret Manager)
# 這些變數是從 cloud-run-service.template.yaml 中直接設定的,不需要存在 Secret Manager 中
-SKIP_VARS=("NODE_ENV" "PORT" "APP_NAME" "LOG_LEVEL" "API_BASE_URL" "CORS_ORIGIN" "NETWORK_TIMEOUT" "NETWORK_RETRIES")
+SKIP_VARS=("NODE_ENV" "PORT" "APP_NAME" "LOG_LEVEL" "API_BASE_URL" "CORS_ORIGIN" "NETWORK_TIMEOUT" "NETWORK_RETRIES" "WEBHOOK_URL")
# 檢查變數是否在白名單中
is_in_skip_list() {
@@ -42,40 +43,30 @@ is_in_skip_list() {
return 1 # 不在白名單中,返回 false
}
-# 檢查環境變數是否設置
-if [ -z "$PROJECT_ID" ]; then
- echo -e "${RED}錯誤: 未設置 PROJECT_ID 環境變數${NC}"
- echo -e "${YELLOW}請先執行 source .env.gcp 或設置 PROJECT_ID 環境變數${NC}"
- exit 1
-fi
+# 處理單個密鑰
+process_specific_key() {
+ local key="$1"
-echo -e "${GREEN}使用專案 ID: $PROJECT_ID${NC}"
-echo -e "${GREEN}從檔案載入環境變數: $ENV_FILE${NC}"
-echo -e "${GREEN}目標環境: ${ENV_NAME}${NC}"
-echo -e "${GREEN}服務帳號: $SERVICE_ACCOUNT_NAME@$PROJECT_ID.iam.gserviceaccount.com${NC}"
-
-# 如果指定了特定密鑰,顯示相關信息
-if [ ! -z "$SPECIFIC_KEY" ]; then
- echo -e "${GREEN}僅處理指定密鑰: $SPECIFIC_KEY${NC}"
-
- # 檢查特定密鑰是否在白名單中
- if is_in_skip_list "$SPECIFIC_KEY"; then
- echo -e "${RED}錯誤: 指定的密鑰 '$SPECIFIC_KEY' 在白名單中,無法處理${NC}"
- exit 1
+ # 檢查密鑰是否在白名單中
+ if is_in_skip_list "$key"; then
+ echo -e "${RED}錯誤: 指定的密鑰 '$key' 在白名單中,無法處理${NC}"
+ return 1
fi
- # 檢查特定密鑰是否已存在
- secret_name="${ENV_NAME}_${SPECIFIC_KEY}"
+ # 檢查密鑰是否已存在
+ local secret_name="${ENV_NAME}_${key}"
+ local update_this_key=false
+
if gcloud secrets describe "$secret_name" --project $PROJECT_ID &> /dev/null; then
echo -e "${YELLOW}密鑰 $secret_name 已存在,將更新該密鑰${NC}"
- UPDATE_MODE=true
+ update_this_key=true
else
echo -e "${GREEN}將創建新密鑰: $secret_name${NC}"
fi
# 從環境文件中尋找密鑰值
- found_in_file=false
- specific_value=""
+ local found_in_file=false
+ local specific_value=""
if [ -f "$ENV_FILE" ]; then
while IFS= read -r line || [ -n "$line" ]; do
@@ -84,14 +75,14 @@ if [ ! -z "$SPECIFIC_KEY" ]; then
[[ -z $line ]] && continue
# 解析變數名稱和值
- key=$(echo $line | cut -d '=' -f 1)
+ local line_key=$(echo $line | cut -d '=' -f 1)
# 如果找到指定的密鑰
- if [[ "$key" == "$SPECIFIC_KEY" ]]; then
- value=$(echo $line | cut -d '=' -f 2-)
+ if [[ "$line_key" == "$key" ]]; then
+ local value=$(echo $line | cut -d '=' -f 2-)
found_in_file=true
specific_value="$value"
- echo -e "${GREEN}在環境文件中找到密鑰 $SPECIFIC_KEY ${NC}"
+ echo -e "${GREEN}在環境文件中找到密鑰 $key ${NC}"
echo -e "${GREEN}密鑰值預覽${NC}: ${value:0:3}$([[ ${#value} -gt 3 ]] && echo '***')"
break
fi
@@ -100,8 +91,8 @@ if [ ! -z "$SPECIFIC_KEY" ]; then
# 如果在文件中沒找到,詢問用戶
if [[ "$found_in_file" != true ]]; then
- echo -e "${YELLOW}在環境文件 $ENV_FILE 中未找到密鑰 $SPECIFIC_KEY${NC}"
- read -p "請輸入 $SPECIFIC_KEY 的值 (輸入空值將創建空值密鑰): " specific_value
+ echo -e "${YELLOW}在環境文件 $ENV_FILE 中未找到密鑰 $key${NC}"
+ read -p "請輸入 $key 的值 (輸入空值將創建空值密鑰): " specific_value
fi
# 確認繼續
@@ -112,17 +103,20 @@ if [ ! -z "$SPECIFIC_KEY" ]; then
fi
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
- echo -e "${YELLOW}操作已取消${NC}"
- exit 0
+ echo -e "${YELLOW}跳過此密鑰${NC}"
+ return 0
fi
# 確保 Secret Manager API 已啟用
- echo "確保 Secret Manager API 已啟用..."
- gcloud services enable secretmanager.googleapis.com --project $PROJECT_ID || echo -e "${YELLOW}啟用 Secret Manager API 失敗,可能已啟用${NC}"
+ if [[ "$API_ENABLED" != true ]]; then
+ echo "確保 Secret Manager API 已啟用..."
+ gcloud services enable secretmanager.googleapis.com --project $PROJECT_ID || echo -e "${YELLOW}啟用 Secret Manager API 失敗,可能已啟用${NC}"
+ API_ENABLED=true
+ fi
# 處理特定密鑰
echo "處理密鑰: $secret_name"
- if [[ "$UPDATE_MODE" == true ]]; then
+ if [[ "$update_this_key" == true ]]; then
echo -e "${YELLOW}更新現有密鑰 $secret_name...${NC}"
echo -n "$specific_value" | gcloud secrets versions add "$secret_name" --data-file=- --project $PROJECT_ID || echo -e "${RED}更新密鑰失敗: $secret_name${NC}"
else
@@ -134,7 +128,7 @@ if [ ! -z "$SPECIFIC_KEY" ]; then
fi
# 授予 Cloud Run 服務帳號存取權限
- SERVICE_ACCOUNT="$SERVICE_ACCOUNT_NAME@$PROJECT_ID.iam.gserviceaccount.com"
+ local SERVICE_ACCOUNT="$SERVICE_ACCOUNT_NAME@$PROJECT_ID.iam.gserviceaccount.com"
# 檢查服務帳號是否存在
if ! gcloud iam service-accounts describe $SERVICE_ACCOUNT --project $PROJECT_ID &> /dev/null; then
@@ -149,9 +143,42 @@ if [ ! -z "$SPECIFIC_KEY" ]; then
fi
echo -e "${GREEN}已成功設置密鑰: $secret_name${NC}"
- echo -e "${GREEN}可以使用以下命令查看密鑰:${NC}"
- echo -e "gcloud secrets versions access latest --secret=$secret_name --project=${PROJECT_ID}"
+ return 0
+}
+
+# 檢查環境變數是否設置
+if [ -z "$PROJECT_ID" ]; then
+ echo -e "${RED}錯誤: 未設置 PROJECT_ID 環境變數${NC}"
+ echo -e "${YELLOW}請先執行 source .env.gcp 或設置 PROJECT_ID 環境變數${NC}"
+ exit 1
+fi
+
+echo -e "${GREEN}使用專案 ID: $PROJECT_ID${NC}"
+echo -e "${GREEN}從檔案載入環境變數: $ENV_FILE${NC}"
+echo -e "${GREEN}目標環境: ${ENV_NAME}${NC}"
+echo -e "${GREEN}服務帳號: $SERVICE_ACCOUNT_NAME@$PROJECT_ID.iam.gserviceaccount.com${NC}"
+
+# 標記API是否已啟用
+API_ENABLED=false
+
+# 如果指定了特定密鑰,處理這些密鑰
+if [ ! -z "$SPECIFIC_KEYS" ]; then
+ # 將逗號分隔的字符串轉換為數組
+ IFS=',' read -ra KEY_ARRAY <<< "$SPECIFIC_KEYS"
+
+ if [ ${#KEY_ARRAY[@]} -gt 1 ]; then
+ echo -e "${GREEN}將處理 ${#KEY_ARRAY[@]} 個密鑰: ${SPECIFIC_KEYS//,/, }${NC}"
+ else
+ echo -e "${GREEN}僅處理指定密鑰: $SPECIFIC_KEYS${NC}"
+ fi
+
+ # 遍歷所有密鑰並處理
+ for key in "${KEY_ARRAY[@]}"; do
+ echo -e "\n${YELLOW}================= 處理密鑰: $key =================${NC}"
+ process_specific_key "$key"
+ done
+ echo -e "\n${GREEN}所有指定的密鑰處理完成!${NC}"
exit 0
fi
@@ -179,6 +206,7 @@ fi
# 確保 Secret Manager API 已啟用
echo "確保 Secret Manager API 已啟用..."
gcloud services enable secretmanager.googleapis.com --project $PROJECT_ID || echo -e "${YELLOW}啟用 Secret Manager API 失敗,可能已啟用${NC}"
+API_ENABLED=true
# 讀取環境檔案並創建密鑰
if [ -f "$ENV_FILE" ]; then
diff --git a/scripts/setup-vars.sh b/scripts/setup-vars.sh
index 4fad36c..d078cc2 100755
--- a/scripts/setup-vars.sh
+++ b/scripts/setup-vars.sh
@@ -8,6 +8,66 @@ YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
+# 檢查現有的 .env.gcp 文件
+ENV_FILE=".env.gcp"
+
+if [ -f "$ENV_FILE" ]; then
+ echo -e "${GREEN}發現現有的 ${ENV_FILE} 文件,將使用其中的值作為默認值${NC}"
+
+ # 讀取現有的值,去除行首的 export 和多餘的空格/引號
+ if [ -n "$(grep 'PROJECT_ID=' $ENV_FILE)" ]; then
+ DEFAULT_PROJECT_ID=$(grep 'PROJECT_ID=' $ENV_FILE | cut -d '=' -f2 | tr -d '"' | tr -d "'" | tr -d ' ')
+ else
+ DEFAULT_PROJECT_ID="one-key-balance-kit"
+ fi
+
+ if [ -n "$(grep 'REGION=' $ENV_FILE)" ]; then
+ DEFAULT_REGION=$(grep 'REGION=' $ENV_FILE | cut -d '=' -f2 | tr -d '"' | tr -d "'" | tr -d ' ')
+ else
+ DEFAULT_REGION="asia-east1"
+ fi
+
+ if [ -n "$(grep 'SERVICE_ACCOUNT_NAME=' $ENV_FILE)" ]; then
+ DEFAULT_SERVICE_ACCOUNT_NAME=$(grep 'SERVICE_ACCOUNT_NAME=' $ENV_FILE | cut -d '=' -f2 | tr -d '"' | tr -d "'" | tr -d ' ')
+ else
+ DEFAULT_SERVICE_ACCOUNT_NAME="github-actions-runner"
+ fi
+
+ if [ -n "$(grep 'POOL_ID=' $ENV_FILE)" ]; then
+ DEFAULT_POOL_ID=$(grep 'POOL_ID=' $ENV_FILE | cut -d '=' -f2 | tr -d '"' | tr -d "'" | tr -d ' ')
+ else
+ DEFAULT_POOL_ID="github-actions-pool"
+ fi
+
+ if [ -n "$(grep 'PROVIDER_ID=' $ENV_FILE)" ]; then
+ DEFAULT_PROVIDER_ID=$(grep 'PROVIDER_ID=' $ENV_FILE | cut -d '=' -f2 | tr -d '"' | tr -d "'" | tr -d ' ')
+ else
+ DEFAULT_PROVIDER_ID="github-provider"
+ fi
+
+ if [ -n "$(grep 'GITHUB_USERNAME=' $ENV_FILE)" ]; then
+ DEFAULT_GITHUB_USERNAME=$(grep 'GITHUB_USERNAME=' $ENV_FILE | cut -d '=' -f2 | tr -d '"' | tr -d "'" | tr -d ' ')
+ else
+ DEFAULT_GITHUB_USERNAME=""
+ fi
+
+ if [ -n "$(grep 'GITHUB_REPO=' $ENV_FILE)" ]; then
+ GITHUB_REPO_VALUE=$(grep 'GITHUB_REPO=' $ENV_FILE | cut -d '=' -f2 | tr -d '"' | tr -d "'" | tr -d ' ')
+ DEFAULT_REPO_NAME=$(echo "$GITHUB_REPO_VALUE" | cut -d '/' -f2)
+ else
+ DEFAULT_REPO_NAME="one-key-balance-kit"
+ fi
+else
+ echo -e "${YELLOW}未找到 ${ENV_FILE} 文件,將使用默認值${NC}"
+ DEFAULT_PROJECT_ID="one-key-balance-kit"
+ DEFAULT_REGION="asia-east1"
+ DEFAULT_SERVICE_ACCOUNT_NAME="github-actions-runner"
+ DEFAULT_POOL_ID="github-actions-pool"
+ DEFAULT_PROVIDER_ID="github-provider"
+ DEFAULT_GITHUB_USERNAME=""
+ DEFAULT_REPO_NAME="one-key-balance-kit"
+fi
+
# 顯示提示
echo -e "${GREEN}設置 Google Cloud 環境變數${NC}"
echo "此腳本將設置必要的環境變數以便後續部署"
@@ -15,35 +75,35 @@ echo -e "${YELLOW}注意:後續腳本 setup-gcp.sh 和 setup-secrets.sh 支持
echo -e "${YELLOW}這意味著即使資源已存在,腳本也能安全地重複執行${NC}"
# 設置基本變數
-read -p "請輸入 Google Cloud 專案 ID [one-key-balance-kit]: " PROJECT_ID
-PROJECT_ID=${PROJECT_ID:-"one-key-balance-kit"}
+read -p "請輸入 Google Cloud 專案 ID [${DEFAULT_PROJECT_ID}]: " PROJECT_ID
+PROJECT_ID=${PROJECT_ID:-"${DEFAULT_PROJECT_ID}"}
-read -p "請輸入部署區域 [asia-east1]: " REGION
-REGION=${REGION:-"asia-east1"}
+read -p "請輸入部署區域 [${DEFAULT_REGION}]: " REGION
+REGION=${REGION:-"${DEFAULT_REGION}"}
-read -p "請輸入服務帳號名稱 [github-actions-runner]: " SERVICE_ACCOUNT_NAME
-SERVICE_ACCOUNT_NAME=${SERVICE_ACCOUNT_NAME:-"github-actions-runner"}
+read -p "請輸入服務帳號名稱 [${DEFAULT_SERVICE_ACCOUNT_NAME}]: " SERVICE_ACCOUNT_NAME
+SERVICE_ACCOUNT_NAME=${SERVICE_ACCOUNT_NAME:-"${DEFAULT_SERVICE_ACCOUNT_NAME}"}
-read -p "請輸入工作負載身份池 ID [github-actions-pool]: " POOL_ID
-POOL_ID=${POOL_ID:-"github-actions-pool"}
+read -p "請輸入工作負載身份池 ID [${DEFAULT_POOL_ID}]: " POOL_ID
+POOL_ID=${POOL_ID:-"${DEFAULT_POOL_ID}"}
-read -p "請輸入工作負載身份提供者 ID [github-provider]: " PROVIDER_ID
-PROVIDER_ID=${PROVIDER_ID:-"github-provider"}
+read -p "請輸入工作負載身份提供者 ID [${DEFAULT_PROVIDER_ID}]: " PROVIDER_ID
+PROVIDER_ID=${PROVIDER_ID:-"${DEFAULT_PROVIDER_ID}"}
-read -p "請輸入您的 GitHub 用戶名: " GITHUB_USERNAME
+read -p "請輸入您的 GitHub 用戶名 [${DEFAULT_GITHUB_USERNAME}]: " GITHUB_USERNAME
+GITHUB_USERNAME=${GITHUB_USERNAME:-"${DEFAULT_GITHUB_USERNAME}"}
if [ -z "$GITHUB_USERNAME" ]; then
echo -e "${RED}錯誤: GitHub 用戶名不能為空${NC}"
exit 1
fi
-read -p "請輸入存儲庫名稱 [one-key-balance-kit]: " REPO_NAME
-REPO_NAME=${REPO_NAME:-"one-key-balance-kit"}
+read -p "請輸入存儲庫名稱 [${DEFAULT_REPO_NAME}]: " REPO_NAME
+REPO_NAME=${REPO_NAME:-"${DEFAULT_REPO_NAME}"}
# 組合完整的 GitHub 存儲庫名稱
GITHUB_REPO="${GITHUB_USERNAME}/${REPO_NAME}"
# 創建或更新環境變數文件
-ENV_FILE=".env.gcp"
echo "# Google Cloud 環境變數" > $ENV_FILE
echo "export PROJECT_ID=${PROJECT_ID}" >> $ENV_FILE
echo "export REGION=${REGION}" >> $ENV_FILE
@@ -56,9 +116,6 @@ echo "export GITHUB_REPO=${GITHUB_REPO}" >> $ENV_FILE
# 設置服務帳號電子郵件
echo "export SERVICE_ACCOUNT_EMAIL=${SERVICE_ACCOUNT_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" >> $ENV_FILE
-# 設置儲存庫名稱
-echo "export REPOSITORY_NAME=${REPO_NAME}" >> $ENV_FILE
-
# 輸出結果
echo -e "${GREEN}環境變數已保存到 ${ENV_FILE} 檔案${NC}"
echo -e "${YELLOW}請執行以下命令載入環境變數:${NC}"
diff --git a/scripts/test-cloudbuild-vars.sh b/scripts/test-cloudbuild-vars.sh
new file mode 100755
index 0000000..d2ba6a9
--- /dev/null
+++ b/scripts/test-cloudbuild-vars.sh
@@ -0,0 +1,186 @@
+#!/bin/bash
+set -e
+
+# ---------- 顏色設置 ----------
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+BOLD='\033[1m'
+
+# ---------- 工作目錄 ----------
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+ROOT_DIR="$(dirname "$SCRIPT_DIR")"
+cd "$ROOT_DIR"
+
+# ---------- 配置 ----------
+CLOUDBUILD_FILE="$ROOT_DIR/cloudbuild.yaml"
+TEST_DIR="$ROOT_DIR/test-build"
+TEST_ENV="${1:-staging}" # 第一個參數或默認值
+
+# ---------- 輔助函數 ----------
+header() {
+ echo -e "\n${BOLD}${BLUE}### $1 ###${NC}\n"
+}
+
+success() {
+ echo -e "${GREEN}$1${NC}"
+}
+
+warning() {
+ echo -e "${YELLOW}$1${NC}"
+}
+
+error() {
+ echo -e "${RED}$1${NC}"
+ if [ "$2" == "exit" ]; then
+ exit 1
+ fi
+}
+
+# ---------- 檢查文件存在 ----------
+if [ ! -f "$CLOUDBUILD_FILE" ]; then
+ error "找不到 cloudbuild.yaml 文件" "exit"
+fi
+
+# ---------- 建立測試目錄 ----------
+mkdir -p "$TEST_DIR"
+echo "創建測試目錄: $TEST_DIR"
+
+# ---------- 定義測試環境變數 ----------
+TEST_PROJECT_ID="one-key-balance-kit"
+TEST_REGION="asia-east1"
+TEST_SHA="test123"
+TEST_MAX_INSTANCES="2"
+
+# 根據環境設置額外變數
+if [ "$TEST_ENV" == "production" ]; then
+ echo "測試生產環境 (production)"
+else
+ echo "測試測試環境 (staging)"
+fi
+
+# ---------- 檢測變量引用 ----------
+header "檢測 Cloud Build 變數引用"
+VAR_REFS=$(grep -o '\${[^}]*}' "$CLOUDBUILD_FILE" | sort -u)
+echo "在 cloudbuild.yaml 中找到以下變數引用:"
+echo "$VAR_REFS"
+
+# 檢查潛在問題變數
+PROBLEM_VARS=$(echo "$VAR_REFS" | grep -v '\${_\|PROJECT_ID\|SHORT_SHA}')
+if [ -n "$PROBLEM_VARS" ]; then
+ warning "發現可能有問題的變數引用:"
+ echo "$PROBLEM_VARS"
+fi
+
+# ---------- 進行變數替換測試 ----------
+header "進行變數替換測試"
+
+# 複製並處理檔案
+TEST_BUILD_FILE="$TEST_DIR/cloudbuild-test.yaml"
+cp "$CLOUDBUILD_FILE" "$TEST_BUILD_FILE"
+
+# 對測試檔案進行變數替換
+echo "替換測試值:"
+echo " - _ENV: $TEST_ENV"
+echo " - _REGION: $TEST_REGION"
+echo " - PROJECT_ID: $TEST_PROJECT_ID"
+echo " - SHORT_SHA: $TEST_SHA"
+echo " - _MAX_INSTANCES: $TEST_MAX_INSTANCES"
+
+# 使用 sed 進行替換 (使用不同分隔符避免路徑中的 / 造成問題)
+sed -i "s|\${_ENV}|$TEST_ENV|g" "$TEST_BUILD_FILE"
+sed -i "s|\${_REGION}|$TEST_REGION|g" "$TEST_BUILD_FILE"
+sed -i "s|\${SHORT_SHA}|$TEST_SHA|g" "$TEST_BUILD_FILE"
+sed -i "s|\$PROJECT_ID|$TEST_PROJECT_ID|g" "$TEST_BUILD_FILE"
+sed -i "s|\${_MAX_INSTANCES}|$TEST_MAX_INSTANCES|g" "$TEST_BUILD_FILE"
+
+# 檢查替換後的檔案中是否還有變數引用
+REMAINING_VARS=$(grep -o '\${[^}]*}' "$TEST_BUILD_FILE" | sort -u)
+if [ -n "$REMAINING_VARS" ]; then
+ warning "替換後仍有以下未解析的變數引用:"
+ echo "$REMAINING_VARS"
+else
+ success "所有基本變數均已成功替換"
+fi
+
+# ---------- 生成純 Bash 測試腳本 ----------
+header "生成純 Bash 測試腳本"
+
+# 提取第一個步驟的 Bash 腳本
+BASH_TEST_FILE="$TEST_DIR/build-image-test.sh"
+BUILD_SCRIPT=$(grep -A 1000 "args:" "$TEST_BUILD_FILE" | grep -m 1 -B 1000 "# ---" | grep -v "args:" | grep -v "# ---" | sed 's/^[ \t]*//')
+echo "#!/bin/bash" > "$BASH_TEST_FILE"
+echo "" >> "$BASH_TEST_FILE"
+echo "# 測試環境設置" >> "$BASH_TEST_FILE"
+echo "export PROJECT_ID=\"$TEST_PROJECT_ID\"" >> "$BASH_TEST_FILE"
+echo "export SHORT_SHA=\"$TEST_SHA\"" >> "$BASH_TEST_FILE"
+echo "export _ENV=\"$TEST_ENV\"" >> "$BASH_TEST_FILE"
+echo "export _REGION=\"$TEST_REGION\"" >> "$BASH_TEST_FILE"
+echo "export _MAX_INSTANCES=\"$TEST_MAX_INSTANCES\"" >> "$BASH_TEST_FILE"
+echo "" >> "$BASH_TEST_FILE"
+echo "# 測試腳本 (從 Cloud Build 提取)" >> "$BASH_TEST_FILE"
+echo "$BUILD_SCRIPT" >> "$BASH_TEST_FILE"
+echo "echo \"測試完成!\"" >> "$BASH_TEST_FILE"
+
+# 提取第二個步驟的 Bash 腳本
+BASH_DEPLOY_FILE="$TEST_DIR/deploy-test.sh"
+DEPLOY_SCRIPT=$(grep -A 1000 "# --- 2)" "$TEST_BUILD_FILE" | grep -A 1000 "args:" | grep -m 1 -B 1000 "images:" | grep -v "args:" | grep -v "images:" | sed 's/^[ \t]*//' | grep -v "^-")
+echo "#!/bin/bash" > "$BASH_DEPLOY_FILE"
+echo "" >> "$BASH_DEPLOY_FILE"
+echo "# 測試環境設置" >> "$BASH_DEPLOY_FILE"
+echo "export PROJECT_ID=\"$TEST_PROJECT_ID\"" >> "$BASH_DEPLOY_FILE"
+echo "export SHORT_SHA=\"$TEST_SHA\"" >> "$BASH_DEPLOY_FILE"
+echo "export _ENV=\"$TEST_ENV\"" >> "$BASH_DEPLOY_FILE"
+echo "export _REGION=\"$TEST_REGION\"" >> "$BASH_DEPLOY_FILE"
+echo "export _MAX_INSTANCES=\"$TEST_MAX_INSTANCES\"" >> "$BASH_DEPLOY_FILE"
+echo "" >> "$BASH_DEPLOY_FILE"
+echo "# 測試腳本 (從 Cloud Build 提取)" >> "$BASH_DEPLOY_FILE"
+echo "$DEPLOY_SCRIPT" >> "$BASH_DEPLOY_FILE"
+echo "echo \"測試完成!\"" >> "$BASH_DEPLOY_FILE"
+
+# 設置可執行權限
+chmod +x "$BASH_TEST_FILE"
+chmod +x "$BASH_DEPLOY_FILE"
+
+success "純 Bash 測試腳本已生成:"
+echo "構建映像腳本: $BASH_TEST_FILE"
+echo "部署測試腳本: $BASH_DEPLOY_FILE"
+
+# ---------- gcloud 模擬測試命令 ----------
+header "模擬 Cloud Build 測試命令"
+
+if command -v gcloud &> /dev/null; then
+ echo -e "您可以使用以下 ${CYAN}gcloud${NC} 命令進行無源碼測試:"
+ echo -e "${CYAN}gcloud builds submit --no-source \\
+ --config=$TEST_BUILD_FILE \\
+ --substitutions=_ENV=$TEST_ENV,_REGION=$TEST_REGION,_MAX_INSTANCES=$TEST_MAX_INSTANCES \\
+ --project=$TEST_PROJECT_ID${NC}"
+ echo ""
+ echo -e "${YELLOW}注意: 這個命令不會實際執行構建,但會驗證配置文件語法${NC}"
+else
+ warning "未找到 gcloud 命令,無法提供測試命令"
+fi
+
+# ---------- 測試構建腳本 ----------
+header "執行純 Bash 測試腳本 (dry run)"
+
+echo -e "${YELLOW}若要在本地執行構建映像測試腳本 (不實際構建):${NC}"
+echo -e "${CYAN}bash -n $BASH_TEST_FILE${NC}"
+echo ""
+echo -e "${YELLOW}若要在本地執行部署測試腳本 (不實際部署):${NC}"
+echo -e "${CYAN}bash -n $BASH_DEPLOY_FILE${NC}"
+echo ""
+
+# ---------- 完成 ----------
+header "測試環境準備完成"
+echo "測試文件位於: $TEST_DIR"
+echo ""
+echo -e "${YELLOW}提示:${NC}"
+echo "1. 查看 $TEST_BUILD_FILE 確認變數替換是否正確"
+echo "2. 使用 bash -n 檢查腳本語法"
+echo "3. 若需在本地執行構建腳本(不實際推送映像),請修改腳本移除 --push 參數"
+echo ""
+success "測試準備完成!"
diff --git a/scripts/validate-cloudbuild.sh b/scripts/validate-cloudbuild.sh
new file mode 100755
index 0000000..08d75ff
--- /dev/null
+++ b/scripts/validate-cloudbuild.sh
@@ -0,0 +1,192 @@
+#!/bin/bash
+
+# ---------- 顏色設置 ----------
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+BOLD='\033[1m'
+
+# ---------- 工作目錄 ----------
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+ROOT_DIR="$(dirname "$SCRIPT_DIR")"
+cd "$ROOT_DIR"
+
+# ---------- 載入 GCP 環境變數 (如果存在) ----------
+ENV_FILE="$ROOT_DIR/.env.gcp"
+if [ -f "$ENV_FILE" ]; then
+ echo -e "${CYAN}載入 GCP 環境變數: $ENV_FILE${NC}"
+ source "$ENV_FILE"
+ PROJECT_ID_FROM_ENV=$PROJECT_ID
+ REGION_FROM_ENV=$REGION
+else
+ echo -e "${YELLOW}注意: 找不到 .env.gcp 文件,將使用預設測試值${NC}"
+fi
+
+# ---------- 檢查必要工具 ----------
+check_command() {
+ if ! command -v "$1" &> /dev/null; then
+ echo -e "${RED}錯誤: 找不到命令 '$1'${NC}"
+ echo -e "請先安裝該命令,若使用 macOS,可以運行: ${CYAN}brew install $2${NC}"
+ exit 1
+ fi
+}
+
+# 檢查基本工具
+check_command "grep" "grep"
+check_command "sed" "gnu-sed"
+check_command "awk" "awk"
+
+# ---------- 輔助函數 ----------
+section() {
+ local title="$1"
+ echo -e "\n${BOLD}${CYAN}=== $title ===${NC}"
+}
+
+pass() {
+ echo -e "${GREEN}✓ $1${NC}"
+}
+
+warn() {
+ echo -e "${YELLOW}⚠ $1${NC}"
+}
+
+error() {
+ echo -e "${RED}✗ $1${NC}"
+}
+
+info() {
+ echo -e "${CYAN}ℹ $1${NC}"
+}
+
+# ---------- 檢查文件存在 ----------
+CLOUDBUILD_FILE="$ROOT_DIR/cloudbuild.yaml"
+if [ ! -f "$CLOUDBUILD_FILE" ]; then
+ error "找不到 $CLOUDBUILD_FILE 文件"
+ exit 1
+fi
+
+section "檢查 Cloud Build 配置文件"
+info "雲構建配置文件路徑: $CLOUDBUILD_FILE"
+
+# ---------- 檢查 YAML 語法 ----------
+section "檢查 YAML 語法"
+if command -v yamllint &> /dev/null; then
+ yamllint -d relaxed "$CLOUDBUILD_FILE"
+ if [ $? -eq 0 ]; then
+ pass "YAML 語法正確"
+ else
+ error "YAML 語法錯誤"
+ fi
+else
+ warn "yamllint 未安裝,跳過 YAML 語法檢查"
+ echo -e "可通過運行 ${CYAN}brew install yamllint${NC} 安裝"
+fi
+
+# ---------- 檢查 Cloud Build 變數語法 ----------
+section "檢查 Cloud Build 變數語法"
+
+# 檢查所有 ${...} 格式的變數引用
+echo -e "找到的變數引用:"
+VARS=$(grep -o '\${[^}]*}' "$CLOUDBUILD_FILE" | sort -u)
+echo "$VARS" | while read -r var; do
+ # 去除 ${ 和 }
+ varname=${var:2:-1}
+
+ # 檢查是否符合 Cloud Build 變數命名規則
+ if [[ "$varname" == _* ]] || [[ "$varname" == "PROJECT_ID" ]] || [[ "$varname" == "SHORT_SHA" ]]; then
+ echo -e " ${GREEN}$var${NC} - 有效的 Cloud Build 變數"
+ # 檢查是否包含 Bash 特殊語法
+ elif [[ "$varname" == *":-"* ]]; then
+ error " $var - 包含 Bash 預設值語法,不被 Cloud Build 支持"
+ else
+ warn " $var - 不是標準的 Cloud Build 內建或自訂變數"
+ fi
+done
+
+# ---------- 檢查 Bash 變數使用 ----------
+section "檢查 Bash 變數使用"
+
+# 查找所有使用 ${VARNAME} 格式但不在引號內的變數
+bash_vars=$(grep -o '\${[A-Za-z0-9_]*\}' "$CLOUDBUILD_FILE" | grep -v '\${_\|PROJECT_ID\|SHORT_SHA}')
+if [ -n "$bash_vars" ]; then
+ warn "找到可能被 Cloud Build 誤解的 Bash 變數:"
+ echo "$bash_vars"
+ echo -e "建議: 在 Bash 腳本中使用 \$VAR 而不是 \${VAR} 格式"
+else
+ pass "未發現可能被 Cloud Build 誤解的 Bash 變數"
+fi
+
+# ---------- 模擬 Cloud Build 變數替換 ----------
+section "模擬 Cloud Build 變數替換"
+
+# 定義測試環境 (優先使用 .env.gcp 中的值)
+TEST_ENV="staging"
+TEST_REGION=${REGION_FROM_ENV:-"asia-east1"}
+TEST_PROJECT=${PROJECT_ID_FROM_ENV:-"test-project"}
+TEST_SHA="abcdef123"
+
+# 創建臨時文件
+TEMP_FILE=$(mktemp)
+cat "$CLOUDBUILD_FILE" > "$TEMP_FILE"
+
+# 模擬簡單的變數替換
+sed -i "s/\${_ENV}/$TEST_ENV/g" "$TEMP_FILE"
+sed -i "s/\${_REGION}/$TEST_REGION/g" "$TEMP_FILE"
+sed -i "s/\${SHORT_SHA}/$TEST_SHA/g" "$TEMP_FILE"
+sed -i "s/\$PROJECT_ID/$TEST_PROJECT/g" "$TEMP_FILE"
+
+info "已建立模擬變數替換後的臨時檔: $TEMP_FILE"
+info "測試環境: env=$TEST_ENV, region=$TEST_REGION, project=$TEST_PROJECT, sha=$TEST_SHA"
+
+# 檢查替換後是否仍有變數引用
+remaining_vars=$(grep -o '\${[^}]*}' "$TEMP_FILE" | sort -u)
+if [ -n "$remaining_vars" ]; then
+ warn "替換後仍有未解析的變數引用:"
+ echo "$remaining_vars"
+else
+ pass "所有變數均成功替換"
+fi
+
+# ---------- 驗證可能的部署步驟 ----------
+section "驗證可能的部署步驟"
+
+# 詢問是否要執行無源碼測試
+if command -v gcloud &> /dev/null; then
+ # 準備命令
+ EXECUTE_CMD="gcloud builds submit --no-source \\
+ --config=$CLOUDBUILD_FILE \\
+ --substitutions=_ENV=$TEST_ENV,_REGION=$TEST_REGION,_MAX_INSTANCES=1,_GIT_SHA=$TEST_SHA \\
+ --project=$TEST_PROJECT"
+
+ echo -e "${CYAN}以下是無源碼驗證命令:${NC}"
+ echo -e "${CYAN}$EXECUTE_CMD${NC}"
+
+ # 詢問是否執行
+ echo ""
+ read -p "是否執行上述命令進行無源碼驗證? (y/N): " DO_EXECUTE
+
+ if [[ "$DO_EXECUTE" == "y" || "$DO_EXECUTE" == "Y" ]]; then
+ echo -e "\n${YELLOW}執行無源碼驗證...${NC}"
+ eval "$EXECUTE_CMD"
+ if [ $? -eq 0 ]; then
+ pass "無源碼驗證成功"
+ else
+ error "無源碼驗證失敗"
+ fi
+ else
+ info "已跳過執行"
+ fi
+else
+ warn "gcloud 命令未找到,無法提供部署驗證指令"
+fi
+
+# ---------- 清理臨時文件 ----------
+rm -f "$TEMP_FILE"
+
+section "整體評估"
+echo -e "cloudbuild.yaml 驗證已完成。請檢查上述報告中的任何錯誤或警告。"
+echo -e "如需進一步驗證,您可以使用 gcloud CLI 進行無源碼測試。"
+
+exit 0
diff --git a/src/chains/__tests__/chains.module.spec.ts b/src/chains/__tests__/chains.module.spec.ts
index 337958a..35d1a2c 100644
--- a/src/chains/__tests__/chains.module.spec.ts
+++ b/src/chains/__tests__/chains.module.spec.ts
@@ -67,7 +67,7 @@ describe('ChainsModule', () => {
// 驗證動態模組結構
expect(dynamicModule.module).toBe(ChainsModule);
- expect(dynamicModule.imports).toHaveLength(3);
+ expect(dynamicModule.imports).toHaveLength(4);
expect(dynamicModule.controllers).toHaveLength(2);
expect(dynamicModule.providers).toHaveLength(13);
expect(dynamicModule.exports).toHaveLength(7);
diff --git a/src/chains/chains.module.ts b/src/chains/chains.module.ts
index 77aab3e..d122c24 100644
--- a/src/chains/chains.module.ts
+++ b/src/chains/chains.module.ts
@@ -15,6 +15,7 @@ import { ChainsController } from './controllers/chains.controller';
import { ChainIdController } from './controllers/chain-id.controller';
import { ChainRouter } from './services/core/chain-router.service';
import { ProvidersModule } from '../providers/providers.module';
+import { PriceModule } from '../prices/price.module';
/**
* 區塊鏈模組
@@ -28,6 +29,7 @@ import { ProvidersModule } from '../providers/providers.module';
}),
DiscoveryModule,
ProvidersModule, // 提供鏈服務所需的提供者
+ PriceModule, // 提供代幣價格服務
],
controllers: [
ChainsController, // 傳統鏈名稱API控制器
@@ -92,6 +94,7 @@ export class ChainsModule {
}),
DiscoveryModule,
ProvidersModule,
+ PriceModule, // 提供代幣價格服務
],
controllers: [
ChainsController,
diff --git a/src/chains/interfaces/token-balance.interface.ts b/src/chains/interfaces/token-balance.interface.ts
new file mode 100644
index 0000000..44f21bb
--- /dev/null
+++ b/src/chains/interfaces/token-balance.interface.ts
@@ -0,0 +1,18 @@
+/**
+ * 代幣餘額介面
+ * 表示用戶在特定代幣上的餘額
+ */
+export interface TokenBalance {
+ /** 代幣地址 */
+ tokenAddress: string;
+ /** 代幣符號(如 ETH, USDC 等) */
+ symbol: string;
+ /** 代幣名稱 */
+ name?: string;
+ /** 代幣餘額(原始字符串格式) */
+ balance: string;
+ /** 代幣小數位數 */
+ decimals: number;
+ /** USD 價值(可選,如果無法獲取價格則為 0) */
+ usdValue?: number;
+}
diff --git a/src/chains/services/bsc/bsc.service.ts b/src/chains/services/bsc/bsc.service.ts
index fe9de28..48b799f 100644
--- a/src/chains/services/bsc/bsc.service.ts
+++ b/src/chains/services/bsc/bsc.service.ts
@@ -35,4 +35,9 @@ export class BscService extends AbstractEvmChainService {
protected getDefaultChainId(): number {
return 56; // BSC主網
}
+
+ // 添加 validateAddress 方法
+ validateAddress(address: string): boolean {
+ return this.isValidAddress(address);
+ }
}
diff --git a/src/chains/services/core/abstract-chain.service.ts b/src/chains/services/core/abstract-chain.service.ts
index 3730308..5a872c6 100644
--- a/src/chains/services/core/abstract-chain.service.ts
+++ b/src/chains/services/core/abstract-chain.service.ts
@@ -1,6 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { ChainService, ProviderAware } from '../../interfaces/chain-service.interface';
+import { TokenBalance } from '../../interfaces/token-balance.interface';
+/**
+ * 抽象鏈服務基類
+ * 定義所有鏈服務必須實現的基本功能
+ */
@Injectable()
export abstract class AbstractChainService implements ChainService, ProviderAware {
protected readonly logger = new Logger(this.constructor.name);
@@ -66,4 +71,20 @@ export abstract class AbstractChainService implements ChainService, ProviderAwar
protected logDebug(message: string): void {
this.logger.debug(`[${this.getChainName()}] ${message}`);
}
+
+ /**
+ * 獲取用戶在指定地址的代幣餘額
+ * @param address 用戶地址
+ * @param chainId 可選的鏈ID,如未指定則使用當前設定的鏈ID
+ * @param providerType 可選的提供者類型
+ * @returns 代幣餘額資訊
+ */
+ abstract getBalances(address: string, chainId?: number, providerType?: string): Promise;
+
+ /**
+ * 驗證地址是否有效
+ * @param address 要驗證的地址
+ * @returns 地址是否有效
+ */
+ abstract validateAddress(address: string): boolean;
}
diff --git a/src/chains/services/core/abstract-evm-chain.service.ts b/src/chains/services/core/abstract-evm-chain.service.ts
index aa9f545..fe30edb 100644
--- a/src/chains/services/core/abstract-evm-chain.service.ts
+++ b/src/chains/services/core/abstract-evm-chain.service.ts
@@ -266,21 +266,7 @@ export abstract class AbstractEvmChainService
);
}
- // 如果沒有可用的提供者或提供者調用失敗,使用默認實現
- this.logInfo('Using default implementation for balances');
- await new Promise((resolve) => setTimeout(resolve, 10));
- return {
- chainId: this.currentChainId,
- nativeBalance: {
- symbol: this.getChainSymbol() || 'UNKNOWN',
- decimals: this.meta?.decimals || 18,
- balance: '1000000000000000000', // 1 單位
- usd: 0,
- },
- fungibles: [],
- nfts: [],
- updatedAt: Math.floor(Date.now() / 1000),
- };
+ throw new Error(`Provider ${selectedProviderType} is not working`);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logError(`Failed to get ${this.getChainName()} balances: ${errorMessage}`);
diff --git a/src/chains/services/core/chain-router.service.spec.ts b/src/chains/services/core/chain-router.service.spec.ts
index 216e292..0b76900 100644
--- a/src/chains/services/core/chain-router.service.spec.ts
+++ b/src/chains/services/core/chain-router.service.spec.ts
@@ -22,6 +22,16 @@ class MockEvmChainService extends AbstractEvmChainService {
return ChainName.ETHEREUM;
}
+ // 實現 validateAddress 方法
+ validateAddress(address: string): boolean {
+ return this.isValidAddress(address);
+ }
+
+ // 實現 getBalances 方法
+ getBalances(address: string, chainId?: number, providerType?: string): Promise {
+ return Promise.resolve([]);
+ }
+
// 其他必要的實現已在 AbstractEvmChainService 中
setChainId = jest.fn();
getBalance = jest.fn();
@@ -35,6 +45,8 @@ class MockNonEvmChainService implements ChainService {
getTransactionDetails = jest.fn().mockResolvedValue({});
getChainName = jest.fn().mockReturnValue('Solana');
getChainSymbol = jest.fn().mockReturnValue('SOL');
+ validateAddress = jest.fn().mockReturnValue(true);
+ getBalances = jest.fn().mockResolvedValue([]);
}
describe('ChainRouter', () => {
diff --git a/src/chains/services/core/priceable-chain.service.ts b/src/chains/services/core/priceable-chain.service.ts
new file mode 100644
index 0000000..b845f9c
--- /dev/null
+++ b/src/chains/services/core/priceable-chain.service.ts
@@ -0,0 +1,91 @@
+import { Logger } from '@nestjs/common';
+import { AbstractPriceService } from '../../../prices/interfaces/abstract-price.service';
+import { TokenBalance } from '../../interfaces/token-balance.interface';
+import { AbstractChainService } from './abstract-chain.service';
+
+/**
+ * 可報價鏈服務抽象類
+ * 為鏈服務增加價格查詢和幣值轉換能力
+ */
+export abstract class PriceableChainService extends AbstractChainService {
+ protected readonly logger = new Logger(PriceableChainService.name);
+
+ /**
+ * 建構子
+ * @param priceService 價格服務
+ */
+ constructor(protected readonly priceService: AbstractPriceService) {
+ super();
+ }
+
+ /**
+ * 判斷是否應該獲取價格
+ * 預設邏輯:主網取價格,測試網不取
+ * 子類可以覆寫此方法改變行為
+ */
+ protected shouldQuote(): boolean {
+ return !this.isTestnet();
+ }
+
+ /**
+ * 判斷當前鏈是否為測試網
+ * 由子類實現
+ */
+ protected abstract isTestnet(): boolean;
+
+ /**
+ * 為代幣餘額添加 USD 價格
+ * @param balances 原始代幣餘額列表
+ * @returns 包含 USD 價格的代幣餘額列表
+ */
+ protected async quote(balances: TokenBalance[]): Promise {
+ // 測試網直接歸零返回
+ if (!this.shouldQuote()) {
+ return balances.map((balance) => ({
+ ...balance,
+ usdValue: 0,
+ }));
+ }
+
+ try {
+ // 獲取代幣地址列表
+ const tokens = balances.map((balance) => balance.tokenAddress);
+
+ // 如果沒有代幣需要查詢,直接返回
+ if (tokens.length === 0) {
+ return balances;
+ }
+
+ // 獲取價格
+ const chainId = this.getChainId();
+ const prices = await this.priceService.getPrices({
+ chainId,
+ tokens,
+ });
+
+ // 為每個代幣計算 USD 價值
+ return balances.map((balance) => {
+ const price = prices.get(balance.tokenAddress.toLowerCase()) || 0;
+ const usdValue = price * parseFloat(balance.balance);
+
+ return {
+ ...balance,
+ usdValue: usdValue,
+ };
+ });
+ } catch (error) {
+ // 價格服務失敗時,不影響餘額查詢,僅將 USD 價值設為 0
+ this.logger.warn(`獲取價格失敗: ${error.message}`, error.stack);
+
+ return balances.map((balance) => ({
+ ...balance,
+ usdValue: 0,
+ }));
+ }
+ }
+
+ /**
+ * 獲取當前鏈的 Chain ID
+ */
+ protected abstract getChainId(): number;
+}
diff --git a/src/chains/services/core/priceable-evm-chain.service.ts b/src/chains/services/core/priceable-evm-chain.service.ts
new file mode 100644
index 0000000..2548eb1
--- /dev/null
+++ b/src/chains/services/core/priceable-evm-chain.service.ts
@@ -0,0 +1,88 @@
+import { Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { AbstractEvmChainService } from './abstract-evm-chain.service';
+import { AbstractPriceService } from '../../../prices/interfaces/abstract-price.service';
+import { ProviderFactory } from '../../../providers/provider.factory';
+import { BalanceResponse } from '../../interfaces/balance-queryable.interface';
+
+/**
+ * 可報價的EVM鏈服務
+ * 繼承自AbstractEvmChainService,增加價格查詢功能
+ */
+@Injectable()
+export abstract class PriceableEvmChainService extends AbstractEvmChainService {
+ constructor(
+ protected readonly providerFactory: ProviderFactory,
+ protected readonly configService: ConfigService,
+ protected readonly priceService: AbstractPriceService,
+ ) {
+ super(providerFactory, configService);
+ }
+
+ /**
+ * 判斷是否應該獲取價格
+ * 預設邏輯:主網取價格,測試網不取
+ * 子類可以覆寫此方法改變行為
+ */
+ protected shouldQuote(): boolean {
+ return !this.isTestnet();
+ }
+
+ /**
+ * 重寫獲取餘額方法,增加價格查詢
+ * @param address 區塊鏈地址
+ * @param chainId 鏈ID(可選)
+ * @param providerType 提供者類型(可選)
+ */
+ async getBalances(
+ address: string,
+ chainId?: number,
+ providerType?: string,
+ ): Promise {
+ // 先獲取原始餘額
+ const balances = await super.getBalances(address, chainId, providerType);
+
+ // 如果不需要報價,直接返回(usd值為0)
+ if (!this.shouldQuote()) {
+ this.logDebug(`${this.getChainName()} 是測試網或設置為不報價,跳過價格查詢`);
+ return balances;
+ }
+
+ try {
+ // 準備代幣地址列表
+ // 原生代幣使用零地址
+ const tokens = [
+ '0x0000000000000000000000000000000000000000', // 原生代幣
+ ...balances.fungibles.map((token) => token.mint),
+ ];
+
+ // 獲取價格
+ const prices = await this.priceService.getPrices({
+ chainId: this.getChainId(),
+ tokens,
+ });
+
+ // 更新原生代幣價格
+ const nativePrice = prices.get('0x0000000000000000000000000000000000000000') || 0;
+ balances.nativeBalance.usd =
+ (nativePrice * parseFloat(balances.nativeBalance.balance)) /
+ 10 ** balances.nativeBalance.decimals;
+
+ // 更新其他代幣價格
+ balances.fungibles = balances.fungibles.map((fungible) => {
+ const price = prices.get(fungible.mint.toLowerCase()) || 0;
+ return {
+ ...fungible,
+ usd: (price * parseFloat(fungible.balance)) / 10 ** fungible.decimals,
+ };
+ });
+
+ this.logDebug(`成功更新 ${this.getChainName()} 餘額的價格信息`);
+ return balances;
+ } catch (error) {
+ // 價格查詢失敗,不影響餘額查詢結果
+ this.logWarn(`更新 ${this.getChainName()} 價格失敗: ${error.message}`);
+ throw error;
+ }
+ }
+}
diff --git a/src/chains/services/ethereum/ethereum.service.spec.ts b/src/chains/services/ethereum/ethereum.service.spec.ts
index 5fcc01b..4529ebf 100644
--- a/src/chains/services/ethereum/ethereum.service.spec.ts
+++ b/src/chains/services/ethereum/ethereum.service.spec.ts
@@ -1,22 +1,19 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
-import { EthereumService } from './ethereum.service';
import { ProviderFactory } from '../../../providers/provider.factory';
-import { EthereumChainId, ETH_SYMBOL, ETH_DECIMALS } from './constants';
+import { EthereumService } from './ethereum.service';
+import {
+ AbstractPriceService,
+ PriceRequest,
+} from '../../../prices/interfaces/abstract-price.service';
import { ChainName } from '../../constants';
-import { NetworkType } from '../../../providers/interfaces/blockchain-provider.interface';
-
-// 模擬以太坊提供者
-const mockEthereumProvider = {
- getProviderName: jest.fn().mockReturnValue('MockProvider'),
- isSupported: jest.fn().mockReturnValue(true),
- getBalances: jest.fn(),
-};
+import { EthereumChainId, ETH_SYMBOL } from './constants';
+import { anyNumber } from '../../../utils/tests/matchers';
// 模擬提供者工廠
const mockProviderFactory = {
- getEthereumProvider: jest.fn().mockReturnValue(mockEthereumProvider),
- getEvmProvider: jest.fn().mockReturnValue(mockEthereumProvider),
+ getProvider: jest.fn(),
+ getEvmProvider: jest.fn(),
};
// 模擬配置服務
@@ -29,20 +26,87 @@ const mockConfigService = {
}),
};
+// 模擬價格服務
+class MockPriceService implements AbstractPriceService {
+ async getPrices(request: PriceRequest): Promise