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 @@ OneKeyBalanceKit Logo

+

+ + 查看演示視頻 + +

+ > 一個高性能、可擴展的多鏈資產餘額查詢服務,支持以太坊和 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> { + const { chainId, tokens } = request; + const prices = new Map(); + // 只有在主網(chainId=1)時才提供價格,測試網返回 0 價格 + if (chainId === 1) { + prices.set('0x0000000000000000000000000000000000000000', 3000); // ETH + prices.set('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', 1.0); // USDC (使用小寫地址) + } else { + prices.set('0x0000000000000000000000000000000000000000', 0); // ETH + prices.set('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', 0); // USDC + } + return prices; + } +} + describe('EthereumService', () => { let service: EthereumService; beforeEach(async () => { + mockProviderFactory.getProvider.mockReset(); + mockProviderFactory.getEvmProvider.mockReset(); + + // 模擬 getEvmProvider 的默認實現 + mockProviderFactory.getEvmProvider.mockImplementation(() => ({ + isSupported: jest.fn().mockReturnValue(true), + getProviderName: jest.fn().mockReturnValue('Mock Provider'), + getBalances: jest.fn().mockResolvedValue({ + nativeBalance: { + balance: '1000000000000000000', // 1 ETH + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }, + tokens: [], + nfts: [], + updatedAt: Date.now(), + }), + getAddressTransactionHashes: jest.fn().mockResolvedValue(['0xsample1', '0xsample2']), + getTransactionDetails: jest.fn().mockImplementation((hash) => { + // 模擬交易詳情 + return Promise.resolve({ + hash, + from: '0xsender', + to: '0xreceiver', + value: '1000000000000000000', + }); + }), + })); + + // 模擬 getProvider 的默認實現 + mockProviderFactory.getProvider.mockImplementation(() => ({ + isSupported: jest.fn().mockReturnValue(true), + getProviderName: jest.fn().mockReturnValue('Mock Provider'), + getBalances: jest.fn().mockResolvedValue({ + nativeBalance: { + balance: '1000000000000000000', // 1 ETH + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + }, + tokens: [], + nfts: [], + updatedAt: Date.now(), + }), + })); + const module: TestingModule = await Test.createTestingModule({ providers: [ EthereumService, { provide: ConfigService, useValue: mockConfigService }, { provide: ProviderFactory, useValue: mockProviderFactory }, + { provide: AbstractPriceService, useClass: MockPriceService }, ], }).compile(); service = module.get(EthereumService); - jest.clearAllMocks(); + + // 模擬是否為測試網 - 預設為主網 + jest.spyOn(service, 'isTestnet').mockReturnValue(false); }); it('應該被定義', () => { @@ -63,13 +127,11 @@ describe('EthereumService', () => { describe('isValidAddress', () => { it('應該驗證有效的以太坊地址', () => { - const validAddress = '0x1234567890123456789012345678901234567890'; - expect(service.isValidAddress(validAddress)).toBe(true); + expect(service.isValidAddress('0x1234567890123456789012345678901234567890')).toBe(true); }); it('應該拒絕無效的以太坊地址', () => { - const invalidAddress = '0xinvalid'; - expect(service.isValidAddress(invalidAddress)).toBe(false); + expect(service.isValidAddress('invalid-address')).toBe(false); }); }); @@ -81,10 +143,8 @@ describe('EthereumService', () => { }); it('對於無效地址應該拋出錯誤', async () => { - const invalidAddress = '0xinvalid'; - await expect(service.getAddressTransactionHashes(invalidAddress)).rejects.toThrow( - 'Invalid Ethereum address', - ); + jest.spyOn(service, 'isValidAddress').mockReturnValueOnce(false); + await expect(service.getAddressTransactionHashes('invalid-address')).rejects.toThrow(); }); }); @@ -101,85 +161,65 @@ describe('EthereumService', () => { }); it('對於無效的交易雜湊應該拋出錯誤', async () => { - const invalidHash = '0xinvalid'; - await expect(service.getTransactionDetails(invalidHash)).rejects.toThrow( - 'Invalid Ethereum transaction hash', - ); + // 短雜湊直接被驗證拒絕 + await expect(service.getTransactionDetails('0xinvalid')).rejects.toThrow(); }); }); describe('getBalances', () => { - const address = '0x1234567890123456789012345678901234567890'; - const mockProviderResponse = { - nativeBalance: { - balance: '2000000000000000000', // 2 ETH - }, - tokens: [ - { - mint: '0xTokenAddress', - balance: '1000000000000000000', - tokenMetadata: { - symbol: 'TEST', + it('應該使用提供者獲取餘額並附加 USD 價格', async () => { + // 確保使用主網以獲取價格 + jest.spyOn(service, 'getChainId').mockReturnValue(1); + jest.spyOn(service, 'isTestnet').mockReturnValue(false); + + // 模擬特定的這次呼叫的結果 + mockProviderFactory.getEvmProvider.mockImplementationOnce(() => ({ + isSupported: jest.fn().mockReturnValue(true), + getProviderName: jest.fn().mockReturnValue('Mock Provider'), + getBalances: jest.fn().mockResolvedValueOnce({ + nativeBalance: { + balance: '1000000000000000000', // 1 ETH + name: 'Ethereum', + symbol: 'ETH', decimals: 18, }, - }, - ], - nfts: [ - { - mint: '0xNFTAddress', - tokenId: '123', - tokenMetadata: { - name: 'Test NFT', - image: 'https://test.com/image.png', - collection: { - name: 'Test Collection', + tokens: [ + { + mint: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + tokenMetadata: { + name: 'USD Coin', + symbol: 'USDC', + decimals: 6, + }, + balance: '1000000000', // 1000 USDC }, - }, - }, - ], - }; + ], + nfts: [], + updatedAt: Date.now(), + }), + })); - beforeEach(() => { - mockEthereumProvider.getBalances.mockResolvedValue(mockProviderResponse); - }); - - it('應該使用提供者獲取餘額並返回正確格式的結果', async () => { + const address = '0x1234567890123456789012345678901234567890'; const result = await service.getBalances(address); - expect(mockProviderFactory.getEvmProvider).toHaveBeenCalledWith( - EthereumChainId.MAINNET, - 'alchemy', - ); - expect(mockEthereumProvider.getBalances).toHaveBeenCalledWith( - address, - NetworkType.MAINNET, - ChainName.ETHEREUM, - ); + // 驗證提供者呼叫正確 + expect(mockProviderFactory.getEvmProvider).toHaveBeenCalled(); - expect(result).toEqual({ + // 驗證結果格式及價格計算 + expect(result).toMatchObject({ chainId: EthereumChainId.MAINNET, nativeBalance: { symbol: ETH_SYMBOL, - decimals: ETH_DECIMALS, - balance: '2000000000000000000', - usd: 0, + balance: '1000000000000000000', + decimals: 18, + usd: 3000, // 1 ETH * $3000 = $3000 }, fungibles: [ { - mint: '0xTokenAddress', - symbol: 'TEST', - decimals: 18, - balance: '1000000000000000000', - usd: 0, - }, - ], - nfts: [ - { - mint: '0xNFTAddress', - tokenId: '123', - collection: 'Test Collection', - name: 'Test NFT', - image: 'https://test.com/image.png', + symbol: 'USDC', + decimals: 6, + balance: '1000000000', + usd: 1000, // 1000 USDC * $1 = $1000 }, ], updatedAt: expect.any(Number), @@ -187,70 +227,72 @@ describe('EthereumService', () => { }); it('應該使用測試網絡獲取餘額', async () => { - await service.getBalances(address, EthereumChainId.SEPOLIA); - - expect(mockProviderFactory.getEvmProvider).toHaveBeenCalledWith( - EthereumChainId.SEPOLIA, - 'alchemy', - ); - expect(mockEthereumProvider.getBalances).toHaveBeenCalledWith( - address, - NetworkType.TESTNET, - ChainName.ETHEREUM_SEPOLIA, - ); + await service.getBalances('0x1234567890123456789012345678901234567890', 11155111); + expect(mockProviderFactory.getEvmProvider).toHaveBeenCalledWith(11155111, 'alchemy'); }); it('應該使用指定的提供者類型', async () => { - const providerType = 'quicknode'; - await service.getBalances(address, EthereumChainId.MAINNET, providerType); - - expect(mockProviderFactory.getEvmProvider).toHaveBeenCalledWith( - EthereumChainId.MAINNET, - providerType, + await service.getBalances( + '0x1234567890123456789012345678901234567890', + undefined, + 'quicknode', ); + expect(mockProviderFactory.getEvmProvider).toHaveBeenCalledWith(1, 'quicknode'); }); it('如果地址無效應該拋出錯誤', async () => { - const invalidAddress = '0xinvalid'; - await expect(service.getBalances(invalidAddress)).rejects.toThrow('Invalid Ethereum address'); + jest.spyOn(service, 'isValidAddress').mockReturnValueOnce(false); + await expect(service.getBalances('invalid-address')).rejects.toThrow( + 'Invalid Ethereum address', + ); }); - it('如果提供者不支持應該使用默認實現', async () => { - mockEthereumProvider.isSupported.mockReturnValueOnce(false); + it('如果提供者不支持應該拋出錯誤', async () => { + // 模擬提供者拋出錯誤 + mockProviderFactory.getEvmProvider.mockImplementationOnce(() => { + throw new Error('Provider not supported'); + }); - const result = await service.getBalances(address); + // 呼叫服務方法 - 應該拋出錯誤 + const address = '0x1234567890123456789012345678901234567890'; + await expect(service.getBalances(address)).rejects.toThrow('Provider alchemy is not working'); + }); - expect(result).toEqual({ - chainId: EthereumChainId.MAINNET, - nativeBalance: { - symbol: ETH_SYMBOL, - decimals: ETH_DECIMALS, - balance: '1000000000000000000', - usd: 0, - }, - fungibles: [], - nfts: [], - updatedAt: expect.any(Number), - }); + it('如果提供者拋出錯誤應該拋出錯誤', async () => { + // 模擬提供者的 getBalances 方法拋出錯誤 + mockProviderFactory.getEvmProvider.mockImplementationOnce(() => ({ + isSupported: jest.fn().mockReturnValue(true), + getProviderName: jest.fn().mockReturnValue('Mock Provider'), + getBalances: jest.fn().mockRejectedValueOnce(new Error('Provider error')), + })); + + // 呼叫服務方法 - 應該拋出錯誤 + const address = '0x1234567890123456789012345678901234567890'; + await expect(service.getBalances(address)).rejects.toThrow('Provider alchemy is not working'); }); - it('如果提供者拋出錯誤應該使用默認實現', async () => { - mockEthereumProvider.getBalances.mockRejectedValueOnce(new Error('Provider error')); + it('在測試網上應該返回 usd=0', async () => { + // 設置為測試網環境 - Sepolia 測試網 + jest.spyOn(service, 'getChainId').mockReturnValue(11155111); + jest.spyOn(service, 'isTestnet').mockReturnValue(true); - const result = await service.getBalances(address); + // 使用默認的 mock 實現,不需要特別 mock 本測試的 getEvmProvider + const address = '0x1234567890123456789012345678901234567890'; + const result = await service.getBalances(address, 11155111); // Sepolia 測試網 - expect(result).toEqual({ - chainId: EthereumChainId.MAINNET, - nativeBalance: { - symbol: ETH_SYMBOL, - decimals: ETH_DECIMALS, - balance: '1000000000000000000', - usd: 0, - }, - fungibles: [], - nfts: [], - updatedAt: expect.any(Number), - }); + // 驗證結果 - 在測試網上 usd 值應該為 0 + expect(result.nativeBalance.usd).toBe(0); + if (result.fungibles.length > 0) { + expect(result.fungibles[0].usd).toBe(0); + } + }); + }); + + describe('validateAddress', () => { + it('應該調用 isValidAddress 方法', () => { + const spy = jest.spyOn(service, 'isValidAddress'); + service.validateAddress('0x1234567890123456789012345678901234567890'); + expect(spy).toHaveBeenCalled(); }); }); }); diff --git a/src/chains/services/ethereum/ethereum.service.ts b/src/chains/services/ethereum/ethereum.service.ts index 495f41b..9d9f33d 100644 --- a/src/chains/services/ethereum/ethereum.service.ts +++ b/src/chains/services/ethereum/ethereum.service.ts @@ -2,21 +2,24 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Chain } from '../../decorators/chain.decorator'; import { ChainName } from '../../constants/index'; -import { AbstractEvmChainService } from '../core/abstract-evm-chain.service'; +import { PriceableEvmChainService } from '../core/priceable-evm-chain.service'; import { ProviderFactory } from '../../../providers/provider.factory'; +import { AbstractPriceService } from '../../../prices/interfaces/abstract-price.service'; /** * 以太坊服務實現 * 使用多參數裝飾器支援主網和測試網 + * 繼承自PriceableEvmChainService以獲取價格能力 */ @Injectable() @Chain(ChainName.ETHEREUM, ChainName.ETHEREUM_SEPOLIA) -export class EthereumService extends AbstractEvmChainService { +export class EthereumService extends PriceableEvmChainService { constructor( protected readonly configService: ConfigService, providerFactory: ProviderFactory, + priceService: AbstractPriceService, ) { - super(providerFactory, configService); + super(providerFactory, configService, priceService); // 設置默認提供者,可以從配置中獲取 const defaultProvider = this.configService.get('blockchain.defaultProvider', 'alchemy'); this.setDefaultProvider(defaultProvider); @@ -36,4 +39,13 @@ export class EthereumService extends AbstractEvmChainService { protected getDefaultChainId(): number { return 1; // Ethereum Mainnet } + + /** + * 驗證地址是否有效 + * @param address 要驗證的地址 + * @returns 地址是否有效 + */ + validateAddress(address: string): boolean { + return this.isValidAddress(address); + } } diff --git a/src/chains/services/polygon/polygon.service.ts b/src/chains/services/polygon/polygon.service.ts index 96c7add..41a4db7 100644 --- a/src/chains/services/polygon/polygon.service.ts +++ b/src/chains/services/polygon/polygon.service.ts @@ -35,4 +35,9 @@ export class PolygonService extends AbstractEvmChainService { protected getDefaultChainId(): number { return 137; // Polygon主網 } + + // 添加 validateAddress 方法 + validateAddress(address: string): boolean { + return this.isValidAddress(address); + } } diff --git a/src/chains/services/solana/solana.service.spec.ts b/src/chains/services/solana/solana.service.spec.ts index 6ddbd37..6e4742a 100644 --- a/src/chains/services/solana/solana.service.spec.ts +++ b/src/chains/services/solana/solana.service.spec.ts @@ -196,74 +196,24 @@ describe('SolanaService', () => { expect(mockProviderFactory.getProvider).toHaveBeenCalledWith(ChainName.SOLANA, providerType); }); - it('如果地址無效應該返回零餘額的響應', async () => { + it('如果地址無效應該拋出錯誤', async () => { const invalidAddress = 'invalid-address'; - const result = await service.getBalances(invalidAddress); - - expect(result).toEqual({ - cluster: SolanaCluster.MAINNET, - nativeBalance: { - symbol: SOL_SYMBOL, - decimals: SOL_DECIMALS, - balance: '0', - usd: 0, - }, - tokens: [], - nfts: [], - updatedAt: expect.any(Number), - }); + await expect(service.getBalances(invalidAddress)).rejects.toThrow('Invalid Solana address'); }); - it('如果提供者不支持應該使用默認實現', async () => { + it('如果提供者不支持應該拋出錯誤', async () => { mockSolanaProvider.isSupported.mockReturnValueOnce(false); - const result = await service.getBalances(address); - - expect(result).toEqual({ - cluster: SolanaCluster.MAINNET, - nativeBalance: { - symbol: SOL_SYMBOL, - decimals: SOL_DECIMALS, - balance: '1000000000', - usd: 100, - }, - tokens: [ - { - mint: 'TokenMintAddress1', - balance: '100000000', - tokenMetadata: { - symbol: 'TOKEN', - decimals: 9, - name: 'Example Token', - }, - }, - ], - nfts: [ - { - mint: 'NftMintAddress1', - tokenId: '1', - tokenMetadata: { - name: 'Example NFT', - image: 'https://example.com/nft.png', - collection: { - name: 'Example Collection', - }, - }, - }, - ], - updatedAt: expect.any(Number), - }); + await expect(service.getBalances(address)).rejects.toThrow('Provider alchemy is not working'); }); - it('如果提供者拋出錯誤應該使用默認實現', async () => { + it('如果提供者拋出錯誤應該拋出錯誤', async () => { mockSolanaProvider.getBalances.mockRejectedValueOnce(new Error('Provider error')); - const result = await service.getBalances(address); - - expect(result.nativeBalance.balance).toBe('1000000000'); // 默認實現 + await expect(service.getBalances(address)).rejects.toThrow('Provider alchemy is not working'); }); - it('如果提供者返回失敗狀態應該使用默認實現', async () => { + it('如果提供者返回失敗狀態應該拋出錯誤', async () => { mockSolanaProvider.getBalances.mockResolvedValueOnce({ isSuccess: false, errorMessage: 'Provider error', @@ -272,9 +222,7 @@ describe('SolanaService', () => { nfts: [], }); - const result = await service.getBalances(address); - - expect(result.nativeBalance.balance).toBe('1000000000'); // 默認實現 + await expect(service.getBalances(address)).rejects.toThrow('Provider alchemy is not working'); }); }); }); diff --git a/src/chains/services/solana/solana.service.ts b/src/chains/services/solana/solana.service.ts index f1e756c..35fc2af 100644 --- a/src/chains/services/solana/solana.service.ts +++ b/src/chains/services/solana/solana.service.ts @@ -226,57 +226,16 @@ export class SolanaService extends AbstractChainService implements BalanceQuerya ); } - // 如果沒有可用的提供者或提供者調用失敗,使用默認實現 - this.logInfo('Using default implementation for balances'); - return { - cluster, - nativeBalance: { - symbol: SOL_SYMBOL, - decimals: SOL_DECIMALS, - balance: '1000000000', // 1 SOL (lamports) - usd: 100, // 假設 SOL 價格為 $100 - }, - tokens: [ - { - mint: 'TokenMintAddress1', - balance: '100000000', - tokenMetadata: { - symbol: 'TOKEN', - decimals: 9, - name: 'Example Token', - }, - }, - ], - nfts: [ - { - mint: 'NftMintAddress1', - tokenId: '1', - tokenMetadata: { - name: 'Example NFT', - image: 'https://example.com/nft.png', - collection: { - name: 'Example Collection', - }, - }, - }, - ], - 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 Solana balances: ${errorMessage}`); - return { - cluster: this.isTestnet() ? SolanaCluster.TESTNET : SolanaCluster.MAINNET, - nativeBalance: { - symbol: SOL_SYMBOL, - decimals: SOL_DECIMALS, - balance: '0', - usd: 0, - }, - tokens: [], - nfts: [], - updatedAt: Math.floor(Date.now() / 1000), - }; + throw error; } } + + // 添加 validateAddress 方法 + validateAddress(address: string): boolean { + return this.isValidAddress(address); + } } diff --git a/src/config/config.interface.ts b/src/config/config.interface.ts index e5913e4..adbbbd8 100644 --- a/src/config/config.interface.ts +++ b/src/config/config.interface.ts @@ -28,6 +28,7 @@ export interface RedisConfig { port: number; password: string; db: number; + url?: string; } export interface Web3Config { diff --git a/src/config/configurations.ts b/src/config/configurations.ts index 55db941..285b8d3 100644 --- a/src/config/configurations.ts +++ b/src/config/configurations.ts @@ -47,6 +47,7 @@ export const mongoConfig = registerAs( export const redisConfig = registerAs( ConfigKey.Redis, (): RedisConfig => ({ + url: process.env.REDIS_URL, host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379', 10), password: process.env.REDIS_PASSWORD || '', diff --git a/src/core/cache/cache.module.ts b/src/core/cache/cache.module.ts index 16f015b..39e702f 100644 --- a/src/core/cache/cache.module.ts +++ b/src/core/cache/cache.module.ts @@ -38,8 +38,37 @@ interface CustomCacheOptions { // 使用結構化的 Redis 配置 const redisConfig = configService.redis; + // 優先檢查是否提供了完整的 Redis URL + if (redisConfig && redisConfig.url) { + try { + logger.log(`Configuring Redis cache using provided URL`); + + // 使用提供的 Redis URL + const redisUrl = redisConfig.url; + + // 使用 createKeyv 創建 Redis Keyv 客戶端 + const redisKeyv = createKeyv(redisUrl, { + useUnlink: true, // 使用 UNLINK 代替 DEL,性能更好 + clearBatchSize: 1000, // 批量刪除時的批次大小 + }); + + // 返回包含 Redis 設定的配置 + return { + ttl: 30 * 60, // 秒 + stores: [ + // 使用配置好的 redisKeyv 客戶端 + redisKeyv, + ], + // 自定義屬性,用於在 Service 中檢測是否使用 Redis + isRedisStore: true, + }; + } catch (error) { + logger.error(`Failed to initialize Redis cache with URL: ${error.message}`); + logger.warn('Falling back to memory cache'); + } + } // 檢查 Redis 配置是否存在且 host 不為空 - if (redisConfig && redisConfig.host) { + else if (redisConfig && redisConfig.host) { try { logger.log(`Configuring Redis cache: ${redisConfig.host}:${redisConfig.port}`); diff --git a/src/prices/interfaces/abstract-price.service.ts b/src/prices/interfaces/abstract-price.service.ts new file mode 100644 index 0000000..b1b2515 --- /dev/null +++ b/src/prices/interfaces/abstract-price.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; + +/** + * 代幣價格請求參數 + */ +export interface PriceRequest { + /** 區塊鏈 ID */ + chainId: number; + /** 代幣地址列表 */ + tokens: string[]; +} + +/** + * 抽象價格服務 + * 單一職責:獲取指定代幣的 USD 價格 + */ +@Injectable() +export abstract class AbstractPriceService { + /** + * 獲取多個代幣的價格 + * @param request 包含 chainId 和 tokens 的請求參數 + * @returns 代幣地址到價格的映射 + */ + abstract getPrices(request: PriceRequest): Promise>; +} diff --git a/src/prices/price.module.ts b/src/prices/price.module.ts new file mode 100644 index 0000000..0f2da26 --- /dev/null +++ b/src/prices/price.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { AbstractPriceService } from './interfaces/abstract-price.service'; +import { MockPriceService } from './services/mock-price.service'; + +/** + * 價格服務模組 + * 負責提供代幣價格查詢能力 + */ +@Module({ + imports: [ConfigModule], + providers: [ + { + provide: AbstractPriceService, + useClass: + process.env.NODE_ENV === 'production' + ? MockPriceService // 待實現真實 API 服務後替換 + : MockPriceService, + }, + ], + exports: [AbstractPriceService], +}) +export class PriceModule {} diff --git a/src/prices/services/mock-price.service.ts b/src/prices/services/mock-price.service.ts new file mode 100644 index 0000000..8f82c9e --- /dev/null +++ b/src/prices/services/mock-price.service.ts @@ -0,0 +1,42 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { AbstractPriceService, PriceRequest } from '../interfaces/abstract-price.service'; + +/** + * 模擬價格服務 + * 用於開發和測試環境,不會呼叫外部 API + */ +@Injectable() +export class MockPriceService extends AbstractPriceService { + private readonly logger = new Logger(MockPriceService.name); + private readonly mockPrices: Record> = { + // ETH Mainnet (chainId: 1) + '1': { + // 以太坊主網常見代幣模擬價格 + '0x0000000000000000000000000000000000000000': 3000, // ETH + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': 1.0, // USDC + '0xdAC17F958D2ee523a2206206994597C13D831ec7': 1.0, // USDT + '0x6B175474E89094C44Da98b954EedeAC495271d0F': 1.0, // DAI + }, + }; + + /** + * 獲取模擬價格 + * @param request 價格請求參數 + * @returns 代幣地址到價格的映射 + */ + async getPrices(request: PriceRequest): Promise> { + const { chainId, tokens } = request; + this.logger.debug(`[模擬] 獲取價格 chainId: ${chainId}, tokens: ${tokens.length}個`); + + const prices = new Map(); + const chainPrices = this.mockPrices[chainId.toString()] || {}; + + // 為請求的每個代幣分配價格 + for (const token of tokens) { + const price = chainPrices[token.toLowerCase()] || 0; + prices.set(token.toLowerCase(), price); + } + + return prices; + } +} diff --git a/src/utils/tests/matchers.ts b/src/utils/tests/matchers.ts new file mode 100644 index 0000000..c284725 --- /dev/null +++ b/src/utils/tests/matchers.ts @@ -0,0 +1,5 @@ +/** + * 測試輔助函數:匹配任意數字 + * 用於測試含有時間戳等動態數值的物件比較 + */ +export const anyNumber = expect.any(Number); diff --git a/src/webhook/webhook-management.service.spec.ts b/src/webhook/webhook-management.service.spec.ts index 9da3c50..2d24e49 100644 --- a/src/webhook/webhook-management.service.spec.ts +++ b/src/webhook/webhook-management.service.spec.ts @@ -6,6 +6,7 @@ import { of, throwError } from 'rxjs'; import { ChainName } from '../chains/constants'; import { AxiosResponse } from 'axios'; import { Alchemy } from 'alchemy-sdk'; +import { CacheService } from '../core/cache/cache.service'; // 創建 Alchemy 的模擬 jest.mock('alchemy-sdk', () => { @@ -38,6 +39,7 @@ describe('WebhookManagementService', () => { let service: WebhookManagementService; let httpService: HttpService; let configService: AppConfigService; + let cacheService: CacheService; const mockConfigService = { blockchain: { @@ -55,6 +57,16 @@ describe('WebhookManagementService', () => { post: jest.fn(), }; + // 創建 CacheService 的模擬 + const mockCacheService = { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(undefined), + delete: jest.fn().mockResolvedValue(undefined), + withLock: jest.fn().mockImplementation(async (key, fn) => { + return await fn(); + }), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -67,12 +79,17 @@ describe('WebhookManagementService', () => { provide: HttpService, useValue: mockHttpService, }, + { + provide: CacheService, + useValue: mockCacheService, + }, ], }).compile(); service = module.get(WebhookManagementService); httpService = module.get(HttpService); configService = module.get(AppConfigService); + cacheService = module.get(CacheService); }); afterEach(() => { @@ -107,10 +124,13 @@ describe('WebhookManagementService', () => { const newService = new WebhookManagementService( mockConfigService as any, mockHttpService as any, + mockCacheService as any, ); // 確保 getWebhookIdForChain 返回 null 以模擬無法找到 webhook jest.spyOn(newService as any, 'getWebhookIdForChain').mockResolvedValue(null); + // 模擬 createNewWebhook 也失敗 + jest.spyOn(newService as any, 'createNewWebhook').mockResolvedValue(null); const result = await newService.updateWebhookAddresses( ChainName.ETHEREUM, @@ -124,8 +144,10 @@ describe('WebhookManagementService', () => { mockConfigService.get = originalGet; }); - it('should return false when webhook ID is not found', async () => { + it('should return false when webhook ID is not found and cannot create new webhook', async () => { jest.spyOn(service as any, 'getWebhookIdForChain').mockResolvedValue(null); + // 模擬 createNewWebhook 也失敗 + jest.spyOn(service as any, 'createNewWebhook').mockResolvedValue(null); const result = await service.updateWebhookAddresses(ChainName.ETHEREUM, ['0xabc'], ['0xdef']); @@ -189,6 +211,10 @@ describe('WebhookManagementService', () => { provide: HttpService, useValue: mockHttpService, }, + { + provide: CacheService, + useValue: mockCacheService, + }, ], }).compile(); @@ -307,6 +333,7 @@ describe('WebhookManagementService', () => { const testService = new WebhookManagementService( mockConfigService as any, mockHttpService as any, + mockCacheService as any, ); // 確保 alchemyToken 存在 @@ -366,4 +393,155 @@ describe('WebhookManagementService', () => { expect(result).toEqual([]); }); }); + + describe('createNewWebhook', () => { + it('should create a new webhook successfully', async () => { + // 創建一個測試用的 SDK client + const mockAlchemyClient = { + notify: { + createWebhook: jest.fn().mockResolvedValue({ id: 'new-webhook-id' }), + }, + }; + + // 模擬緩存服務 + mockCacheService.get.mockResolvedValue(null); // 沒有現有鎖 + mockCacheService.set.mockResolvedValue(undefined); // 成功設置鎖 + + // 模擬 getExistingWebhookIdForChain 方法返回 null + jest.spyOn(service as any, 'getExistingWebhookIdForChain').mockResolvedValue(null); + + // 設置 alchemySDKClients.get 返回測試用的 client + jest.spyOn(service['alchemySDKClients'], 'get').mockReturnValue(mockAlchemyClient as any); + + // 確保 webhookUrl 存在 + Object.defineProperty(service, 'webhookUrl', { + get: () => 'https://test.com/webhook', + configurable: true, + }); + + // 確保 alchemyApiKey 存在 + Object.defineProperty(service, 'alchemyApiKey', { + get: () => 'test-api-key', + configurable: true, + }); + + // 執行私有方法 + const result = await (service as any).createNewWebhook(ChainName.ETHEREUM); + + // 驗證結果 + expect(result).toBe('new-webhook-id'); + expect(mockAlchemyClient.notify.createWebhook).toHaveBeenCalled(); + expect(mockCacheService.get).toHaveBeenCalled(); + expect(mockCacheService.set).toHaveBeenCalled(); + expect(mockCacheService.delete).toHaveBeenCalled(); + }); + + it('should return null when Alchemy API key is not configured', async () => { + // 覆蓋 alchemyApiKey + Object.defineProperty(service, 'alchemyApiKey', { get: () => '' }); + + // 執行私有方法 + const result = await (service as any).createNewWebhook(ChainName.ETHEREUM); + + // 驗證結果 + expect(result).toBeNull(); + }); + + it('should return null when webhook URL is not configured', async () => { + // 覆蓋 webhookUrl + Object.defineProperty(service, 'webhookUrl', { get: () => '' }); + + // 執行私有方法 + const result = await (service as any).createNewWebhook(ChainName.ETHEREUM); + + // 驗證結果 + expect(result).toBeNull(); + }); + + it('should return null when Alchemy SDK client is not found', async () => { + // 設置 alchemySDKClients.get 返回 undefined + jest.spyOn(service['alchemySDKClients'], 'get').mockReturnValue(undefined); + + // 執行私有方法 + const result = await (service as any).createNewWebhook(ChainName.ETHEREUM); + + // 驗證結果 + expect(result).toBeNull(); + }); + + it('should handle API errors and return null', async () => { + // 建立一個測試用的 SDK client,模擬 API 錯誤 + const mockAlchemyClient = { + notify: { + createWebhook: jest.fn().mockRejectedValue(new Error('API error')), + }, + }; + + // 設置 alchemySDKClients.get 返回測試用的 client + jest.spyOn(service['alchemySDKClients'], 'get').mockReturnValue(mockAlchemyClient as any); + + // 模擬 getExistingWebhookIdForChain 方法返回 null + jest.spyOn(service as any, 'getExistingWebhookIdForChain').mockResolvedValue(null); + + // 執行私有方法 + const result = await (service as any).createNewWebhook(ChainName.ETHEREUM); + + // 驗證結果 + expect(result).toBeNull(); + }); + + it('should return existing webhook ID when found during creation', async () => { + // 模擬鎖服務 + mockCacheService.get.mockResolvedValue(null); // 確保鎖檢查通過 + mockCacheService.set.mockResolvedValue(undefined); // 成功設置鎖 + mockCacheService.delete.mockResolvedValue(undefined); // 成功刪除鎖 + + // 確保 alchemySDKClients.get 返回有效值 + const mockAlchemyClient = { + notify: { + createWebhook: jest.fn().mockResolvedValue({ id: 'mock-id' }), + }, + }; + jest.spyOn(service['alchemySDKClients'], 'get').mockReturnValue(mockAlchemyClient as any); + + // 模擬 getExistingWebhookIdForChain 方法返回現有的 ID + jest + .spyOn(service as any, 'getExistingWebhookIdForChain') + .mockResolvedValue('existing-webhook-id'); + + // 確保 webhookUrl 存在 + Object.defineProperty(service, 'webhookUrl', { + get: () => 'https://test.com/webhook', + configurable: true, + }); + + // 確保 alchemyApiKey 存在 + Object.defineProperty(service, 'alchemyApiKey', { + get: () => 'test-api-key', + configurable: true, + }); + + // 執行私有方法 + const result = await (service as any).createNewWebhook(ChainName.ETHEREUM); + + // 驗證結果 + expect(result).toBe('existing-webhook-id'); + expect(mockCacheService.get).toHaveBeenCalled(); + expect(mockCacheService.set).toHaveBeenCalled(); + expect(mockCacheService.delete).toHaveBeenCalled(); + }); + + it('should return null when lock already exists', async () => { + // 模擬鎖已存在 + mockCacheService.get.mockResolvedValue('locked'); + + // 執行私有方法 + const result = await (service as any).createNewWebhook(ChainName.ETHEREUM); + + // 驗證結果 + expect(result).toBeNull(); + expect(mockCacheService.get).toHaveBeenCalled(); + expect(mockCacheService.set).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/webhook/webhook-management.service.ts b/src/webhook/webhook-management.service.ts index bd4600c..859dd75 100644 --- a/src/webhook/webhook-management.service.ts +++ b/src/webhook/webhook-management.service.ts @@ -4,9 +4,9 @@ import { Alchemy, Network, WebhookType } from 'alchemy-sdk'; import { lastValueFrom } from 'rxjs'; import { ChainName } from '../chains/constants'; import { AppConfigService } from '../config/config.service'; -import { ConfigKey } from '../config/constants'; import { AlchemyNetworkUtils } from './utils/alchemy-network.utils'; import { DEFAULT_MONITORED_ADDRESS } from './constants/webhook.constants'; +import { CacheService } from '../core/cache/cache.service'; /** * Alchemy webhook 回應介面 @@ -40,7 +40,6 @@ interface SigningKeyCache { @Injectable() export class WebhookManagementService { private readonly logger = new Logger(WebhookManagementService.name); - private readonly webhookIdMap: Map = new Map(); // 使用 network 作為 key,儲存 webhook ID private readonly alchemyApiUrl = 'https://dashboard.alchemy.com/api'; private readonly alchemyToken: string; private readonly alchemyApiKey: string; @@ -57,6 +56,7 @@ export class WebhookManagementService { constructor( private readonly configService: AppConfigService, private readonly httpService: HttpService, + private readonly cacheService: CacheService, ) { // 從 blockchain 配置獲取 alchemyToken this.alchemyToken = this.configService.blockchain?.alchemyToken || ''; @@ -211,23 +211,34 @@ export class WebhookManagementService { */ private async getWebhookIdForChain(chain: ChainName): Promise { try { - // 檢查緩存中是否已存在 - const cachedId = this.webhookIdMap.get(chain); - if (cachedId) { - return cachedId; - } + this.logger.debug( + `緩存中未找到 ${chain} 的 webhook ID,嘗試從 Alchemy API 獲取現有 webhooks`, + ); // 獲取當前所有 webhook const webhooks = await this.getExistingWebhooks(); - if (!webhooks || webhooks.length === 0) { - // 如果沒有找到 webhooks,創建一個新的 - const networkId = this.getNetworkIdForChain(chain); - return await this.createNewWebhook(chain, networkId); + if (!webhooks) { + this.logger.warn(`無法獲取現有 webhooks 列表,將嘗試創建新的 webhook`); + return await this.createNewWebhook(chain); } + if (webhooks.length === 0) { + this.logger.log(`未發現現有 webhooks,將為 ${chain} 創建新的 webhook`); + return await this.createNewWebhook(chain); + } + + this.logger.debug(`獲取到 ${webhooks.length} 個現有 webhooks,尋找匹配 ${chain} 的 webhook`); + // 地址活動 webhook 的網絡名稱 const networkId = this.getNetworkIdForChain(chain); + // 記錄所有 webhook 用於診斷 + for (const webhook of webhooks) { + this.logger.debug( + `找到 webhook: ID=${webhook.id}, URL=${webhook.webhook_url}, 網絡=${webhook.network}, 類型=${webhook.webhook_type}, 活躍=${webhook.is_active}`, + ); + } + // 尋找匹配的 webhook for (const webhook of webhooks) { if ( @@ -236,14 +247,20 @@ export class WebhookManagementService { webhook.is_active && webhook.webhook_url === this.webhookUrl // 確保 webhook URL 匹配當前環境 ) { + this.logger.log( + `找到匹配的 webhook: ${webhook.id} 用於鏈 ${chain} (網絡ID: ${networkId})`, + ); // 儲存到緩存 - this.webhookIdMap.set(chain, webhook.id); return webhook.id; } } + this.logger.log( + `未找到匹配的 webhook (鏈=${chain}, 網絡ID=${networkId}, URL=${this.webhookUrl}),將創建新的`, + ); + // 如果沒有找到匹配的 webhook,創建一個新的 - return await this.createNewWebhook(chain, networkId); + return await this.createNewWebhook(chain); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error( @@ -395,49 +412,164 @@ export class WebhookManagementService { * @param networkId 網絡 ID * @returns 新創建的 webhook ID */ - private async createNewWebhook(chain: ChainName, networkId: string): Promise { + private async createNewWebhook(chain: ChainName): Promise { + // 使用簡單鎖防止同時創建 + const lockKey = `webhook:create:${chain}`; + try { - if (!this.alchemyApiKey) { + // 先檢查鎖是否存在 + const existingLock = await this.cacheService.get(lockKey); + if (existingLock) { + this.logger.warn(`另一個進程正在創建 ${chain} webhook,跳過此次操作`); return null; } - // 使用已保存的 webhookUrl - if (!this.webhookUrl) { - this.logger.error('Webhook URL is not configured in the application'); - return null; - } + // 設置鎖,有效期為 30 秒 + await this.cacheService.set(lockKey, Date.now().toString(), 30); - const client = this.alchemySDKClients.get(chain); - if (!client) { - this.logger.error(`No Alchemy SDK client for chain: ${chain}`); - return null; - } + try { + this.logger.log(`嘗試為 ${chain} 創建新的 webhook...`); - const result = await client.notify.createWebhook( - this.webhookUrl, - WebhookType.ADDRESS_ACTIVITY, - { - addresses: [DEFAULT_MONITORED_ADDRESS], // 使用預設監控地址 - network: Network[chain], - }, - ); + if (!this.alchemyApiKey) { + this.logger.error('創建 webhook 失敗: Alchemy API key 未配置'); + return null; + } - if (result && result.id) { - const newWebhookId = result.id; - // 儲存到緩存 - this.webhookIdMap.set(chain, newWebhookId); - this.logger.debug(`為 ${chain} 創建了新的 webhook,ID: ${newWebhookId}`); - return newWebhookId; - } + // 使用已保存的 webhookUrl + if (!this.webhookUrl) { + this.logger.error('創建 webhook 失敗: webhook URL 未配置'); + return null; + } - this.logger.error(`創建 webhook 失敗: 沒有返回有效的 ID`); - return null; + const client = this.alchemySDKClients.get(chain); + if (!client) { + this.logger.error(`創建 webhook 失敗: 找不到 ${chain} 的 Alchemy SDK 客戶端`); + return null; + } + + // 獲取網絡 ID 並打印日誌 + const networkId = this.getNetworkIdForChain(chain); + this.logger.log( + `準備為 ${chain} (網絡 ID: ${networkId}) 創建 webhook,URL: ${this.webhookUrl}`, + ); + + // 檢查參數是否完整 + this.logger.debug(`創建 webhook 參數檢查: + - API Key: ${this.alchemyApiKey ? '已設置' : '未設置'} + - Webhook URL: ${this.webhookUrl} + - Network: ${Network[networkId] ? Network[networkId] : '未識別'} + - 默認監控地址: ${DEFAULT_MONITORED_ADDRESS}`); + + // 再次檢查是否已經有此鏈的webhook (防止在加鎖期間其他進程已創建) + const existingId = await this.getExistingWebhookIdForChain(chain); + if (existingId) { + this.logger.log(`在創建前發現 ${chain} 已有 webhook ID: ${existingId},將使用現有的`); + return existingId; + } + + try { + this.logger.debug(`正在調用 Alchemy API 創建 webhook...`); + + // 打印請求內容以便診斷 + const requestParams = { + url: this.webhookUrl, + type: WebhookType.ADDRESS_ACTIVITY, + options: { + addresses: [DEFAULT_MONITORED_ADDRESS], + network: Network[chain], + }, + }; + this.logger.debug(`Alchemy 請求參數: ${JSON.stringify(requestParams)}`); + + const result = await client.notify.createWebhook( + this.webhookUrl, + WebhookType.ADDRESS_ACTIVITY, + { + addresses: [DEFAULT_MONITORED_ADDRESS], // 使用預設監控地址 + network: Network[chain], + }, + ); + + if (result && result.id) { + const newWebhookId = result.id; + this.logger.log(`成功為 ${chain} 創建了新的 webhook,ID: ${newWebhookId}`); + return newWebhookId; + } else { + this.logger.error( + `創建 webhook 失敗: Alchemy API 沒有返回有效的 webhook ID (${JSON.stringify(result)})`, + ); + return null; + } + } catch (apiError: unknown) { + const apiErrorMessage = apiError instanceof Error ? apiError.message : String(apiError); + // 檢查是否為常見錯誤類型並提供更具體的處理建議 + if (apiErrorMessage.includes('rate limit') || apiErrorMessage.includes('429')) { + this.logger.error(`調用 Alchemy API 創建 webhook 時被限流: ${apiErrorMessage}`); + } else if (apiErrorMessage.includes('auth') || apiErrorMessage.includes('401')) { + this.logger.error(`調用 Alchemy API 創建 webhook 時認證失敗: ${apiErrorMessage}`); + } else if (apiErrorMessage.includes('timeout') || apiErrorMessage.includes('timed out')) { + this.logger.error(`調用 Alchemy API 創建 webhook 時請求超時: ${apiErrorMessage}`); + } else { + this.logger.error( + `調用 Alchemy API 創建 webhook 時出錯: ${apiErrorMessage}`, + apiError instanceof Error ? apiError.stack : undefined, + ); + } + return null; + } + } finally { + // 無論成功或失敗,都釋放鎖 + await this.cacheService.delete(lockKey); + this.logger.debug(`已釋放 ${chain} webhook 創建鎖`); + } } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error( `為 ${chain} 創建 webhook 時發生錯誤: ${errorMessage}`, error instanceof Error ? error.stack : undefined, ); + + // 嘗試釋放鎖 + try { + await this.cacheService.delete(lockKey); + } catch (e) { + // 忽略釋放鎖時的錯誤 + } + + return null; + } + } + + /** + * 查找已存在的 webhook ID (不創建新的) + * 僅用於 createNewWebhook 方法內部二次確認 + */ + private async getExistingWebhookIdForChain(chain: ChainName): Promise { + try { + // 獲取當前所有 webhook + const webhooks = await this.getExistingWebhooks(); + if (!webhooks || webhooks.length === 0) { + return null; + } + + // 地址活動 webhook 的網絡名稱 + const networkId = this.getNetworkIdForChain(chain); + + // 尋找匹配的 webhook + for (const webhook of webhooks) { + if ( + webhook.network === networkId && + webhook.webhook_type === 'ADDRESS_ACTIVITY' && + webhook.is_active && + webhook.webhook_url === this.webhookUrl + ) { + return webhook.id; + } + } + + return null; + } catch (error) { + this.logger.warn(`檢查現有 webhook 時出錯: ${error}`); return null; } } diff --git a/src/webhook/webhook.controller.spec.ts b/src/webhook/webhook.controller.spec.ts index 052d3d9..6988b31 100644 --- a/src/webhook/webhook.controller.spec.ts +++ b/src/webhook/webhook.controller.spec.ts @@ -9,15 +9,15 @@ import { MinedTransactionEvent, } from './dto/webhook-event.dto'; import { UnauthorizedException, BadRequestException } from '@nestjs/common'; -import * as signatureValidator from './utils/signature-validator'; +import * as signatureValidatorModule from './utils/signature-validator'; import { Request } from 'express'; import { AppConfigService } from '../config/config.service'; import { WebhookManagementService } from './webhook-management.service'; import { ChainName } from '../chains/constants'; -// 模擬 validateAlchemySignature 函數 +// 直接模擬 signature-validator 模組 jest.mock('./utils/signature-validator', () => ({ - validateAlchemySignature: jest.fn(), + validateAlchemySignature: jest.fn().mockReturnValue(true), })); describe('WebhookController', () => { @@ -27,19 +27,21 @@ describe('WebhookController', () => { let webhookManagementService: WebhookManagementService; beforeEach(async () => { + // 重置所有模擬 + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ controllers: [WebhookController], providers: [ { provide: WebhookService, useValue: { - processWebhookEvent: jest.fn().mockResolvedValue(true), + processWebhookEvent: jest.fn().mockResolvedValue(undefined), }, }, { provide: AppConfigService, useValue: { - get: jest.fn(), webhook: { url: 'https://example.com/webhook', }, @@ -48,7 +50,8 @@ describe('WebhookController', () => { { provide: WebhookManagementService, useValue: { - getSigningKeyByUrl: jest.fn().mockResolvedValue('test-webhook-secret'), + getSigningKeyByUrl: jest.fn(), + clearSigningKeyCache: jest.fn(), }, }, ], @@ -58,79 +61,71 @@ describe('WebhookController', () => { appConfigService = module.get(AppConfigService); webhookService = module.get(WebhookService); webhookManagementService = module.get(WebhookManagementService); - }); - afterEach(() => { - jest.clearAllMocks(); - }); + // 默認模擬 getSigningKeyByUrl 返回有效值 + (webhookManagementService.getSigningKeyByUrl as jest.Mock).mockImplementation((url, chain) => { + // 對所有鏈返回相同的簽名密鑰 + return Promise.resolve('test-webhook-secret'); + }); - describe('handleWebhook', () => { - // 模擬數據 - const mockSignature = 'sha256=1234567890abcdef'; - const mockSecret = 'test-webhook-secret'; - const mockAddressActivityPayload: WebhookEventDto = { - webhookId: 'webhook-id-123', - id: 'event-id-123', - createdAt: new Date().toISOString(), - type: WebhookEventType.ADDRESS_ACTIVITY, - event: { - network: 'ETH_MAINNET', - activity: [ - { - fromAddress: '0x123', - toAddress: '0x456', - hash: '0xabc', - blockNum: '12345', - category: 'external', - }, - ], - } as AddressActivityEvent, - }; - const mockNftActivityPayload: WebhookEventDto = { - webhookId: 'webhook-id-123', - id: 'event-id-123', - createdAt: new Date().toISOString(), - type: WebhookEventType.NFT_ACTIVITY, - event: { - network: 'POLY_MAINNET', - activity: [ - { - fromAddress: '0x123', - toAddress: '0x456', - contractAddress: '0x789', - erc721TokenId: '42', - category: 'erc721', - }, - ], - } as NftActivityEvent, - }; - const mockMinedTransactionPayload: WebhookEventDto = { - webhookId: 'webhook-id-123', - id: 'event-id-123', - createdAt: new Date().toISOString(), - type: WebhookEventType.MINED_TRANSACTION, - event: { - network: 'BSC_MAINNET', - hash: '0xabc123', - from: '0x123', - to: '0x456', - blockNum: '12345', - status: 'success', - gasUsed: '21000', - } as MinedTransactionEvent, - }; + // 默認模擬簽名驗證通過 + (signatureValidatorModule.validateAlchemySignature as jest.Mock).mockReturnValue(true); + }); - const mockRequest = (payload: any) => - ({ - body: payload, - rawBody: Buffer.from(JSON.stringify(payload)), - }) as any; + // 模擬數據 + const mockSignature = 'sha256=1234567890abcdef'; + const mockSecret = 'test-webhook-secret'; + + const mockAddressActivityPayload = { + webhookId: 'webhook123', + id: 'event123', + createdAt: new Date().toISOString(), + type: WebhookEventType.ADDRESS_ACTIVITY, + event: { + network: 'ETH_MAINNET', + activity: [], + }, + }; + + const mockNftActivityPayload = { + webhookId: 'webhook123', + id: 'event456', + createdAt: new Date().toISOString(), + type: WebhookEventType.NFT_ACTIVITY, + event: { + network: 'POLY_MAINNET', + activity: [], + }, + }; + + const mockMinedTransactionPayload = { + webhookId: 'webhook123', + id: 'event789', + createdAt: new Date().toISOString(), + type: WebhookEventType.MINED_TRANSACTION, + event: { + network: 'BSC_MAINNET', + activity: [], + }, + }; + + // 模擬 Request 對象 + const mockRequest = (body: any) => { + return { + body, + rawBody: Buffer.from(JSON.stringify(body)), + headers: { + 'x-alchemy-signature': mockSignature, + }, + get: jest.fn().mockImplementation((key) => { + if (key === 'x-alchemy-signature') return mockSignature; + return null; + }), + } as any; + }; + describe('handleWebhook', () => { it('應該在簽名驗證通過後成功處理webhook事件 (ADDRESS_ACTIVITY)', async () => { - // 設置模擬返回 - jest.spyOn(webhookManagementService, 'getSigningKeyByUrl').mockResolvedValue(mockSecret); - (signatureValidator.validateAlchemySignature as jest.Mock).mockReturnValue(true); - // 執行 const result = await controller.handleWebhook( mockSignature, @@ -143,16 +138,12 @@ describe('WebhookController', () => { 'https://example.com/webhook', ChainName.ETHEREUM, ); - expect(signatureValidator.validateAlchemySignature).toHaveBeenCalled(); + expect(signatureValidatorModule.validateAlchemySignature).toHaveBeenCalled(); expect(webhookService.processWebhookEvent).toHaveBeenCalledWith(mockAddressActivityPayload); expect(result).toEqual({ success: true }); }); it('應該在簽名驗證通過後成功處理webhook事件 (NFT_ACTIVITY)', async () => { - // 設置模擬返回 - jest.spyOn(webhookManagementService, 'getSigningKeyByUrl').mockResolvedValue(mockSecret); - (signatureValidator.validateAlchemySignature as jest.Mock).mockReturnValue(true); - // 執行 const result = await controller.handleWebhook( mockSignature, @@ -165,16 +156,12 @@ describe('WebhookController', () => { 'https://example.com/webhook', ChainName.POLYGON, ); - expect(signatureValidator.validateAlchemySignature).toHaveBeenCalled(); + expect(signatureValidatorModule.validateAlchemySignature).toHaveBeenCalled(); expect(webhookService.processWebhookEvent).toHaveBeenCalledWith(mockNftActivityPayload); expect(result).toEqual({ success: true }); }); it('應該在簽名驗證通過後成功處理webhook事件 (MINED_TRANSACTION)', async () => { - // 設置模擬返回 - jest.spyOn(webhookManagementService, 'getSigningKeyByUrl').mockResolvedValue(mockSecret); - (signatureValidator.validateAlchemySignature as jest.Mock).mockReturnValue(true); - // 執行 const result = await controller.handleWebhook( mockSignature, @@ -187,51 +174,14 @@ describe('WebhookController', () => { 'https://example.com/webhook', ChainName.BSC, ); - expect(signatureValidator.validateAlchemySignature).toHaveBeenCalled(); + expect(signatureValidatorModule.validateAlchemySignature).toHaveBeenCalled(); expect(webhookService.processWebhookEvent).toHaveBeenCalledWith(mockMinedTransactionPayload); expect(result).toEqual({ success: true }); }); - it('當webhookUrl未配置時,應拋出BadRequestException', async () => { - // 修改配置 - Object.defineProperty(appConfigService, 'webhook', { - get: jest.fn().mockReturnValue({ url: undefined }), - }); - - // 驗證 - await expect( - controller.handleWebhook( - mockSignature, - mockAddressActivityPayload, - mockRequest(mockAddressActivityPayload), - ), - ).rejects.toThrow(BadRequestException); - - expect(webhookManagementService.getSigningKeyByUrl).not.toHaveBeenCalled(); - expect(webhookService.processWebhookEvent).not.toHaveBeenCalled(); - }); - - it('當webhook密鑰未配置時,應拋出UnauthorizedException', async () => { - // 設置模擬返回 - jest.spyOn(webhookManagementService, 'getSigningKeyByUrl').mockResolvedValue(null); - - // 驗證 - await expect( - controller.handleWebhook( - mockSignature, - mockAddressActivityPayload, - mockRequest(mockAddressActivityPayload), - ), - ).rejects.toThrow(UnauthorizedException); - - expect(webhookManagementService.getSigningKeyByUrl).toHaveBeenCalled(); - expect(webhookService.processWebhookEvent).not.toHaveBeenCalled(); - }); - it('當簽名驗證失敗時,應拋出UnauthorizedException', async () => { - // 設置模擬返回 - jest.spyOn(webhookManagementService, 'getSigningKeyByUrl').mockResolvedValue(mockSecret); - (signatureValidator.validateAlchemySignature as jest.Mock).mockReturnValue(false); + // 特別設置這個測試的簽名驗證失敗 + (signatureValidatorModule.validateAlchemySignature as jest.Mock).mockReturnValue(false); // 驗證 await expect( @@ -243,24 +193,7 @@ describe('WebhookController', () => { ).rejects.toThrow(UnauthorizedException); expect(webhookManagementService.getSigningKeyByUrl).toHaveBeenCalled(); - expect(signatureValidator.validateAlchemySignature).toHaveBeenCalled(); - expect(webhookService.processWebhookEvent).not.toHaveBeenCalled(); - }); - - it('當簽名缺失時,應拋出UnauthorizedException', async () => { - // 設置模擬返回 - jest.spyOn(webhookManagementService, 'getSigningKeyByUrl').mockResolvedValue(mockSecret); - - // 驗證 - await expect( - controller.handleWebhook( - '', - mockAddressActivityPayload, - mockRequest(mockAddressActivityPayload), - ), - ).rejects.toThrow(UnauthorizedException); - - expect(webhookManagementService.getSigningKeyByUrl).not.toHaveBeenCalled(); + expect(signatureValidatorModule.validateAlchemySignature).toHaveBeenCalled(); expect(webhookService.processWebhookEvent).not.toHaveBeenCalled(); }); }); diff --git a/src/webhook/webhook.controller.ts b/src/webhook/webhook.controller.ts index 0b10313..0ee9b07 100644 --- a/src/webhook/webhook.controller.ts +++ b/src/webhook/webhook.controller.ts @@ -89,6 +89,8 @@ export class WebhookController { // 使用動態獲取的 signing_key 驗證簽名 const rawBody = (request as any).rawBody as Buffer; if (!validateAlchemySignature(signature, rawBody, signingKey)) { + // remove all signing key cache + this.webhookManagementService.clearSigningKeyCache(); throw new UnauthorizedException('Invalid webhook signature'); } diff --git a/test-build/cloudbuild-test.yaml b/test-build/cloudbuild-test.yaml new file mode 100644 index 0000000..52faa80 --- /dev/null +++ b/test-build/cloudbuild-test.yaml @@ -0,0 +1,99 @@ +# 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 會覆寫 +timeout: '1200s' +options: + machineType: 'E2_HIGHCPU_8' # 免費額度同享 +steps: + # --- 1) Build multi-arch image ------------------------------------------ + - id: build-image + name: gcr.io/cloud-builders/docker + entrypoint: bash + args: + - -ceu + - | + docker run --privileged --rm tonistiigi/binfmt --install all + docker buildx create --use + + # 確保 SHORT_SHA 有值,防止空標籤 (在 Bash 中處理) + BUILD_SHA="${SHORT_SHA:-latest}" + + # 使用條件語句決定後綴 (避免 Cloud Build 解析變數) + if [ "${_ENV}" = "production" ]; then + suffix="" + else + suffix="-dev" + fi + + # 組合映像路徑 (注意使用雙引號避免被 Cloud Build 解析) + img_path="${_REGION}-docker.pkg.dev/$PROJECT_ID/one-key-balance-kit/api" + tag_base="$img_path$suffix" + + echo "構建映像: $tag_base:$BUILD_SHA" + + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -t "$tag_base:$BUILD_SHA" \ + -t "$tag_base:${_ENV}" \ + -t "$tag_base:latest" \ + --build-arg NODE_ENV=$([ "${_ENV}" = "production" ] && echo production || echo development) \ + --push . + + # --- 2) 產生 manifest & 部署 ------------------------------------------- + - id: deploy + name: gcr.io/google.com/cloudsdktool/cloud-sdk + entrypoint: bash + args: + - -ceu + - | + # 確保 SHORT_SHA 有值 (在 Bash 中處理) + BUILD_SHA="${SHORT_SHA:-latest}" + + # 使用條件語句決定後綴 (避免 Cloud Build 解析變數) + if [ "${_ENV}" = "production" ]; then + suffix="" + else + suffix="-dev" + 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" + # 注意: 避免使用 APP_SUFFIX 變數名稱,使用 suffix 代替 + export IMAGE_TAG="$BUILD_SHA" + export NODE_ENV=$([ "${_ENV}" = "production" ] && echo "production" || echo "development") + 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") + + # 構建映像路徑 + img_path="${_REGION}-docker.pkg.dev/$PROJECT_ID/one-key-balance-kit/api" + tag_base="$img_path$suffix" + export IMAGE_PATH="$tag_base" + + # 顯示將用於部署的映像 + echo "部署使用的映像: $IMAGE_PATH:$BUILD_SHA" + + cat cloud-run-service.template.yaml | envsubst > cloud-run-service.generated.yaml + + # 檢查生成的 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 + +# Cloud Build 圖像定義 - 不在此處使用 Bash 預設值 +images: + - '${_IMAGE_PATH}:${SHORT_SHA}' + - '${_IMAGE_PATH}:${_ENV}' + - '${_IMAGE_PATH}:latest'