diff --git a/.github/workflows/dev-deploy.yml b/.github/workflows/dev-deploy.yml index f9442a0..58cf1d4 100644 --- a/.github/workflows/dev-deploy.yml +++ b/.github/workflows/dev-deploy.yml @@ -78,3 +78,246 @@ jobs: run: | firebase deploy --project d-shrine-dev --force working-directory: ./app + + - name: setup go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: go build & test (functions-go) + run: | + go build ./... + go vet ./... + working-directory: ./app/functions-go + + # Firestoreエミュレータの実行にはJDK 21以上が必要(firebase-toolsの要件)。 + # ubuntu-22.04ランナーのデフォルトJavaはこれを満たさないため明示的にセットアップする。 + - name: setup java (for Firestore emulator) + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "21" + + # sanpai/ranking/register/statusCacheBackfill等のFirestore統合テストは + # FIRESTORE_EMULATOR_HOST未設定時は自動スキップされる設計(README参照)。 + # CIでも実際に検証されるよう、Firestoreエミュレータを起動した状態で + # go test を実行する(firebase emulators:exec が終了時に自動でエミュレータを + # 落とすため、後続ステップに影響しない)。 + - name: go test (functions-go, with Firestore emulator) + run: | + firebase emulators:exec --project d-shrine-dev --only firestore \ + 'cd functions-go && go test ./...' + working-directory: ./app + + # OGP画像生成(userOGPGo)のQC。サンプルOGPをPNG/WebPでレンダリングして + # アーティファクト化し、生成物を目視確認できるようにする(描画の破綻を + # デプロイ前に検知する目的も兼ねる)。 + - name: generate OGP sample for QC + run: | + go test ./internal/ogpimage/ -run TestWriteQCArtifacts -count=1 -v + working-directory: ./app/functions-go + env: + OGP_QC_OUT: ${{ github.workspace }}/ogp-qc + + - name: upload OGP sample artifact + uses: actions/upload-artifact@v4 + with: + name: ogp-sample + path: ${{ github.workspace }}/ogp-qc/ + if-no-files-found: error + + # ここから Go版 Cloud Run functions のデプロイ。 + # Gen2(Cloud Run)関数のデプロイは内部でCloud Buildのコンテナビルドを伴い + # 1本あたり数分かかる。関数間に依存が無いため、以前のように1本ずつ直列で + # deployすると全体で15〜30分規模になっていた。そこで前提処理(サービス有効化・ + # Pub/Subトピック作成・認証情報取得・ロケーション解決)を先に済ませてから、 + # 全関数のdeployをバックグラウンドで同時に起動して一括waitする + # (体感で最も遅い1本分の時間に短縮)。 + + # HTTP関数とスケジュール関数(Pub/Subトリガー)の両方に必要なGCPサービスを一括有効化。 + - name: enable required GCP services + run: | + gcloud services enable \ + cloudfunctions.googleapis.com \ + run.googleapis.com \ + cloudbuild.googleapis.com \ + artifactregistry.googleapis.com \ + pubsub.googleapis.com \ + cloudscheduler.googleapis.com \ + eventarc.googleapis.com \ + --project=d-shrine-dev + + # gen2(Cloud Run)関数のイメージ格納先であるArtifact Registryリポジトリ + # (デフォルト名 gcf-artifacts)を事前に作成しておく(冪等)。 + # gcloudは通常このリポジトリを遅延自動解決するが、複数のdeployを並列実行すると + # その解決が競合し、一部が + # `gcloud crashed (AttributeError): 'NoneType' object has no attribute 'dockerRepository'` + # でクラッシュする(並列時のみ・ランダムな一部で発生する非決定的な競合)。 + # 事前作成し、各deployで --docker-repository を明示指定することで自動解決の競合を無くす。 + - name: ensure Artifact Registry repo for gen2 functions + run: | + gcloud artifacts repositories describe gcf-artifacts \ + --location=us-central1 --project=d-shrine-dev \ + || gcloud artifacts repositories create gcf-artifacts \ + --location=us-central1 --repository-format=docker --project=d-shrine-dev + + # Cloud Functions(Gen2)の --trigger-topic は Gen1と異なりPub/Subトピックを + # 自動作成しない(存在しないトピックを指定すると + # `Validation failed for trigger ...: Resource not found` で即失敗する)。 + # そのため各スケジュール関数用トピックを事前に作成しておく + # (既に存在すれば describe が成功するのでスキップする、冪等な実装)。 + - name: create Pub/Sub topics for scheduled Go functions + run: | + for TOPIC in ranking-update-go ranking-cache-go status-cache-backfill-go scheduled-ogp-delete-go; do + gcloud pubsub topics describe "$TOPIC" --project=d-shrine-dev \ + || gcloud pubsub topics create "$TOPIC" --project=d-shrine-dev + done + + # GitHub OAuth Appのclient_id/client_secretは、Node版が使う + # `firebase functions:config:get` (既にGCP側に設定済みの値)からそのまま取得し、 + # 新規のGitHub Secretsは増やさない。ログに値が出ないよう ::add-mask:: で + # マスクしてから $GITHUB_ENV に渡す(sanpaiGoで使用)。 + - name: fetch GitHub OAuth credentials for sanpaiGo + run: | + set -eo pipefail + CONFIG_JSON=$(firebase functions:config:get github --project d-shrine-dev) + CLIENT_ID=$(echo "$CONFIG_JSON" | jq -r '.client_id') + CLIENT_SECRET=$(echo "$CONFIG_JSON" | jq -r '.client_secret') + echo "::add-mask::$CLIENT_ID" + echo "::add-mask::$CLIENT_SECRET" + echo "SANPAI_GH_CLIENT_ID=$CLIENT_ID" >> "$GITHUB_ENV" + echo "SANPAI_GH_CLIENT_SECRET=$CLIENT_SECRET" >> "$GITHUB_ENV" + working-directory: ./app + + # FUNC_BASE_URLはNode版が functions.config().func.base_url から読む値と同じもので、 + # sanpaiGoのGitHub資格情報と同様に既存のFirebase Functions Configから取得する + # (ogpRewriteGoで使用)。 + - name: fetch base_url for ogpRewriteGo + run: | + set -eo pipefail + CONFIG_JSON=$(firebase functions:config:get func --project d-shrine-dev) + BASE_URL=$(echo "$CONFIG_JSON" | jq -r '.base_url') + echo "OGP_FUNC_BASE_URL=$BASE_URL" >> "$GITHUB_ENV" + working-directory: ./app + + # Cloud SchedulerジョブのlocationはプロジェクトのApp Engineアプリと + # 同一でなければならない(App Engineが存在するプロジェクトでは、それ以外の + # リージョンを指定すると `Location must equal ` エラーになる)。 + # `gcloud app describe` が返す locationId はレガシーなApp Engine表記 + # (例: "us-central")の場合があり、Cloud Scheduler/Cloud Functions等の + # 通常のCloudリージョン表記(例: "us-central1")とは異なることがあるため、 + # 既知の対応表で変換する。 + - name: resolve App Engine location for Cloud Scheduler + run: | + set -eo pipefail + RAW_LOCATION=$(gcloud app describe --project=d-shrine-dev --format='value(locationId)') + case "$RAW_LOCATION" in + us-central) LOCATION=us-central1 ;; + europe-west) LOCATION=europe-west1 ;; + *) LOCATION="$RAW_LOCATION" ;; + esac + echo "resolved App Engine location: $RAW_LOCATION -> $LOCATION" + echo "APP_ENGINE_LOCATION=$LOCATION" >> "$GITHUB_ENV" + + # 全Go関数のデプロイをバックグラウンドで同時起動し、まとめてwaitする。 + # 依存関係(トピック/認証情報/ロケーション)は上の先行ステップで解決済み。 + # timeout: HTTP関数は30s、スケジュール関数は全ユーザースキャン等で長くなり得るため + # 安全側に300sを明示。ログは関数ごとにファイルへ退避し、最後に順番に出力する + # (並行実行でログが混ざるのを防ぐ)。1本でも失敗したらステップを失敗させる。 + - name: deploy all Go Cloud Run functions in parallel + working-directory: ./app/functions-go + run: | + set -uo pipefail + # 事前作成済みのAR docker リポジトリを明示指定し、並列deploy時の自動解決競合を防ぐ。 + DOCKER_REPO="projects/d-shrine-dev/locations/us-central1/repositories/gcf-artifacts" + declare -a PIDS NAMES + deploy() { + local name="$1"; shift + local entry="$1"; shift + echo "start deploy: $name" + ( gcloud functions deploy "$name" \ + --project=d-shrine-dev \ + --gen2 \ + --runtime=go125 \ + --region=us-central1 \ + --docker-repository="$DOCKER_REPO" \ + --source=. \ + --entry-point="$entry" \ + "$@" ) > "/tmp/deploy-$name.log" 2>&1 & + PIDS+=("$!") + NAMES+=("$name") + } + + # HTTPトリガー関数 + deploy statusGo StatusGo \ + --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s + deploy sanpaiGo SanpaiGo \ + --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s \ + --set-env-vars="GITHUB_CLIENT_ID=$SANPAI_GH_CLIENT_ID,GITHUB_CLIENT_SECRET=$SANPAI_GH_CLIENT_SECRET,SANPAI_NEXT_TIME_SECONDS=60" + deploy registerGo RegisterGo \ + --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s + deploy rankingGo RankingGo \ + --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s + deploy ogpRewriteGo OgpRewriteGo \ + --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s \ + --set-env-vars="FUNC_BASE_URL=$OGP_FUNC_BASE_URL,OGP_PROJECT_ID=d-shrine-dev" + # userOGPGoは画像合成(base同梱)+WebPエンコード+アバター取得+GCSアップロードを + # 行うため、他HTTP関数より余裕を持たせる(512Mi/60s)。 + deploy userOGPGo UserOGPGo \ + --trigger-http --allow-unauthenticated --memory=512Mi --timeout=60s \ + --set-env-vars="STORAGE_BUCKET_NAME=d-shrine-dev.appspot.com" + + # Pub/Subトリガー(スケジュール)関数 + deploy rankingUpdateGo RankingUpdateGo \ + --trigger-topic=ranking-update-go --memory=256Mi --timeout=300s + deploy rankingCacheGo RankingCacheGo \ + --trigger-topic=ranking-cache-go --memory=256Mi --timeout=300s + deploy statusCacheBackfillGo StatusCacheBackfillGo \ + --trigger-topic=status-cache-backfill-go --memory=512Mi --timeout=300s + deploy scheduledOgpDeleteGo ScheduledOgpDeleteGo \ + --trigger-topic=scheduled-ogp-delete-go --memory=256Mi --timeout=300s \ + --set-env-vars="STORAGE_BUCKET_NAME=d-shrine-dev.appspot.com" + + fail=0 + for idx in "${!NAMES[@]}"; do + name="${NAMES[$idx]}" + if wait "${PIDS[$idx]}"; then + status=OK + else + status=FAILED + fail=1 + fi + echo "::group::deploy $status: $name" + cat "/tmp/deploy-$name.log" + echo "::endgroup::" + done + if [ "$fail" -ne 0 ]; then + echo "one or more Go function deployments failed" >&2 + fi + exit "$fail" + + # スケジュール関数用のCloud Schedulerジョブを作成/更新する(冪等)。 + # ジョブはトピックにメッセージを流すだけなので関数デプロイ完了後に一括で行う。 + # update が存在しないジョブに対して失敗したら create にフォールバックする。 + - name: create/update Cloud Scheduler jobs for scheduled Go functions + run: | + set -uo pipefail + upsert_job() { + local job="$1" schedule="$2" topic="$3" + gcloud scheduler jobs update pubsub "$job" \ + --project=d-shrine-dev --location="$APP_ENGINE_LOCATION" \ + --schedule="$schedule" \ + --topic="$topic" \ + --message-body="{}" \ + --time-zone=Etc/UTC \ + || gcloud scheduler jobs create pubsub "$job" \ + --project=d-shrine-dev --location="$APP_ENGINE_LOCATION" \ + --schedule="$schedule" \ + --topic="$topic" \ + --message-body="{}" \ + --time-zone=Etc/UTC + } + upsert_job ranking-update-go "0 * * * *" ranking-update-go + upsert_job ranking-cache-go "0 */2 * * *" ranking-cache-go + upsert_job status-cache-backfill-go "*/30 * * * *" status-cache-backfill-go + upsert_job scheduled-ogp-delete-go "0 */1 * * *" scheduled-ogp-delete-go diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 8938fa8..0b8a32f 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -34,24 +34,8 @@ jobs: restore-keys: | ${{ runner.os }}-node- - - name: nuxt setup - run: echo "${{ secrets.PROD_ENV }}" > .env - working-directory: ./web - - - name: nuxt generate - run: | - set -ex - yarn - yarn generate - working-directory: ./web - - - name: copy files - run: | - set -ex - rm -rf app/dist - cp -r web/dist app - working-directory: ./ - + # firebase-tools は Go関数デプロイの前提処理(functions:config:get)と + # 最後の firebase deploy の両方で使う。Node関数の依存もここで入れる。 - name: firebase setup run: | set -ex @@ -76,6 +60,242 @@ jobs: install_components: '' project_id: 'd-shrine' + # ==================================================================== + # 1) バックエンド(Go関数)を先にデプロイする。 + # hosting rewrite (/u/* -> ogpRewriteGo) や フロントからのGo関数呼び出しが + # あるため、フロント(firebase deploy)より先にGo関数を出す必要がある。 + # ==================================================================== + + - name: setup go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: go build & test (functions-go) + run: | + go build ./... + go vet ./... + working-directory: ./app/functions-go + + # Firestoreエミュレータの実行にはJDK 21以上が必要(firebase-toolsの要件)。 + - name: setup java (for Firestore emulator) + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "21" + + # Firestore統合テストはFIRESTORE_EMULATOR_HOST未設定時は自動スキップされる設計。 + # 本番デプロイ前のゲートとしてエミュレータを起動した状態で go test を実行する。 + - name: go test (functions-go, with Firestore emulator) + run: | + firebase emulators:exec --project d-shrine --only firestore \ + 'cd functions-go && go test ./...' + working-directory: ./app + + # OGP画像生成(userOGPGo)のQC。サンプルOGPをレンダリングしてアーティファクト化する。 + - name: generate OGP sample for QC + run: | + go test ./internal/ogpimage/ -run TestWriteQCArtifacts -count=1 -v + working-directory: ./app/functions-go + env: + OGP_QC_OUT: ${{ github.workspace }}/ogp-qc + + - name: upload OGP sample artifact + uses: actions/upload-artifact@v4 + with: + name: ogp-sample + path: ${{ github.workspace }}/ogp-qc/ + if-no-files-found: error + + - name: enable required GCP services + run: | + gcloud services enable \ + cloudfunctions.googleapis.com \ + run.googleapis.com \ + cloudbuild.googleapis.com \ + artifactregistry.googleapis.com \ + pubsub.googleapis.com \ + cloudscheduler.googleapis.com \ + eventarc.googleapis.com \ + --project=d-shrine + + # gen2(Cloud Run)関数のイメージ格納先であるArtifact Registryリポジトリ + # (デフォルト名 gcf-artifacts)を事前に作成しておく(冪等)。 + # gcloudは通常このリポジトリを遅延自動解決するが、複数のdeployを並列実行すると + # その解決が競合し、一部が + # `gcloud crashed (AttributeError): 'NoneType' object has no attribute 'dockerRepository'` + # でクラッシュする(並列時のみ・ランダムな一部で発生する非決定的な競合)。 + # 事前作成し、各deployで --docker-repository を明示指定することで自動解決の競合を無くす。 + - name: ensure Artifact Registry repo for gen2 functions + run: | + gcloud artifacts repositories describe gcf-artifacts \ + --location=us-central1 --project=d-shrine \ + || gcloud artifacts repositories create gcf-artifacts \ + --location=us-central1 --repository-format=docker --project=d-shrine + + - name: create Pub/Sub topics for scheduled Go functions + run: | + for TOPIC in ranking-update-go ranking-cache-go status-cache-backfill-go scheduled-ogp-delete-go; do + gcloud pubsub topics describe "$TOPIC" --project=d-shrine \ + || gcloud pubsub topics create "$TOPIC" --project=d-shrine + done + + # GitHub OAuth Appのclient_id/client_secretは、Node版が使う + # `firebase functions:config:get` から取得する(sanpaiGoで使用)。 + - name: fetch GitHub OAuth credentials for sanpaiGo + run: | + set -eo pipefail + CONFIG_JSON=$(firebase functions:config:get github --project d-shrine) + CLIENT_ID=$(echo "$CONFIG_JSON" | jq -r '.client_id') + CLIENT_SECRET=$(echo "$CONFIG_JSON" | jq -r '.client_secret') + echo "::add-mask::$CLIENT_ID" + echo "::add-mask::$CLIENT_SECRET" + echo "SANPAI_GH_CLIENT_ID=$CLIENT_ID" >> "$GITHUB_ENV" + echo "SANPAI_GH_CLIENT_SECRET=$CLIENT_SECRET" >> "$GITHUB_ENV" + working-directory: ./app + + # FUNC_BASE_URLはNode版が functions.config().func.base_url から読む値と同じもの(ogpRewriteGoで使用)。 + - name: fetch base_url for ogpRewriteGo + run: | + set -eo pipefail + CONFIG_JSON=$(firebase functions:config:get func --project d-shrine) + BASE_URL=$(echo "$CONFIG_JSON" | jq -r '.base_url') + echo "OGP_FUNC_BASE_URL=$BASE_URL" >> "$GITHUB_ENV" + working-directory: ./app + + # Cloud SchedulerジョブのlocationはApp Engineアプリと同一でなければならない。 + - name: resolve App Engine location for Cloud Scheduler + run: | + set -eo pipefail + RAW_LOCATION=$(gcloud app describe --project=d-shrine --format='value(locationId)') + case "$RAW_LOCATION" in + us-central) LOCATION=us-central1 ;; + europe-west) LOCATION=europe-west1 ;; + *) LOCATION="$RAW_LOCATION" ;; + esac + echo "resolved App Engine location: $RAW_LOCATION -> $LOCATION" + echo "APP_ENGINE_LOCATION=$LOCATION" >> "$GITHUB_ENV" + + # 全Go関数のデプロイをバックグラウンドで同時起動し、まとめてwaitする。 + # sanpaiGoのクールダウンは本番=300s(Node版 projectID=='d-shrine' ? 300 : 60 と一致)。 + # userOGPGo/scheduledOgpDeleteGoのバケットは本番の d-shrine.appspot.com。 + - name: deploy all Go Cloud Run functions in parallel + working-directory: ./app/functions-go + run: | + set -uo pipefail + # 事前作成済みのAR docker リポジトリを明示指定し、並列deploy時の自動解決競合を防ぐ。 + DOCKER_REPO="projects/d-shrine/locations/us-central1/repositories/gcf-artifacts" + declare -a PIDS NAMES + deploy() { + local name="$1"; shift + local entry="$1"; shift + echo "start deploy: $name" + ( gcloud functions deploy "$name" \ + --project=d-shrine \ + --gen2 \ + --runtime=go125 \ + --region=us-central1 \ + --docker-repository="$DOCKER_REPO" \ + --source=. \ + --entry-point="$entry" \ + "$@" ) > "/tmp/deploy-$name.log" 2>&1 & + PIDS+=("$!") + NAMES+=("$name") + } + + # HTTPトリガー関数 + deploy statusGo StatusGo \ + --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s + deploy sanpaiGo SanpaiGo \ + --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s \ + --set-env-vars="GITHUB_CLIENT_ID=$SANPAI_GH_CLIENT_ID,GITHUB_CLIENT_SECRET=$SANPAI_GH_CLIENT_SECRET,SANPAI_NEXT_TIME_SECONDS=300" + deploy registerGo RegisterGo \ + --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s + deploy rankingGo RankingGo \ + --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s + deploy ogpRewriteGo OgpRewriteGo \ + --trigger-http --allow-unauthenticated --memory=256Mi --timeout=30s \ + --set-env-vars="FUNC_BASE_URL=$OGP_FUNC_BASE_URL,OGP_PROJECT_ID=d-shrine" + deploy userOGPGo UserOGPGo \ + --trigger-http --allow-unauthenticated --memory=512Mi --timeout=60s \ + --set-env-vars="STORAGE_BUCKET_NAME=d-shrine.appspot.com" + + # Pub/Subトリガー(スケジュール)関数 + deploy rankingUpdateGo RankingUpdateGo \ + --trigger-topic=ranking-update-go --memory=256Mi --timeout=300s + deploy rankingCacheGo RankingCacheGo \ + --trigger-topic=ranking-cache-go --memory=256Mi --timeout=300s + deploy statusCacheBackfillGo StatusCacheBackfillGo \ + --trigger-topic=status-cache-backfill-go --memory=512Mi --timeout=300s + deploy scheduledOgpDeleteGo ScheduledOgpDeleteGo \ + --trigger-topic=scheduled-ogp-delete-go --memory=256Mi --timeout=300s \ + --set-env-vars="STORAGE_BUCKET_NAME=d-shrine.appspot.com" + + fail=0 + for idx in "${!NAMES[@]}"; do + name="${NAMES[$idx]}" + if wait "${PIDS[$idx]}"; then + status=OK + else + status=FAILED + fail=1 + fi + echo "::group::deploy $status: $name" + cat "/tmp/deploy-$name.log" + echo "::endgroup::" + done + if [ "$fail" -ne 0 ]; then + echo "one or more Go function deployments failed" >&2 + fi + exit "$fail" + + # スケジュール関数用のCloud Schedulerジョブを作成/更新する(冪等)。 + - name: create/update Cloud Scheduler jobs for scheduled Go functions + run: | + set -uo pipefail + upsert_job() { + local job="$1" schedule="$2" topic="$3" + gcloud scheduler jobs update pubsub "$job" \ + --project=d-shrine --location="$APP_ENGINE_LOCATION" \ + --schedule="$schedule" \ + --topic="$topic" \ + --message-body="{}" \ + --time-zone=Etc/UTC \ + || gcloud scheduler jobs create pubsub "$job" \ + --project=d-shrine --location="$APP_ENGINE_LOCATION" \ + --schedule="$schedule" \ + --topic="$topic" \ + --message-body="{}" \ + --time-zone=Etc/UTC + } + upsert_job ranking-update-go "0 * * * *" ranking-update-go + upsert_job ranking-cache-go "0 */2 * * *" ranking-cache-go + upsert_job status-cache-backfill-go "*/30 * * * *" status-cache-backfill-go + upsert_job scheduled-ogp-delete-go "0 */1 * * *" scheduled-ogp-delete-go + + # ==================================================================== + # 2) Go関数のデプロイ完了後にフロント(+Node関数)をデプロイする。 + # これにより hosting rewrite が指すGo関数が既に存在する状態で公開される。 + # ==================================================================== + + - name: nuxt setup + run: echo "${{ secrets.PROD_ENV }}" > .env + working-directory: ./web + + - name: nuxt generate + run: | + set -ex + yarn + yarn generate + working-directory: ./web + + - name: copy files + run: | + set -ex + rm -rf app/dist + cp -r web/dist app + working-directory: ./ + - name: firebase deploy run: | firebase deploy --project d-shrine --force diff --git a/app/firebase.json b/app/firebase.json index ceb1ee5..7a7bf6d 100644 --- a/app/firebase.json +++ b/app/firebase.json @@ -13,7 +13,7 @@ "rewrites": [ { "source": "/u/*", - "function": "ogpRewrite" + "function": "ogpRewriteGo" }, { "source": "**", diff --git a/app/functions-go/README.md b/app/functions-go/README.md new file mode 100644 index 0000000..44db28c --- /dev/null +++ b/app/functions-go/README.md @@ -0,0 +1,210 @@ +# functions-go + +レイテンシー(特にコールドスタート)がクリティカルなエンドポイントを Go で実装し、 +[Cloud Run functions](https://cloud.google.com/functions) として個別にデプロイするための +モジュール。 + +## なぜGoか + +`app/functions`(Node.js/`firebase-functions`)はマイページ表示の解析キャッシュ導入で +ウォーム時のレイテンシーは大幅に改善したが、コールドスタート自体はランタイム由来の +オーバーヘッドが残る。Go は Node.js よりコールドスタートが大幅に短く(目安: Go 100〜300ms +vs Node.js 300〜800ms)、同時実行数が増えてもコンテナ起動回数(≒課金対象のインスタンス数)を +抑えやすいため、まずマイページ/プロフィール表示で使われる `status` エンドポイントから +Go へ移植している。 + +## なぜ Firebase Functions ではなく Cloud Run functions として直接デプロイするか + +`firebase-functions` SDK自体はGoをサポートしていない(Node.js/Python/実験的Dartのみ)。 +一方、Firebase Functionsの実体はCloud Run functions(旧Cloud Functions 2nd gen)であるため、 +同一GCPプロジェクトに `gcloud functions deploy --gen2 --runtime=go125` で直接デプロイすれば +Firebaseプロジェクトと共存できる。 + +## ディレクトリ構成 + +Cloud Run functions(Go)の制約上、関数のエントリポイント(`functions.HTTP(...)`を呼ぶ +コード)はモジュールルートのパッケージに置く必要がある(サブディレクトリ配置不可)。 +共有ロジックはサブパッケージとして `internal/` 配下に置く。 + +``` +functions-go/ + go.mod + status.go # statusGo エンドポイント(モジュールルートパッケージ) + sanpai.go # sanpaiGo エンドポイント(モジュールルートパッケージ) + sanpai_test.go # sanpaiGoのFirestoreエミュレータ統合テスト + ranking.go # rankingGo エンドポイント + ranking_test.go + register.go # registerGo エンドポイント + register_test.go + ogp_rewrite.go # ogpRewriteGo エンドポイント + ogp_rewrite_test.go + userogp.go # userOGPGo エンドポイント(OGP画像生成) + ranking_update.go # rankingUpdateGo (Pub/Subトリガー、スケジュール関数) + ranking_update_test.go + ranking_cache.go # rankingCacheGo (Pub/Subトリガー、スケジュール関数) + ranking_cache_test.go + status_cache_backfill.go # statusCacheBackfillGo (Pub/Subトリガー、スケジュール関数) + status_cache_backfill_test.go + scheduled_ogp_delete.go # scheduledOgpDeleteGo (Pub/Subトリガー、スケジュール関数) + scheduled_ogp_delete_test.go + cmd/ + main.go # ローカル動作確認専用(デプロイでは使わない) + internal/ + performance/ + performance.go # app/functions/performance.js の対象範囲のGoポート + performance_test.go # performance.test.js と同一の入出力を検証 + ogpimage/ # OGP画像合成(userOGPGoが利用)。設計は同ディレクトリのREADME参照 + ogpimage.go # 合成本体(座標/クロップ/縮小/WebPエンコード) + radar.go # レーダーチャート描画 + base.png # ベース画像(2500x1313, go:embed) + fonts/ # Noto Sans JP(go:embed) +``` + +OGP画像生成 `userOGP` も Go へ移植済み(`userOGPGo`)。画像描画は `internal/ogpimage` +に分離している(詳細は `docs/backend.md`「`userOGP` エンドポイントのGo移植」および +`internal/ogpimage/README.md` を参照)。 + +## 関数の命名規則(既存Node関数との共存) + +Go版は既存のNode版と**別の関数名**でデプロイする(例: `status`(Node) → `statusGo`(Go))。 +同名で運用すると、`firebase deploy`(Node側のソースに存在しない関数として誤って +削除しようとする)と `gcloud functions deploy`(Go側)が同じ関数を取り合う事故が +起こり得るため、意図的に名前を分けて完全に独立させている。 + +移行が完了し十分な期間安定稼働を確認できた関数については、フロントエンドの +呼び出し先をGo版に切り替えたうえで、Node側の対応するexportを削除する +(削除の判断はその都度提案し、承認を得てから行う)。 + +## ローカルでの動作確認 + +```bash +cd app/functions-go +go build ./... +go vet ./... +go test ./... + +# sanpaiGoのFirestore統合テストも含めて実行する場合 +# (firebase emulators:start --only firestore を別途起動しておく) +FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 go test ./... -v + +# Firestoreエミュレータ(別途起動)に対してローカルでHTTPサーバーを起動する場合 +FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 \ +GOOGLE_CLOUD_PROJECT=d-shrine-dev \ +FUNCTION_TARGET=StatusGo \ +PORT=8090 \ +go run ./cmd +``` + +Firestoreエミュレータ統合テスト(`sanpai_test.go`等)は `FIRESTORE_EMULATOR_HOST` +が未設定の場合は自動的にスキップするが、CI(`dev-deploy.yml`)では +`firebase emulators:exec --only firestore 'go test ./...'` の形でFirestore +エミュレータを起動した状態で実行しているため、実際にスキップされず検証される。 +GitHub Events APIはテスト用の `httptest` モックサーバーに差し替えており +(`githubAPIBaseURL` 変数)、実際のGitHub APIやFirebase Authへの通信は発生しない。 + +## デプロイ + +CI(`.github/workflows/dev-deploy.yml`)から以下相当のコマンドで自動デプロイされる。 + +```bash +gcloud functions deploy statusGo \ + --project=d-shrine-dev \ + --gen2 \ + --runtime=go125 \ + --region=us-central1 \ + --source=. \ + --entry-point=StatusGo \ + --trigger-http \ + --allow-unauthenticated \ + --memory=256Mi \ + --timeout=30s +``` + +`--source=.`(functions-goディレクトリ全体)を指定し、`--entry-point` でどの +`functions.HTTP(...)` 登録を使うかを選ぶ。 + +`sanpaiGo` は書き込み系(Firebase認証・GitHub API呼び出し・Firestore更新)のため、 +デプロイ時に以下の環境変数を追加で渡している(`--set-env-vars`)。 + +- `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`: GitHub Events API呼び出し用の + OAuth App資格情報。Node版が `functions.config().github` から読む値と同じもので、 + CIが `firebase functions:config:get github` から取得しCloud Functionsの環境変数 + として橋渡ししている(新規のGitHub Secretsは追加していない)。 +- `SANPAI_NEXT_TIME_SECONDS`: 参拝のクールダウン秒数。Node版は + `projectID == 'd-shrine' ? 300 : 60` とプロジェクトIDで分岐しているが、Go版は + デプロイ時に明示的な値を渡す(dev: `60`。prod移植時は `300` を指定する)。 + +`rankingGo`/`registerGo`/`ogpRewriteGo`/`userOGPGo` も同様のパターンでデプロイしている。 +`ogpRewriteGo` は追加で `FUNC_BASE_URL`(Node版の +`functions.config().func.base_url` 相当)と `OGP_PROJECT_ID` を +`--set-env-vars` で渡す必要がある。`userOGPGo` は画像合成・WebPエンコード・ +アバター取得・GCSアップロードを行うため他HTTP関数より余裕を持たせ +(`--memory=512Mi --timeout=60s`)、OGPキャッシュ書き込み先の +`STORAGE_BUCKET_NAME` を `--set-env-vars` で渡す。 + +## スケジュール関数(Pub/Subトリガー)のデプロイ + +`rankingUpdateGo`/`rankingCacheGo`/`statusCacheBackfillGo`/`scheduledOgpDeleteGo` +はHTTPトリガーではなくPub/Sub(Cloud Scheduler経由)トリガーのため、 +デプロイ方法が異なる。 + +```bash +gcloud functions deploy rankingUpdateGo \ + --project=d-shrine-dev \ + --gen2 \ + --runtime=go125 \ + --region=us-central1 \ + --source=. \ + --entry-point=RankingUpdateGo \ + --trigger-topic=ranking-update-go \ + --memory=256Mi \ + --timeout=300s +``` + +`--trigger-topic` に指定したPub/Subトピックは存在しなければCloud Functions側が +自動作成するため、事前のトピック作成は不要。ただし実際に定期実行するには +別途Cloud Schedulerジョブが必要で、CIでは「更新を試みて、ジョブが無ければ +作成する」形で冪等にセットアップしている(`update`は対象ジョブが存在しない場合 +失敗するため、`||`で`create`にフォールバックする)。 + +Cloud SchedulerジョブのlocationはプロジェクトのApp Engineアプリと同一の +リージョンでなければならない制約があるため、`gcloud app describe`から動的に +取得している。ただし `gcloud app describe` が返す `locationId` はレガシーな +App Engine表記(例: `us-central`)の場合があり、Cloud Scheduler側が要求する +通常のCloudリージョン表記(例: `us-central1`)と異なることがあるため、 +既知のパターンを変換してから使用する(詳細はCIのコメント参照)。 + +```bash +RAW_LOCATION=$(gcloud app describe --project=d-shrine-dev --format='value(locationId)') +# us-central -> us-central1 等、既知の表記ゆれを変換した値をLOCATIONとする +gcloud scheduler jobs update pubsub ranking-update-go \ + --project=d-shrine-dev --location="$LOCATION" \ + --schedule="0 * * * *" \ + --topic=ranking-update-go \ + --message-body="{}" \ + --time-zone=Etc/UTC \ +|| gcloud scheduler jobs create pubsub ranking-update-go \ + --project=d-shrine-dev --location="$LOCATION" \ + --schedule="0 * * * *" \ + --topic=ranking-update-go \ + --message-body="{}" \ + --time-zone=Etc/UTC +``` + +`statusCacheBackfillGo` はNode版の `runWith({timeoutSeconds:300, memory:"512MB"})` +に合わせて `--memory=512Mi --timeout=300s` を指定している。`scheduledOgpDeleteGo` +はCloud Storageを操作するため `STORAGE_BUCKET_NAME`(Node版の +`${projectID}.appspot.com` 相当)を `--set-env-vars` で渡している。 + +各スケジュールの間隔・タイムゾーンはNode版と揃えているが、固定間隔 +(`every N minutes`)の実行に対してタイムゾーンの選択は実行時刻そのものには +影響しない(例えば「毎時0分」はUTCでもAsia/Tokyoでも同じ実時刻に発火する。 +Asia/Tokyoは夏時間の無い固定オフセットのため)ため、CI側は全て `Etc/UTC` を +明示指定している。 + +## Node版との等価性の確認方法 + +新しく移植する際は、Firestoreエミュレータに同一のテストデータを投入し、 +Node版のハンドラをスクリプトから直接呼び出した出力と、Go版をローカル起動して +叩いた出力を比較し、フィールド単位で一致することを確認する +(`status`移植時の実施例は本PRのコミットログ・レビュー履歴を参照)。 diff --git a/app/functions-go/cmd/main.go b/app/functions-go/cmd/main.go new file mode 100644 index 0000000..b312bab --- /dev/null +++ b/app/functions-go/cmd/main.go @@ -0,0 +1,28 @@ +// ローカル動作確認専用のエントリポイント。 +// デプロイ(gcloud functions deploy)では使用しない(buildpackが自動生成するmainを使う)。 +package main + +import ( + "log" + "os" + + "github.com/GoogleCloudPlatform/functions-framework-go/funcframework" + + _ "github.com/428lab/debug-shrine/functions-go" +) + +func main() { + target := os.Getenv("FUNCTION_TARGET") + if target == "" { + target = "StatusGo" + } + os.Setenv("FUNCTION_TARGET", target) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + if err := funcframework.Start(port); err != nil { + log.Fatalf("funcframework.Start: %v", err) + } +} diff --git a/app/functions-go/go.mod b/app/functions-go/go.mod new file mode 100644 index 0000000..3c46c4e --- /dev/null +++ b/app/functions-go/go.mod @@ -0,0 +1,76 @@ +module github.com/428lab/debug-shrine/functions-go + +go 1.25.8 + +require ( + cloud.google.com/go/firestore v1.22.0 + cloud.google.com/go/storage v1.63.0 + firebase.google.com/go/v4 v4.20.0 + github.com/GoogleCloudPlatform/functions-framework-go v1.9.2 + github.com/HugoSmits86/nativewebp v1.3.0 + github.com/cloudevents/sdk-go/v2 v2.15.2 + github.com/fogleman/gg v1.3.0 + golang.org/x/image v0.43.0 + google.golang.org/api v0.287.0 + google.golang.org/grpc v1.81.1 +) + +require ( + cel.dev/expr v0.25.2 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/functions v1.24.0 // indirect + cloud.google.com/go/iam v1.11.0 // indirect + cloud.google.com/go/longrunning v1.0.0 // indirect + cloud.google.com/go/monitoring v1.29.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect + github.com/MicahParks/keyfunc v1.9.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect + github.com/json-iterator/go v1.1.10 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/atomic v1.4.0 // indirect + go.uber.org/multierr v1.1.0 // indirect + go.uber.org/zap v1.10.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/appengine/v2 v2.0.6 // indirect + google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/app/functions-go/go.sum b/app/functions-go/go.sum new file mode 100644 index 0000000..bb4be12 --- /dev/null +++ b/app/functions-go/go.sum @@ -0,0 +1,203 @@ +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E= +cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU= +cloud.google.com/go/functions v1.24.0 h1:0nb8LMMABq/oChZg+ovRD5bsc/dNm5ti/aoHRZ9MoUs= +cloud.google.com/go/functions v1.24.0/go.mod h1:t40GeqBAQNuqKlHCxmV/pxhyYJnImLcvRa3GBv4tAy0= +cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= +cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k= +cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= +cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= +cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= +cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY= +cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= +cloud.google.com/go/storage v1.63.0 h1:hvXF2xfg9I32bjujggxgkEZn/Ej6sJ9pieFgeueBLrQ= +cloud.google.com/go/storage v1.63.0/go.mod h1:tirWVptrFNo5GEX2DQ47JooF7yaweJdAJ1hYAVMvKzE= +cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= +cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= +firebase.google.com/go/v4 v4.20.0 h1:ighpjeAC45rY/95cUQ+ojIKlKcTnz2YC0ldam56z2YU= +firebase.google.com/go/v4 v4.20.0/go.mod h1:hqhkQtZkThGH42TnaYi7A8EFR1E0FEuB5oHvJ1Q57t8= +github.com/GoogleCloudPlatform/functions-framework-go v1.9.2 h1:Cev/PdoxY86bJjGwHJcpiWMhrZMVEoKp9wuEp9gCUvw= +github.com/GoogleCloudPlatform/functions-framework-go v1.9.2/go.mod h1:wLEV4uSJztSBI+QyUy2fkHBuGFjRIAEDOqcEQ2hwmgE= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= +github.com/HugoSmits86/nativewebp v1.3.0 h1:n1egtEzSV4KwFtealr7dzdYq1wI/uj/bOQ/QcTcIyVE= +github.com/HugoSmits86/nativewebp v1.3.0/go.mod h1:YNQuWenlVmSUUASVNhTDwf4d7FwYQGbGhklC8p72Vr8= +github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= +github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudevents/sdk-go/v2 v2.15.2 h1:54+I5xQEnI73RBhWHxbI1XJcqOFOVJN85vb41+8mHUc= +github.com/cloudevents/sdk-go/v2 v2.15.2/go.mod h1:lL7kSWAE/V8VI4Wh0jbL2v/jvqsm6tjmaQBSvxcv4uE= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg= +github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/atomic v1.4.0 h1:cxzIVoETapQEqDhQu3QfnvXAV4AlzcvUCxkVUFw3+EU= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0 h1:ORx85nbTijNz8ljznvCMR1ZBIPKFn3jQrag10X2AsuM= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.287.0 h1:CQDMqUiqZZ0U/Yge3zyjAhNQ0OSYEH0PaA7l4xtEen4= +google.golang.org/api v0.287.0/go.mod h1:pPW85yt3Iuc3unkpaMhFtMmOqnTdCwCqEOaUlnuxRlQ= +google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= +google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= +google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94 h1:YJjbgu+dkp5kUJLfpMyCLfBIWZb/FcJyuLeo1gVBOuo= +google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:RRHjglSYABVCWpQ7USCpdfhcd9t4PkajvVwyynZizTc= +google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 h1:g0RAkxK/smSu/iRwC/KIX1mwUoVJtk2OjbgaeS4DmUM= +google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/app/functions-go/internal/ogpimage/README.md b/app/functions-go/internal/ogpimage/README.md new file mode 100644 index 0000000..667c492 --- /dev/null +++ b/app/functions-go/internal/ogpimage/README.md @@ -0,0 +1,57 @@ +# internal/ogpimage + +ユーザーOGP画像(でばっぐのうりょくカード)を生成するパッケージ。 +`userOGPGo`(`app/functions-go/userogp.go`)から利用する。Node版 +(`app/functions/index.js` の `createOgp`、`canvas` + `chartjs-node-canvas`)相当の +カード合成を、外部プロセス依存なしのGoネイティブ実装として再現する。 + +## ファイル構成 + +| ファイル | 役割 | +| --- | --- | +| `ogpimage.go` | 合成本体。座標定数・フォント/base読込・テキスト/アイコン描画・クロップ&縮小・WebP/PNGエンコード。 | +| `radar.go` | レーダーチャート描画(Chart.js の radar 設定に対応)。ピクセル指定で解像度非依存。 | +| `base.png` | ベースカード画像(2500×1313)。`go:embed` で同梱。 | +| `fonts/NotoSansJP-Regular.otf` | 日本語フォント。`go:embed` で同梱(OFL)。 | +| `ogpimage_test.go` | 出力寸法・WebPマジック・レーダー幾何の単体テスト+QCサンプル生成。 | +| `testdata/` | QC用に生成したサンプルOGP(目視確認用の参照画像)。 | + +## 描画パイプライン + +1. `base.png`(2500×1313)を `gg.Context` に読み込む。 +2. デザイン基準(Node版=2500×1313)の座標定数を `scale = baseW/2500` で比例させて、 + 表示名・アイコン(円形クリップ)・ステータス・レーダーチャートを合成する。 +3. **カード領域をOG比(1200:630)でクロップ**する。元画像は上下左右の暗い背景余白が + 広く、そのまま縮小するとカード=文字が小さくなるため、カード塗り(黒/青)の実測 + 外接矩形に小マージンを付けた矩形を切り出す。 +4. クロップ結果を**一度だけ**最終出力サイズ(1200×630)へ高品質縮小(CatmullRom)する。 + 縮小前にクロップすることで、再拡大による画質劣化を避ける。 +5. WebP(可逆VP8L)でエンコードして返す(`EncodeWebP`)。QC比較用に `EncodePNG` もある。 + +## 座標・定数のメンテナンス + +- レイアウト座標はすべて Node版のデザイン基準(2500×1313)で `ogpimage.go` の定数として保持。 +- クロップに使うカード外接矩形(`cardMinX/MaxX/MinY/MaxY`)は `base.png` から実測した値。 + **`base.png` を差し替えたら再計測して更新すること。** +- 出力サイズは `outputWidth`/`outputHeight`(1200×630)。`ogpRewrite` が注入する + `og:image:width`/`:height` と一致させること。 + +## QC(画質確認) + +`TestWriteQCArtifacts` は環境変数 `OGP_QC_OUT` が指すディレクトリにサンプルOGP +(PNG/WebP)を書き出す。ローカルでの生成例: + +```bash +cd app/functions-go +OGP_QC_OUT="$PWD/internal/ogpimage/testdata" go test ./internal/ogpimage/ -run TestWriteQCArtifacts -v +``` + +CI(`.github/workflows/dev-deploy.yml`)でも同テストを実行し、生成物を +`actions/upload-artifact`(名前 `ogp-sample`)としてアップロードして目視確認できる。 + +## 依存 + +- `github.com/fogleman/gg` — 2D描画。 +- `golang.org/x/image/draw` — 高品質リサイズ(CatmullRom)。 +- `golang.org/x/image/font/opentype` — フォント読込。 +- `github.com/HugoSmits86/nativewebp` — 純Goの可逆WebPエンコーダ(cgo不要)。 diff --git a/app/functions-go/internal/ogpimage/base.png b/app/functions-go/internal/ogpimage/base.png new file mode 100644 index 0000000..55a7ac8 Binary files /dev/null and b/app/functions-go/internal/ogpimage/base.png differ diff --git a/app/functions-go/internal/ogpimage/fonts/NotoSansJP-Regular.otf b/app/functions-go/internal/ogpimage/fonts/NotoSansJP-Regular.otf new file mode 100644 index 0000000..4af556a Binary files /dev/null and b/app/functions-go/internal/ogpimage/fonts/NotoSansJP-Regular.otf differ diff --git a/app/functions-go/internal/ogpimage/ogpimage.go b/app/functions-go/internal/ogpimage/ogpimage.go new file mode 100644 index 0000000..5f1bb87 --- /dev/null +++ b/app/functions-go/internal/ogpimage/ogpimage.go @@ -0,0 +1,335 @@ +// Package ogpimage はユーザーOGP画像(でばっぐのうりょくカード)を生成する。 +// +// Node版(app/functions/index.js の createOgp)は canvas + chartjs-node-canvas で +// ベース画像に「表示名・アイコン・ステータス・レーダーチャート」を合成していた。 +// 本パッケージはそれを外部プロセス依存なしのGoネイティブ実装として再現する。 +// +// 設計方針: +// - 座標はNode版のデザイン基準(2500x1313)をそのまま定数として保持し、 +// 実際の base 画像の幅から scale = baseW/2500 を求めて全座標を比例縮小する。 +// これによりベース画像の解像度を変えてもレイアウトを1箇所で追従できる。 +// - ベースPNG(2500x1313)とフォント(Noto Sans JP)はバイナリに go:embed で同梱する。 +// Node版のように実行時にGCSからbase.pngをダウンロードする往復が不要になり +// 高速化する。 +// - レンダリングは base の元解像度(2500x1313)で行い、合成後に「カード領域」を +// OG比(outputWidth:outputHeight)でクロップしてから最終出力サイズへ一度だけ +// 高品質縮小する。元画像には上下左右に暗い背景の余白が広く、そのまま縮小すると +// カード=文字が小さくなってしまうため、余白を除去して相対的に大きく見せる +// (元解像度でレンダリング→クロップ→縮小、の順にすることで再拡大による画質劣化を避ける)。 +// - 出力はWebP(可逆VP8L)。フラットなカード画像はPNGより大幅に小さくなる。 +package ogpimage + +import ( + "bytes" + _ "embed" + "fmt" + "image" + "image/color" + _ "image/jpeg" // アバターJPEGのデコード用 + "image/png" + "sync" + + "github.com/HugoSmits86/nativewebp" + "github.com/fogleman/gg" + xdraw "golang.org/x/image/draw" + "golang.org/x/image/font" + "golang.org/x/image/font/opentype" +) + +//go:embed base.png +var basePNGData []byte + +//go:embed fonts/NotoSansJP-Regular.otf +var notoSansJPData []byte + +// デザイン基準(Node版 index.js の座標系。base.png=2500x1313 を前提にしていた)。 +const ( + designWidth = 2500.0 + + nameX = 700.0 + nameY = 310.0 + nameMaxRight = 1280.0 // fillText の maxWidth 引数(userPos.max) + baseFontSize = 60.0 // fontStyle: 60px + lineHeight = 100.0 // fontStyle.lineHeight + + avatarX = 680.0 + avatarY = 431.0 + avatarSize = 215.0 + + // ステータス3行。左パネル(青)のステータス行の右端は実測で design x≈1292、左端は624。 + // せんとうりょく等が大きい値(例: 99999)でも右端をはみ出さないよう、開始位置を + // 左へ寄せ(680→640)、さらに上限幅ガードを設ける。 + // 「せんとうりょく:99999」=645px は縮小なしで収まり、6桁など上限を超える値のみ + // canvasのfillText(maxWidth)と同様に横方向へ縮小する。 + statsX = 640.0 + statsMaxWidth = 650.0 // 640 + 650 = 1290 ≦ パネル右端1292 + statsY = 740.0 + + // チャート(chartPost + chartWidth/Height=550)。中心はボックス中央。 + chartCenterX = 1325.0 + 550.0/2.0 // 1600 + chartCenterY = 300.0 + 550.0/2.0 // 575 + // レーダー半径とラベル距離はChart.jsの見た目に合わせて調整した値。 + // (Chart.jsはpointLabelsの領域を差し引いて描画半径を決めるため、 + // 550pxボックス内にラベルが収まるよう半径を抑えている) + chartRadius = 185.0 + chartLabelDist = 225.0 + labelFontSize = 25.0 + + gridLineWidth = 3.0 + dataStrokeW = 2.0 + // グリッド(同心リング)の分割数。Chart.js の stepSize=10 相当は15本だが、 + // 蜘蛛の巣状で見づらいため約半分の8本に減らしている。 + radarGridSteps = 8 + + // 最終出力サイズ(OGP標準)。クロップ後にこのサイズへ一度だけ縮小する。 + outputWidth = 1200 + outputHeight = 630 + + // カード(塗り=黒/青)の外接矩形。base.png(2500x1313)から実測した値 + // (black|blue 画素の bbox)。base.png を差し替えたら再計測して更新すること。 + cardMinX = 594.0 + cardMaxX = 1905.0 + cardMinY = 228.0 + cardMaxY = 1084.0 + // カードの丸角/枠を切り落とさないための余白(デザイン基準px)。 + cardMargin = 20.0 +) + +// Params は1枚のOGP画像を生成するための入力。 +type Params struct { + DisplayName string + Avatar image.Image // 円形クリップして描画。nilなら描画しない。 + Level int + Points int + Total int + // レーダー各軸の値(0..150)。 + HP int + Power int + Intelligence int + Defence int + Agility int +} + +var ( + baseOnce sync.Once + baseImg image.Image + baseErr error + fontOnce sync.Once + fontSFNT *opentype.Font + fontErr error + faceCache sync.Map // float64(size) -> font.Face +) + +func loadBase() (image.Image, error) { + baseOnce.Do(func() { + baseImg, baseErr = png.Decode(bytes.NewReader(basePNGData)) + }) + return baseImg, baseErr +} + +func loadFont() (*opentype.Font, error) { + fontOnce.Do(func() { + fontSFNT, fontErr = opentype.Parse(notoSansJPData) + }) + return fontSFNT, fontErr +} + +// newFace はサイズ(px)ごとに font.Face を生成してキャッシュする。DPI=72なので +// opentype.FaceOptions.Size(pt) はそのままpxとして扱える。 +func newFace(sizePx float64) (font.Face, error) { + if f, ok := faceCache.Load(sizePx); ok { + return f.(font.Face), nil + } + sfnt, err := loadFont() + if err != nil { + return nil, err + } + face, err := opentype.NewFace(sfnt, &opentype.FaceOptions{ + Size: sizePx, + DPI: 72, + Hinting: font.HintingFull, + }) + if err != nil { + return nil, err + } + faceCache.Store(sizePx, face) + return face, nil +} + +func rgb(r, g, b uint8) color.Color { return color.RGBA{R: r, G: g, B: b, A: 255} } + +// Render はカード画像を合成して image.Image を返す。 +func Render(p Params) (image.Image, error) { + base, err := loadBase() + if err != nil { + return nil, fmt.Errorf("load base: %w", err) + } + dc := gg.NewContextForImage(base) + scale := float64(dc.Width()) / designWidth + + nameFace, err := newFace(baseFontSize * scale) + if err != nil { + return nil, fmt.Errorf("name face: %w", err) + } + labelFace, err := newFace(labelFontSize * scale) + if err != nil { + return nil, fmt.Errorf("label face: %w", err) + } + + dc.SetColor(rgb(255, 255, 255)) + + // 表示名(canvasのmaxWidth相当: はみ出す場合は横方向に縮小) + drawTextTopScaled(dc, nameFace, p.DisplayName, nameX*scale, nameY*scale, (nameMaxRight-nameX)*scale) + + // アイコン(円形クリップ) + if p.Avatar != nil { + drawCircularAvatar(dc, p.Avatar, avatarX*scale, avatarY*scale, avatarSize*scale) + } + + // ステータス(maxWidthなし) + dc.SetColor(rgb(255, 255, 255)) + stats := []string{ + fmt.Sprintf("れべる:%d", p.Level), + fmt.Sprintf("ポイント:%d", p.Points), + fmt.Sprintf("せんとうりょく:%d", p.Total), + } + for i, line := range stats { + drawTextTopScaled(dc, nameFace, line, statsX*scale, (statsY+lineHeight*float64(i))*scale, statsMaxWidth*scale) + } + + // レーダーチャート + drawRadar(dc, radarParams{ + cx: chartCenterX * scale, + cy: chartCenterY * scale, + radius: chartRadius * scale, + labelDist: chartLabelDist * scale, + values: [5]float64{float64(p.HP), float64(p.Power), float64(p.Intelligence), float64(p.Defence), float64(p.Agility)}, + maxValue: 150, + gridSteps: radarGridSteps, + labels: [5]string{"たいりょく", "ちから", "かしこさ", "しゅびりょく", "すばやさ"}, + gridWidth: gridLineWidth * scale, + dataStroke: dataStrokeW * scale, + labelFace: labelFace, + }) + + return cropCardAndResize(dc.Image(), scale), nil +} + +// cropCardAndResize は合成済み画像(base元解像度)から、カード領域をOG比で切り出し、 +// 最終出力サイズ(outputWidth×outputHeight)へ一度だけ高品質縮小する。 +func cropCardAndResize(src image.Image, scale float64) image.Image { + // デザイン基準でのクロップ矩形(OG比を保ちつつカード全体を含む最小矩形)。 + boxL := cardMinX - cardMargin + boxR := cardMaxX + cardMargin + boxT := cardMinY - cardMargin + boxB := cardMaxY + cardMargin + boxW := boxR - boxL + boxH := boxB - boxT + cx := (boxL + boxR) / 2 + cy := (boxT + boxB) / 2 + + ratio := float64(outputWidth) / float64(outputHeight) + cropH := boxH + if w := boxW / ratio; w > cropH { + cropH = w + } + cropW := cropH * ratio + + // 実ピクセルへ換算し、中心を合わせつつ画像内へクランプ。 + sw := float64(src.Bounds().Dx()) + sh := float64(src.Bounds().Dy()) + cwp := cropW * scale + chp := cropH * scale + if cwp > sw { + cwp = sw + } + if chp > sh { + chp = sh + } + x0 := cx*scale - cwp/2 + y0 := cy*scale - chp/2 + if x0 < 0 { + x0 = 0 + } + if y0 < 0 { + y0 = 0 + } + if x0+cwp > sw { + x0 = sw - cwp + } + if y0+chp > sh { + y0 = sh - chp + } + rect := image.Rect(int(x0+0.5), int(y0+0.5), int(x0+cwp+0.5), int(y0+chp+0.5)).Add(src.Bounds().Min) + + sub := image.Image(src) + if si, ok := src.(interface { + SubImage(r image.Rectangle) image.Image + }); ok { + sub = si.SubImage(rect) + } + + out := image.NewRGBA(image.Rect(0, 0, outputWidth, outputHeight)) + xdraw.CatmullRom.Scale(out, out.Bounds(), sub, sub.Bounds(), xdraw.Over, nil) + return out +} + +// drawTextTopScaled は「上揃え(textBaseline=top)」でテキストを描く。maxWidth>0 かつ +// テキスト幅がそれを超える場合は、canvasのfillText(maxWidth)と同様に横方向へ縮小する。 +func drawTextTopScaled(dc *gg.Context, face font.Face, text string, x, y, maxWidth float64) { + dc.SetFontFace(face) + ascent := float64(face.Metrics().Ascent) / 64.0 + w, _ := dc.MeasureString(text) + sx := 1.0 + if maxWidth > 0 && w > maxWidth { + sx = maxWidth / w + } + dc.Push() + dc.Translate(x, y+ascent) + if sx != 1.0 { + dc.Scale(sx, 1) + } + dc.DrawString(text, 0, 0) + dc.Pop() +} + +// drawCircularAvatar はアバターを size×size に縮小し、半径 size/2*0.9 の円でクリップして描く +// (Node版 userIconCanvas の ri = width/2*0.9 と同じ)。 +func drawCircularAvatar(dc *gg.Context, avatar image.Image, x, y, size float64) { + iSize := int(size + 0.5) + scaled := image.NewRGBA(image.Rect(0, 0, iSize, iSize)) + xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), avatar, avatar.Bounds(), xdraw.Over, nil) + + dc.Push() + dc.DrawCircle(x+size/2, y+size/2, size/2*0.9) + dc.Clip() + dc.DrawImage(scaled, int(x+0.5), int(y+0.5)) + dc.ResetClip() + dc.Pop() +} + +// EncodeWebP は Render 結果を可逆WebP(VP8L)としてエンコードする。 +func EncodeWebP(p Params) ([]byte, error) { + img, err := Render(p) + if err != nil { + return nil, err + } + var buf bytes.Buffer + if err := nativewebp.Encode(&buf, img, nil); err != nil { + return nil, fmt.Errorf("encode webp: %w", err) + } + return buf.Bytes(), nil +} + +// EncodePNG は Render 結果をPNGとしてエンコードする(主にQC/比較用)。 +func EncodePNG(p Params) ([]byte, error) { + img, err := Render(p) + if err != nil { + return nil, err + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, fmt.Errorf("encode png: %w", err) + } + return buf.Bytes(), nil +} diff --git a/app/functions-go/internal/ogpimage/ogpimage_test.go b/app/functions-go/internal/ogpimage/ogpimage_test.go new file mode 100644 index 0000000..abf04a5 --- /dev/null +++ b/app/functions-go/internal/ogpimage/ogpimage_test.go @@ -0,0 +1,108 @@ +package ogpimage + +import ( + "bytes" + "image" + "image/color" + "math" + "os" + "path/filepath" + "testing" +) + +// syntheticAvatar はテスト用の擬似アバター(放射状グラデーションの矩形)を作る。 +// 実アバターはGitHubから取得するため、ローカルQCでは合成画像で代用する。 +func syntheticAvatar(size int) image.Image { + img := image.NewRGBA(image.Rect(0, 0, size, size)) + cx, cy := float64(size)/2, float64(size)/2 + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + dx, dy := float64(x)-cx, float64(y)-cy + d := math.Hypot(dx, dy) / (float64(size) / 2) + r := uint8(80 + 120*d) + g := uint8(180 * (1 - d)) + b := uint8(200) + img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: 255}) + } + } + return img +} + +func sampleParams() Params { + return Params{ + DisplayName: "でばっぐ 太郎", + Avatar: syntheticAvatar(460), + Level: 42, + Points: 12345, + Total: 99999, + HP: 120, + Power: 85, + Intelligence: 140, + Defence: 60, + Agility: 95, + } +} + +func TestRenderOutputDimensions(t *testing.T) { + img, err := Render(sampleParams()) + if err != nil { + t.Fatalf("Render: %v", err) + } + want := image.Rect(0, 0, outputWidth, outputHeight) + if img.Bounds() != want { + t.Fatalf("rendered bounds = %v, want %v (OG standard, cropped+resized)", img.Bounds(), want) + } +} + +func TestEncodeWebPMagic(t *testing.T) { + data, err := EncodeWebP(sampleParams()) + if err != nil { + t.Fatalf("EncodeWebP: %v", err) + } + if len(data) < 12 { + t.Fatalf("webp too short: %d bytes", len(data)) + } + // RIFF ヘッダ + "WEBP" フォーマットタグ。 + if !bytes.Equal(data[0:4], []byte("RIFF")) || !bytes.Equal(data[8:12], []byte("WEBP")) { + t.Fatalf("not a webp: header=% x", data[0:12]) + } +} + +func TestAxisAngleAndPoint(t *testing.T) { + // 軸0は真上(画面座標で中心の真上=yが小さい方)。 + x, y := axisPoint(100, 100, 50, axisAngle(0)) + if math.Abs(x-100) > 1e-6 || math.Abs(y-50) > 1e-6 { + t.Errorf("axis0 point = (%.3f,%.3f), want (100,50)", x, y) + } + // 5軸が72度刻みで一巡する。 + if got := axisAngle(5) - axisAngle(0); math.Abs(got-2*math.Pi) > 1e-9 { + t.Errorf("full turn = %.6f, want 2π", got) + } +} + +// TestWriteQCArtifacts は OGP_QC_OUT が設定されているときだけ、目視確認用の +// サンプル画像(PNG/WebP)を testdata/ に書き出す(CIのアーティファクト化にも使う)。 +func TestWriteQCArtifacts(t *testing.T) { + outDir := os.Getenv("OGP_QC_OUT") + if outDir == "" { + t.Skip("OGP_QC_OUT not set; skipping artifact generation") + } + if err := os.MkdirAll(outDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + pngData, err := EncodePNG(sampleParams()) + if err != nil { + t.Fatalf("EncodePNG: %v", err) + } + webpData, err := EncodeWebP(sampleParams()) + if err != nil { + t.Fatalf("EncodeWebP: %v", err) + } + if err := os.WriteFile(filepath.Join(outDir, "sample_ogp.png"), pngData, 0o644); err != nil { + t.Fatalf("write png: %v", err) + } + if err := os.WriteFile(filepath.Join(outDir, "sample_ogp.webp"), webpData, 0o644); err != nil { + t.Fatalf("write webp: %v", err) + } + t.Logf("QC artifacts: png=%d bytes, webp=%d bytes -> %s", len(pngData), len(webpData), outDir) +} diff --git a/app/functions-go/internal/ogpimage/radar.go b/app/functions-go/internal/ogpimage/radar.go new file mode 100644 index 0000000..7f923cd --- /dev/null +++ b/app/functions-go/internal/ogpimage/radar.go @@ -0,0 +1,116 @@ +// レーダーチャート描画。Node版(userOGP)が chartjs-node-canvas で描いていた +// 5軸レーダーチャートを、外部プロセス(Chart.js)に依存せずGoネイティブで再現する。 +// +// Chart.js の radar 設定(app/functions/index.js の chartconfig)に対応: +// - 5軸(たいりょく/ちから/かしこさ/しゅびりょく/すばやさ) +// - min=0, max=150, グリッドは stepSize=10 ごと +// - グリッド/軸線/ラベル色 rgb(242,242,242) +// - データ塗り rgba(0,168,228,0.6) / 枠線 rgb(0,117,159) +// - 頂点マーカー非表示 +// +// 座標系はピクセル(出力解像度)で受け取り、解像度非依存にしている +// (呼び出し側 ogpimage.Render が base 実寸から係数を掛けて渡す)。 +package ogpimage + +import ( + "math" + + "github.com/fogleman/gg" + "golang.org/x/image/font" +) + +// radarParams はレーダーチャート1枚を描くためのピクセル指定。 +type radarParams struct { + cx, cy float64 // 中心座標(px) + radius float64 // max(=150)に対応する半径(px) + labelDist float64 // 中心からラベル中心までの距離(px) + values [5]float64 // 各軸の値(0..maxValue) + maxValue float64 // 満点(=150) + gridSteps int // 同心グリッドの分割数(Chart.js: max/stepSize = 150/10 = 15) + labels [5]string + gridWidth float64 // グリッド/軸線の線幅(px) + dataStroke float64 // データ枠線の線幅(px) + labelFace font.Face +} + +// axisAngle は軸 i の角度(ラジアン)を返す。頂点は真上(12時)始まりで時計回り +// (Chart.js radar と同じ並び)。画面座標はyが下向きなので真上は -90度。 +func axisAngle(i int) float64 { + return -math.Pi/2 + float64(i)*(2*math.Pi/5) +} + +// axisPoint は中心(cx,cy)から角度theta・距離rの点を返す。 +func axisPoint(cx, cy, r, theta float64) (float64, float64) { + return cx + r*math.Cos(theta), cy + r*math.Sin(theta) +} + +// drawRadar は dc に対してレーダーチャートを描画する。 +func drawRadar(dc *gg.Context, p radarParams) { + // --- 同心グリッド(多角形リング) --- + dc.SetRGB255(242, 242, 242) + dc.SetLineWidth(p.gridWidth) + for step := 1; step <= p.gridSteps; step++ { + r := p.radius * float64(step) / float64(p.gridSteps) + for i := 0; i < 5; i++ { + x, y := axisPoint(p.cx, p.cy, r, axisAngle(i)) + if i == 0 { + dc.MoveTo(x, y) + } else { + dc.LineTo(x, y) + } + } + dc.ClosePath() + dc.Stroke() + } + + // --- 軸線(中心から各頂点) --- + for i := 0; i < 5; i++ { + x, y := axisPoint(p.cx, p.cy, p.radius, axisAngle(i)) + dc.MoveTo(p.cx, p.cy) + dc.LineTo(x, y) + dc.Stroke() + } + + // --- データ多角形 --- + for i := 0; i < 5; i++ { + v := p.values[i] + if v < 0 { + v = 0 + } + if v > p.maxValue { + v = p.maxValue + } + r := p.radius * v / p.maxValue + x, y := axisPoint(p.cx, p.cy, r, axisAngle(i)) + if i == 0 { + dc.MoveTo(x, y) + } else { + dc.LineTo(x, y) + } + } + dc.ClosePath() + dc.SetRGBA255(0, 168, 228, 153) // rgba(0,168,228,0.6) + dc.SetLineWidth(p.dataStroke) + dc.SetStrokeStyle(gg.NewSolidPattern(rgb(0, 117, 159))) + dc.FillPreserve() + dc.Stroke() + + // --- ラベル --- + if p.labelFace != nil { + dc.SetFontFace(p.labelFace) + dc.SetRGB255(242, 242, 242) + for i := 0; i < 5; i++ { + theta := axisAngle(i) + x, y := axisPoint(p.cx, p.cy, p.labelDist, theta) + // 左方向へ伸びる軸(すばやさ/しゅびりょく)のラベルはやや右寄せにして、 + // テキストが中心(レーダー)側に被らないよう外側(左)へ少しだけ逃がす。 + // 完全な右寄せ(1.0)だと左の青パネルに重なるため、控えめの0.7にする。 + // 右側/上下は従来どおり中央寄せ(右寄せにするとカード外へはみ出すため)。 + ax := 0.5 + if math.Cos(theta) < -0.3 { + ax = 0.7 + } + dc.DrawStringAnchored(p.labels[i], x, y, ax, 0.5) + } + } +} diff --git a/app/functions-go/internal/ogpimage/testdata/sample_ogp.png b/app/functions-go/internal/ogpimage/testdata/sample_ogp.png new file mode 100644 index 0000000..a044752 Binary files /dev/null and b/app/functions-go/internal/ogpimage/testdata/sample_ogp.png differ diff --git a/app/functions-go/internal/ogpimage/testdata/sample_ogp.webp b/app/functions-go/internal/ogpimage/testdata/sample_ogp.webp new file mode 100644 index 0000000..3ac68f5 Binary files /dev/null and b/app/functions-go/internal/ogpimage/testdata/sample_ogp.webp differ diff --git a/app/functions-go/internal/performance/README.md b/app/functions-go/internal/performance/README.md new file mode 100644 index 0000000..4cd1511 --- /dev/null +++ b/app/functions-go/internal/performance/README.md @@ -0,0 +1,57 @@ +# internal/performance + +参拝の能力解析(パフォーマンス計算)に関する純粋ロジックのGoポート。 + +`app/functions/performance.js` (Node版)のうち、`status`/`sanpai` エンドポイント +(Go版)が必要とする範囲を移植している: + +- `GetLevel` / `GetNextLevelExp` (Node版 `get_level` / `get_next_leve_exp`) +- `UserPerformance` (Node版 `user_performance`) +- `UserFormattedPerformance` (Node版 `user_formatted_performance`) +- `RawUserDataFromStatus` (Node版 `raw_user_data_from_status`) +- `ComputePerformanceIncrement` (Node版 `compute_performance_increment`、増分計算) +- `LatestActivityCreatedAt` (Node版 `latest_activity_created_at`) + +## Node版との既知の意図的な単純化 + +- `UserFormattedPerformance` の `User` は常に `UserInfo` 構造体を要求する + (Node版は `append_data.user` 未指定時に `user_data.user`(文字列)へ + フォールバックするが、現在の全呼び出し箇所(`status`/`sanpai`/ + `statusCacheBackfill`)で `append_data.user` は必ず設定されているため、 + この単純化は挙動を変えない)。 + +## 解析ロジックのバージョン (`StatusLogicVersion`) + +`StatusLogicVersion`(Node版 `STATUS_LOGIC_VERSION` と必ず一致)は、この計算ロジックの +バージョンを表す。**計算式(加点テーブルや判定条件など、同一アクティビティに対する +算出結果)を変えたら必ずインクリメントする。** 呼び出し側(`status`/`sanpai`/ +`statusCacheBackfill`)はこの値をユーザードキュメントの `status_version` に刻み、 +古いバージョンで計算されたキャッシュを再計算して自己修復する(詳細は +`docs/backend.md`「解析ロジックのバージョン管理と自己修復キャッシュ」を参照)。 + +## Node版と同一に保つべき点(変更する場合は両実装を同時に更新すること) + +- `StatusLogicVersion` / `STATUS_LOGIC_VERSION` の値(計算式変更時は両方インクリメント) +- `targetPoints` の値 +- イベント種別ごとの加点テーブル(`UserPerformance` の switch) +- `IssuesEvent` の加点は `payload.action`("opened"→intelligence+3 / + "closed"→defence+5)で判定する。GitHub Events API の payload はオブジェクトで + action フィールドに開閉種別が入るため(公式ドキュメント/実データで確認済み)。 + ※移植前のNode版は `payload`(オブジェクト)を文字列 "opened"/"closed" と + 直接比較しており、GitHub実データのオブジェクトpayloadとは決して一致しない + ため Issue のオープン/クローズが一切加点されないバグがあった。移植に合わせて + 両実装で修正済み。 +- agility/hp の時間差バケット境界値 + +## テスト + +`performance_test.go` は `app/functions/test/performance.test.js` のうち +上記の移植範囲に対応するケースを同一の入出力で移植したもの。Node版のテストを +変更する場合は、対応するGo側のテストも同時に更新すること。 + +増分計算(`ComputePerformanceIncrement`)については、ランダム生成したアクティビティ +列に対して「全件を一度に `UserPerformance` で計算した結果」と「バッチに分けて +`ComputePerformanceIncrement` を繰り返し適用した結果」が完全一致することを +プロパティテスト(`TestIncrementEqualsFullCalculation_TwoBatches`:2000ケース、 +`TestIncrementEqualsFullCalculation_ThreeBatchesSequential`:1000ケース)で +検証している。これは `performance.test.js` の同名テストと同一のロジック。 diff --git a/app/functions-go/internal/performance/performance.go b/app/functions-go/internal/performance/performance.go new file mode 100644 index 0000000..9698e4e --- /dev/null +++ b/app/functions-go/internal/performance/performance.go @@ -0,0 +1,370 @@ +// Package performance は参拝の能力解析(パフォーマンス計算)に関する純粋ロジックを提供する。 +// app/functions/performance.js (Node版)と同一の計算結果を返すことを目的としたポートであり、 +// 副作用(Firestore/HTTP)を持たずユニットテスト可能な単位として切り出している。 +// +// 値を変更する場合は必ず Node版(app/functions/performance.js)と同時に更新し、 +// 両実装のテスト(performance_test.go / performance.test.js)で等価性を確認すること。 +package performance + +import ( + "log" + "sort" + "time" +) + +// StatusLogicVersion は能力解析(performance)の計算ロジックのバージョン。 +// 計算式(加点テーブルや判定条件など、同一アクティビティに対する算出結果)を変えたら +// 必ずインクリメントすること。users/{id}.status_version に保存され、キャッシュ済み +// status がこのバージョン未満なら再計算対象になる(status/sanpai/statusCacheBackfill)。 +// +// 履歴: +// +// 1: IssuesEvent を payload.action で加点するよう修正(それ以前は常に未加点だった)。 +// 未設定(フィールドが存在しない旧キャッシュ)は 0 として扱われ再計算される。 +// +// Node版 (app/functions/performance.js) の STATUS_LOGIC_VERSION と必ず一致させること。 +const StatusLogicVersion int64 = 1 + +// レベルアップに必要な累計ポイントの閾値テーブル。Node版 target_points と同一の値。 +var targetPoints = []int{ + 0, 5, 11, 19, 30, 45, 65, 91, 124, 166, 218, 281, 357, 447, 553, 676, 818, 981, + 1167, 1378, 1616, 1884, 2184, 2519, 2892, 3306, 3764, 4269, 4825, 5436, 6106, + 6840, 7643, 8520, 9477, 10520, 11656, 12892, 14236, 15696, 17281, 19001, 20867, + 22891, 25086, 27466, 30046, 32842, 35872, 39156, +} + +// GetLevel は累計ポイントからレベルを算出する(Node版 get_level と同一)。 +func GetLevel(points int) int { + level := 0 + for i, t := range targetPoints { + if points <= t { + level = i + 1 + break + } + } + return level +} + +// NextLevelExp は次レベルとその必要経験値。 +type NextLevelExp struct { + NextLevel int + NextExp int +} + +// GetNextLevelExp は次レベルに必要な経験値を返す(Node版 get_next_leve_exp と同一)。 +// 最大レベルを超える場合(target_points の範囲外)は NextExp=0 とする +// (Node版は該当時 undefined になり JSON からフィールドが消えるが、実運用では到達しない +// 極端な境界のため、Goでは panic を避けて 0 を返す)。 +func GetNextLevelExp(points int) NextLevelExp { + level := GetLevel(points) + nextExp := 0 + if level >= 0 && level < len(targetPoints) { + nextExp = targetPoints[level] + } + return NextLevelExp{NextLevel: level + 1, NextExp: nextExp} +} + +// Activity はGitHub Events APIの1イベント(Firestoreにキャッシュされた raw JSON)を表す。 +// Payload は IssuesEvent の開閉種別など任意のオブジェクトであり得るため any として保持し、 +// payloadAction で payload.action(GitHub Events APIの action フィールド)を取り出す。 +type Activity struct { + Type string `json:"type"` + CreatedAt string `json:"created_at"` + Payload any `json:"payload"` +} + +// payloadAction は payload オブジェクトから action フィールド("opened"/"closed"等)を取り出す。 +// GitHub Events API の IssuesEvent 等の payload は JSON オブジェクトで、開閉種別は +// payload.action に入る。payload がオブジェクトでない/action が無い場合は空文字を返す。 +func payloadAction(payload any) string { + m, ok := payload.(map[string]any) + if !ok { + return "" + } + action, _ := m["action"].(string) + return action +} + +func parseCreatedAt(createdAt string) time.Time { + t, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + // created_at はGitHub Events APIが常にRFC3339で返すため通常発生しない。 + // パース不能な入力はゼロ値(0001-01-01)として扱い、以降の差分計算を破綻させない。 + return time.Time{} + } + return t +} + +// RawUserData は集計途中の生ステータス(Node版 user_performance の戻り値相当)。 +type RawUserData struct { + User string + HP int + Power int + Defence int + Dex int + Agility int + Intelligence int +} + +// UserPerformance はアクティビティ一覧から生ステータスを集計する(Node版 user_performance と同一)。 +func UserPerformance(items []Activity, username string) RawUserData { + data := RawUserData{User: username} + + sorted := make([]Activity, len(items)) + copy(sorted, items) + sort.SliceStable(sorted, func(i, j int) bool { + return parseCreatedAt(sorted[i].CreatedAt).Before(parseCreatedAt(sorted[j].CreatedAt)) + }) + + var previous *Activity + continuousCount := 0 + + for i := range sorted { + item := &sorted[i] + if previous != nil { + diff := parseCreatedAt(item.CreatedAt).Sub(parseCreatedAt(previous.CreatedAt)).Seconds() + + switch { + case diff > 30 && diff <= 120: + data.Agility += 6 + case diff <= 180: + data.Agility += 3 + case diff <= 300: + data.Agility += 2 + case diff <= 1200: + data.Agility += 1 + } + if diff <= 7200 { + continuousCount++ + } else { + data.HP += continuousCount * 2 + continuousCount = 0 + } + } + + switch item.Type { + case "ForkEvent": + data.Power += 1 + case "PushEvent": + data.Power += 2 + case "CreateEvent", "DeleteEvent": + data.Power += 1 + case "PullRequestEvent": + data.Power += 3 + case "IssuesEvent": + switch payloadAction(item.Payload) { + case "opened": + data.Intelligence += 3 + case "closed": + data.Defence += 5 + } + case "IssueCommentEvent": + data.Intelligence += 2 + case "PullRequestReviewEvent": + data.Defence += 3 + case "PullRequestReviewCommentEvent": + data.Defence += 3 + case "GollumEvent": + data.Defence += 3 + case "ReleaseEvent": + data.Defence += 10 + } + previous = item + } + if continuousCount > 0 { + data.HP += continuousCount * 2 + } + return data +} + +// UserInfo は表示用のユーザー情報(append_data.user 相当)。 +type UserInfo struct { + DisplayName string `json:"display_name" firestore:"display_name"` + ScreenName string `json:"screen_name" firestore:"screen_name"` + GithubImagePath string `json:"github_image_path" firestore:"github_image_path"` +} + +// Chart はレーダーチャート表示用の内訳(Node版 chart と同一)。 +type Chart struct { + HP int `json:"hp" firestore:"hp"` + Power int `json:"power" firestore:"power"` + Intelligence int `json:"intelligence" firestore:"intelligence"` + Defence int `json:"defence" firestore:"defence"` + Agility int `json:"agility" firestore:"agility"` +} + +// FormattedPerformance は user_formatted_performance の戻り値相当。 +// +// Node版は append_data.user / append_data.exp が未指定の場合のフォールバック +// (userのみの文字列表示、pointsを0のまま等)を持つ汎用実装だが、現時点の呼び出し元 +// (status エンドポイント)は必ず User/Exp を明示的に与えるため、Goでは単純化して +// User を常に必須の構造体としている(既存の全呼び出し箇所で append_data.user は +// 常に設定されているため、この単純化は挙動を変えない)。 +type FormattedPerformance struct { + User UserInfo `json:"user"` + Points int `json:"points"` + HP int `json:"hp"` + Power int `json:"power"` + Intelligence int `json:"intelligence"` + Defence int `json:"defence"` + Agility int `json:"agility"` + Total int `json:"total"` + Level int `json:"level"` + Exp int `json:"exp"` + NextExp int `json:"next_exp"` + Chart Chart `json:"chart"` +} + +// AppendData は user_formatted_performance の第2引数(append_data)相当。 +type AppendData struct { + Exp int + User UserInfo +} + +// RawUserDataFromStatus は保存済み status から user_performance 相当の生ステータスを +// 復元する(Node版 raw_user_data_from_status と同一)。 +func RawUserDataFromStatus(status FormattedPerformance, username string) RawUserData { + return RawUserData{ + User: username, + HP: status.HP, + Power: status.Power, + Defence: status.Defence, + Dex: 0, + Agility: status.Agility, + Intelligence: status.Intelligence, + } +} + +// LatestActivityCreatedAt はアクティビティ群の中で最も新しい created_at を返す +// (無ければ空文字。Node版 latest_activity_created_at は null を返すが、Goでは +// zero valueとして空文字を使う)。 +func LatestActivityCreatedAt(items []Activity) string { + latest := "" + for _, item := range items { + if latest == "" || parseCreatedAt(item.CreatedAt).After(parseCreatedAt(latest)) { + latest = item.CreatedAt + } + } + return latest +} + +// IncrementResult は ComputePerformanceIncrement の戻り値。 +type IncrementResult struct { + UserData RawUserData + LastCreatedAt string +} + +// ComputePerformanceIncrement は累積ステータス(baseUserData)に新着アクティビティ分だけを +// 加算する増分計算(Node版 compute_performance_increment と同一)。 +// +// user_performance の per-event / per-pair の寄与は加算的で、バッチ境界 +// (previousCreatedAt と新着先頭の時間差)だけがクロスバッチ依存となるため、 +// 全件を再集計せずとも全件計算と同一の結果が得られる。 +// +// 前提となる不変条件(全件計算と一致するのは以下が成り立つ場合のみ): +// 1. newItems は baseUserData に未集計のイベントだけで構成される(二重計上しない)。 +// 2. newItems の全イベントが previousCreatedAt(累積済みイベントの最大時刻)より後である。 +// 3. previousCreatedAt は累積済みイベントの最大 created_at である。 +// +// 呼び出し側(sanpai)は「created_at > last_sanpai」で newItems を抽出し、 +// previousCreatedAt に保存済み最大時刻(last_activity_created_at)を渡すことでこれを満たす。 +func ComputePerformanceIncrement(baseUserData RawUserData, newItems []Activity, previousCreatedAt string) IncrementResult { + data := RawUserData{ + User: baseUserData.User, + HP: baseUserData.HP, + Power: baseUserData.Power, + Defence: baseUserData.Defence, + Dex: baseUserData.Dex, + Agility: baseUserData.Agility, + Intelligence: baseUserData.Intelligence, + } + + sorted := make([]Activity, len(newItems)) + copy(sorted, newItems) + sort.SliceStable(sorted, func(i, j int) bool { + return parseCreatedAt(sorted[i].CreatedAt).Before(parseCreatedAt(sorted[j].CreatedAt)) + }) + + // 不変条件2の検知: 新着の最古イベントが境界より前なら前提が崩れている。 + if previousCreatedAt != "" && len(sorted) > 0 && + parseCreatedAt(sorted[0].CreatedAt).Before(parseCreatedAt(previousCreatedAt)) { + log.Printf("[performance] 増分計算の前提違反: 新着の最古イベント(%s)が境界(%s)より前です。全件計算と不一致になり得ます。", sorted[0].CreatedAt, previousCreatedAt) + } + + prevCreatedAt := previousCreatedAt + for i := range sorted { + item := &sorted[i] + if prevCreatedAt != "" { + diff := parseCreatedAt(item.CreatedAt).Sub(parseCreatedAt(prevCreatedAt)).Seconds() + + switch { + case diff > 30 && diff <= 120: + data.Agility += 6 + case diff <= 180: + data.Agility += 3 + case diff <= 300: + data.Agility += 2 + case diff <= 1200: + data.Agility += 1 + } + if diff <= 7200 { + data.HP += 2 + } + } + + switch item.Type { + case "ForkEvent": + data.Power += 1 + case "PushEvent": + data.Power += 2 + case "CreateEvent", "DeleteEvent": + data.Power += 1 + case "PullRequestEvent": + data.Power += 3 + case "IssuesEvent": + switch payloadAction(item.Payload) { + case "opened": + data.Intelligence += 3 + case "closed": + data.Defence += 5 + } + case "IssueCommentEvent": + data.Intelligence += 2 + case "PullRequestReviewEvent": + data.Defence += 3 + case "PullRequestReviewCommentEvent": + data.Defence += 3 + case "GollumEvent": + data.Defence += 3 + case "ReleaseEvent": + data.Defence += 10 + } + prevCreatedAt = item.CreatedAt + } + return IncrementResult{UserData: data, LastCreatedAt: prevCreatedAt} +} + +// UserFormattedPerformance は生ステータスを表示用に整形する(Node版 user_formatted_performance と同一)。 +func UserFormattedPerformance(data RawUserData, append AppendData) FormattedPerformance { + total := data.HP + data.Power + data.Intelligence + data.Defence + data.Agility + return FormattedPerformance{ + User: append.User, + Points: append.Exp, + HP: data.HP, + Power: data.Power, + Intelligence: data.Intelligence, + Defence: data.Defence, + Agility: data.Agility, + Total: total, + Level: GetLevel(total), + Exp: append.Exp, + NextExp: GetNextLevelExp(total).NextExp, + Chart: Chart{ + HP: data.HP, + Power: data.Power, + Intelligence: data.Intelligence, + Defence: data.Defence, + Agility: data.Agility, + }, + } +} diff --git a/app/functions-go/internal/performance/performance_test.go b/app/functions-go/internal/performance/performance_test.go new file mode 100644 index 0000000..8e81952 --- /dev/null +++ b/app/functions-go/internal/performance/performance_test.go @@ -0,0 +1,322 @@ +// performance.go のテスト。app/functions/test/performance.test.js のうち、 +// status エンドポイントで使用する範囲(get_level/get_next_leve_exp/user_performance/ +// user_formatted_performance)を同一の入出力で移植し、Node版との等価性を保証する。 +package performance + +import ( + "math/rand" + "reflect" + "sort" + "testing" + "time" +) + +var seq int + +func iso(unixSec int64) string { + return time.Unix(unixSec, 0).UTC().Format("2006-01-02T15:04:05") + "Z" +} + +func item(eventType string, unixSec int64, payload any) Activity { + seq++ + return Activity{Type: eventType, CreatedAt: iso(unixSec), Payload: payload} +} + +func TestGetLevel_Boundary(t *testing.T) { + cases := []struct { + points, want int + }{ + {0, 1}, // target_points[0]=0 + {5, 2}, // target_points[1]=5 + {6, 3}, // 6<=11 + {11, 3}, + {12, 4}, // 12<=19 + } + for _, c := range cases { + if got := GetLevel(c.points); got != c.want { + t.Errorf("GetLevel(%d) = %d, want %d", c.points, got, c.want) + } + } +} + +func TestGetNextLevelExp(t *testing.T) { + r := GetNextLevelExp(0) // level=1 -> target_points[1]=5 + if r.NextLevel != 2 { + t.Errorf("NextLevel = %d, want 2", r.NextLevel) + } + if r.NextExp != 5 { + t.Errorf("NextExp = %d, want 5", r.NextExp) + } +} + +func TestUserPerformance_EventTypePoints(t *testing.T) { + cases := []struct { + eventType string + field string + want int + }{ + {"ForkEvent", "power", 1}, + {"PushEvent", "power", 2}, + {"CreateEvent", "power", 1}, + {"DeleteEvent", "power", 1}, + {"PullRequestEvent", "power", 3}, + {"IssueCommentEvent", "intelligence", 2}, + {"PullRequestReviewEvent", "defence", 3}, + {"PullRequestReviewCommentEvent", "defence", 3}, + {"GollumEvent", "defence", 3}, + {"ReleaseEvent", "defence", 10}, + } + for _, c := range cases { + r := UserPerformance([]Activity{item(c.eventType, 1000, nil)}, "u") + var got int + switch c.field { + case "power": + got = r.Power + case "intelligence": + got = r.Intelligence + case "defence": + got = r.Defence + } + if got != c.want { + t.Errorf("%s.%s = %d, want %d", c.eventType, c.field, got, c.want) + } + } +} + +func TestUserPerformance_UnsupportedEvent(t *testing.T) { + r := UserPerformance([]Activity{item("WatchEvent", 1000, nil)}, "u") + sum := r.Power + r.Defence + r.Intelligence + r.Agility + r.HP + if sum != 0 { + t.Errorf("unsupported event sum = %d, want 0", sum) + } +} + +func TestUserPerformance_IssuesEventPayload(t *testing.T) { + // GitHub Events API の payload は {action:"opened"} 等のオブジェクトなので、 + // payload.action を見て加点する(opened -> intelligence+3, closed -> defence+5)。 + if r := UserPerformance([]Activity{item("IssuesEvent", 1000, map[string]any{"action": "opened"})}, "u"); r.Intelligence != 3 { + t.Errorf("opened intelligence = %d, want 3", r.Intelligence) + } + if r := UserPerformance([]Activity{item("IssuesEvent", 1000, map[string]any{"action": "closed"})}, "u"); r.Defence != 5 { + t.Errorf("closed defence = %d, want 5", r.Defence) + } + // action が opened/closed 以外(reopened等)は加点されない。 + if r := UserPerformance([]Activity{item("IssuesEvent", 1000, map[string]any{"action": "reopened"})}, "u"); r.Intelligence != 0 || r.Defence != 0 { + t.Errorf("reopened intelligence=%d defence=%d, want 0,0", r.Intelligence, r.Defence) + } + // payload がオブジェクトでない(文字列/nil)場合は action を取れず加点されない。 + if r := UserPerformance([]Activity{item("IssuesEvent", 1000, "opened")}, "u"); r.Intelligence != 0 || r.Defence != 0 { + t.Errorf("string payload intelligence=%d defence=%d, want 0,0", r.Intelligence, r.Defence) + } +} + +func twoPush(diffSec int64) RawUserData { + return UserPerformance([]Activity{item("PushEvent", 1000, nil), item("PushEvent", 1000+diffSec, nil)}, "u") +} + +func TestUserPerformance_AgilityByDiff(t *testing.T) { + cases := []struct { + diff int64 + want int + }{ + {60, 6}, // 30 2ペア -> hp 4 + three := UserPerformance([]Activity{ + item("PushEvent", 1000, nil), item("PushEvent", 2000, nil), item("PushEvent", 3000, nil), + }, "u") + if three.HP != 4 { + t.Errorf("three.HP = %d, want 4", three.HP) + } +} + +func TestRawUserDataFromStatus(t *testing.T) { + status := FormattedPerformance{HP: 1, Power: 2, Defence: 3, Agility: 4, Intelligence: 5} + got := RawUserDataFromStatus(status, "u") + want := RawUserData{User: "u", HP: 1, Power: 2, Defence: 3, Dex: 0, Agility: 4, Intelligence: 5} + if got != want { + t.Errorf("RawUserDataFromStatus = %+v, want %+v", got, want) + } +} + +func TestLatestActivityCreatedAt(t *testing.T) { + if got := LatestActivityCreatedAt(nil); got != "" { + t.Errorf("LatestActivityCreatedAt(nil) = %q, want empty", got) + } + items := []Activity{item("PushEvent", 3000, nil), item("PushEvent", 1000, nil), item("PushEvent", 5000, nil)} + want := iso(5000) + if got := LatestActivityCreatedAt(items); got != want { + t.Errorf("LatestActivityCreatedAt = %q, want %q", got, want) + } +} + +func TestComputePerformanceIncrement_InvariantViolationDoesNotPanic(t *testing.T) { + base := RawUserData{User: "u"} + // 境界より前のcreated_atを渡しても(警告ログのみで)パニックしないことを確認する。 + _ = ComputePerformanceIncrement(base, []Activity{item("PushEvent", 1000, nil)}, iso(5000)) + _ = ComputePerformanceIncrement(base, []Activity{item("PushEvent", 9000, nil)}, iso(5000)) +} + +// ============================================================ +// 増分計算の等価性(プロパティテスト) +// performance.test.js の同名テストと同一のロジックをGoで移植。 +// ============================================================ + +var eventTypes = []string{ + "ForkEvent", "PushEvent", "CreateEvent", "DeleteEvent", "PullRequestEvent", + "IssuesEvent", "IssueCommentEvent", "PullRequestReviewEvent", + "PullRequestReviewCommentEvent", "GollumEvent", "ReleaseEvent", "WatchEvent", +} + +var payloadCandidates = []any{ + map[string]any{"action": "opened"}, map[string]any{"action": "closed"}, "opened", "closed", nil, +} + +func genItems(rng *rand.Rand, count int, startUnix int64) []Activity { + t := startUnix + items := make([]Activity, 0, count) + for i := 0; i < count; i++ { + t += rng.Int63n(10000) // 7200秒境界を跨ぐようばらつかせる + items = append(items, item(eventTypes[rng.Intn(len(eventTypes))], t, payloadCandidates[rng.Intn(len(payloadCandidates))])) + } + return items +} + +func sortByCreatedAt(items []Activity) { + sort.SliceStable(items, func(i, j int) bool { + return parseCreatedAt(items[i].CreatedAt).Before(parseCreatedAt(items[j].CreatedAt)) + }) +} + +// pickedFields は比較対象のフィールドのみ抽出する(performance.test.js の pick と同じ意図)。 +type pickedFields struct { + HP, Power, Intelligence, Defence, Agility, Total, Level, NextExp, Points, Exp int +} + +func pick(f FormattedPerformance) pickedFields { + return pickedFields{f.HP, f.Power, f.Intelligence, f.Defence, f.Agility, f.Total, f.Level, f.NextExp, f.Points, f.Exp} +} + +var appendForTest = AppendData{Exp: 42, User: UserInfo{DisplayName: "d", ScreenName: "s"}} + +func TestIncrementEqualsFullCalculation_TwoBatches(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + for c := 0; c < 2000; c++ { + all := genItems(rng, 1+rng.Intn(40), int64(1577836800)+rng.Int63n(1000000)) // 2020-01-01T00:00:00Z + sortByCreatedAt(all) + k := rng.Intn(len(all) + 1) + oldItems := all[:k] + newItems := all[k:] + if len(newItems) == 0 { + continue + } + + full := UserFormattedPerformance(UserPerformance(all, ""), appendForTest) + + var incFmt FormattedPerformance + if len(oldItems) > 0 { + baseStatus := UserFormattedPerformance(UserPerformance(oldItems, ""), AppendData{}) + inc := ComputePerformanceIncrement(RawUserDataFromStatus(baseStatus, "s"), newItems, LatestActivityCreatedAt(oldItems)) + incFmt = UserFormattedPerformance(inc.UserData, appendForTest) + } else { + incFmt = UserFormattedPerformance(UserPerformance(newItems, ""), appendForTest) + } + if !reflect.DeepEqual(pick(incFmt), pick(full)) { + t.Fatalf("case %d: increment=%+v full=%+v", c, pick(incFmt), pick(full)) + } + } +} + +func applyIncrement(rng *rand.Rand, prevStatus *FormattedPerformance, prevTs string, batch []Activity) (FormattedPerformance, string) { + if prevStatus != nil { + inc := ComputePerformanceIncrement(RawUserDataFromStatus(*prevStatus, "s"), batch, prevTs) + return UserFormattedPerformance(inc.UserData, AppendData{}), inc.LastCreatedAt + } + return UserFormattedPerformance(UserPerformance(batch, ""), AppendData{}), LatestActivityCreatedAt(batch) +} + +func TestIncrementEqualsFullCalculation_ThreeBatchesSequential(t *testing.T) { + rng := rand.New(rand.NewSource(2)) + for c := 0; c < 1000; c++ { + all := genItems(rng, 3+rng.Intn(40), int64(1577836800)+rng.Int63n(1000000)) + sortByCreatedAt(all) + p1 := rng.Intn(len(all) + 1) + p2 := p1 + rng.Intn(len(all)-p1+1) + b1, b2, b3 := all[:p1], all[p1:p2], all[p2:] + if len(b3) == 0 { + continue + } + + full := UserFormattedPerformance(UserPerformance(all, ""), appendForTest) + + var s *FormattedPerformance + var ts string + if len(b1) > 0 { + r, t2 := applyIncrement(rng, s, ts, b1) + s, ts = &r, t2 + } + if len(b2) > 0 { + r, t2 := applyIncrement(rng, s, ts, b2) + s, ts = &r, t2 + } + + var finalFmt FormattedPerformance + if s != nil { + inc := ComputePerformanceIncrement(RawUserDataFromStatus(*s, "s"), b3, ts) + finalFmt = UserFormattedPerformance(inc.UserData, appendForTest) + } else { + finalFmt = UserFormattedPerformance(UserPerformance(b3, ""), appendForTest) + } + if !reflect.DeepEqual(pick(finalFmt), pick(full)) { + t.Fatalf("case %d: final=%+v full=%+v", c, pick(finalFmt), pick(full)) + } + } +} + +func TestUserFormattedPerformance(t *testing.T) { + raw := RawUserData{User: "u", HP: 10, Power: 4, Intelligence: 2, Defence: 3, Agility: 6} + fmt_ := UserFormattedPerformance(raw, AppendData{Exp: 100, User: UserInfo{DisplayName: "d"}}) + + if fmt_.Total != 10+4+2+3+6 { + t.Errorf("Total = %d, want %d", fmt_.Total, 10+4+2+3+6) + } + if fmt_.Exp != 100 { + t.Errorf("Exp = %d, want 100", fmt_.Exp) + } + if fmt_.Points != 100 { + t.Errorf("Points = %d, want 100", fmt_.Points) + } + wantChart := Chart{HP: 10, Power: 4, Intelligence: 2, Defence: 3, Agility: 6} + if fmt_.Chart != wantChart { + t.Errorf("Chart = %+v, want %+v", fmt_.Chart, wantChart) + } + if fmt_.Level != GetLevel(fmt_.Total) { + t.Errorf("Level = %d, want %d", fmt_.Level, GetLevel(fmt_.Total)) + } + if fmt_.User.DisplayName != "d" { + t.Errorf("User.DisplayName = %q, want %q", fmt_.User.DisplayName, "d") + } +} diff --git a/app/functions-go/ogp_rewrite.go b/app/functions-go/ogp_rewrite.go new file mode 100644 index 0000000..40b78a4 --- /dev/null +++ b/app/functions-go/ogp_rewrite.go @@ -0,0 +1,133 @@ +// プロフィールページのOGPメタタグ書き換え(ogpRewrite)エンドポイントのGo実装。 +// +// Node版(app/functions/index.js の exports.ogpRewrite)からの移植であり、 +// コールドスタートを短縮するために Go/Cloud Run functions として個別に +// デプロイする(関数名は ogpRewriteGo。既存の ogpRewrite(Node) とは別関数として +// 共存させる)。Firebase Hostingの `/u/*` リライト先を切り替えるまでは +// 実際のトラフィックは受けない。 +// +// 挙動はNode版と同一にすることを優先し、独自の改善は入れていない +// (詳細は docs/backend.md「ogpRewrite エンドポイントのGo移植」を参照)。 +package gofunctions + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "os" + "regexp" + "strings" + "time" + + "github.com/GoogleCloudPlatform/functions-framework-go/functions" +) + +func init() { + functions.HTTP("OgpRewriteGo", ogpRewriteHandler) +} + +// userPathPattern はNode版 `req_path.match("/u/(.+)")` と同一。 +var userPathPattern = regexp.MustCompile(`/u/(.+)`) + +var ogpHTTPClient = &http.Client{} + +func ogpRewriteHandler(w http.ResponseWriter, r *http.Request) { + reqPath := r.URL.RequestURI() + log.Printf("ogpRewrite: request url: %s", reqPath) + + m := userPathPattern.FindStringSubmatch(reqPath) + if m == nil { + // Node版は Firebase Hosting の `/u/*` リライト経由でのみ呼ばれる前提のため + // この分岐は通常到達しない。Node版はここで正規表現マッチ結果に対して + // 未定義動作(null.lengthの参照)を起こし例外化するため、Go版でも + // 正常系としては扱わず internal error とする(意図的な仕様ではなく、 + // 到達しない想定のNode側の既存バグをそのまま踏襲)。 + log.Printf("ogpRewrite: mismatch query: %s", reqPath) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + username := m[1] + + if err := runOgpRewrite(r.Context(), w, username); err != nil { + log.Printf("ogpRewrite: %v", err) + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("failed")) + } +} + +func runOgpRewrite(ctx context.Context, w http.ResponseWriter, username string) error { + baseURL := os.Getenv("FUNC_BASE_URL") + projectID := os.Getenv("OGP_PROJECT_ID") + + nowUnix := time.Now().Unix() + // og:image は Go版OGP生成関数(userOGPGo)を指す。userOGPGoはWebP(1200x630)を返す。 + ogpURL := fmt.Sprintf("https://us-central1-%s.cloudfunctions.net/userOGPGo?user=%s&t=%d", projectID, username, nowUnix) + description := fmt.Sprintf("これが%sの でばっぐのうりょくだ!", username) + title := fmt.Sprintf("%sの でばっぐのうりょく - でばっぐ神社", username) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) + if err != nil { + return err + } + resp, err := ogpHTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("base_url fetch: status %d", resp.StatusCode) + } + + data := string(body) + replacements := []struct{ from, to string }{ + { + fmt.Sprintf(``, baseURL), + // og:image を差し替えると同時に、WebPであることと固定サイズ(1200x630)を + // クローラに明示する og:image:type / :width / :height を後続に注入する。 + fmt.Sprintf(``, ogpURL), + }, + { + ``, + fmt.Sprintf(``, description), + }, + { + ``, + fmt.Sprintf(``, description), + }, + { + ``, + fmt.Sprintf(``, description), + }, + { + ``, + fmt.Sprintf(``, title), + }, + { + ``, + fmt.Sprintf(``, title), + }, + { + ``, + fmt.Sprintf(``, description), + }, + { + fmt.Sprintf(``, baseURL), + fmt.Sprintf(``, ogpURL), + }, + } + for _, rep := range replacements { + data = strings.Replace(data, rep.from, rep.to, 1) + } + + w.Header().Set("Cache-Control", "public, max-age=300, s-maxage=300") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, data) + return nil +} diff --git a/app/functions-go/ogp_rewrite_test.go b/app/functions-go/ogp_rewrite_test.go new file mode 100644 index 0000000..6089c79 --- /dev/null +++ b/app/functions-go/ogp_rewrite_test.go @@ -0,0 +1,148 @@ +package gofunctions + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func sampleIndexHTML(baseURL string) string { + return ` + + + + + + + +` +} + +func TestOgpRewrite_Success(t *testing.T) { + var baseURL string + baseSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(sampleIndexHTML(baseURL))) + })) + defer baseSrv.Close() + + baseURL = baseSrv.URL + "/" + t.Setenv("FUNC_BASE_URL", baseURL) + t.Setenv("OGP_PROJECT_ID", "d-shrine-dev") + + req := httptest.NewRequest(http.MethodGet, "/u/octocat", nil) + rec := httptest.NewRecorder() + ogpRewriteHandler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, "octocatの でばっぐのうりょくだ!") { + t.Errorf("description not rewritten; body=%s", body) + } + if !strings.Contains(body, "octocatの でばっぐのうりょく - でばっぐ神社") { + t.Errorf("title not rewritten; body=%s", body) + } + if strings.Contains(body, "バグった時の神頼み。") { + t.Errorf("default description text should have been replaced; body=%s", body) + } + if strings.Contains(body, `content="でばっぐ神社"`) { + t.Errorf("default title text should have been replaced; body=%s", body) + } + if !strings.Contains(body, "userOGPGo?user=octocat") { + t.Errorf("og:image should point to userOGPGo; body=%s", body) + } + ogImageTag := extractMetaTag(body, "og:image") + twitterImageTag := extractMetaTag(body, "twitter:image") + if ogImageTag == "" || !strings.Contains(ogImageTag, "userOGPGo?user=octocat") { + t.Errorf("og:image tag not rewritten correctly: %q", ogImageTag) + } + if twitterImageTag == "" || !strings.Contains(twitterImageTag, "userOGPGo?user=octocat") { + t.Errorf("twitter:image tag not rewritten correctly: %q", twitterImageTag) + } + // WebP出力に伴い og:image:type / :width / :height を注入していること。 + if !strings.Contains(body, `property="og:image:type" content="image/webp"`) { + t.Errorf("og:image:type=image/webp not injected; body=%s", body) + } + if !strings.Contains(body, `property="og:image:width" content="1200"`) || + !strings.Contains(body, `property="og:image:height" content="630"`) { + t.Errorf("og:image dimensions not injected; body=%s", body) + } + if got := rec.Header().Get("Cache-Control"); got != "public, max-age=300, s-maxage=300" { + t.Errorf("Cache-Control = %q, want public, max-age=300, s-maxage=300", got) + } +} + +// extractMetaTag はテスト用に data-hid="name" を持つmetaタグの1行を取り出す。 +func extractMetaTag(html, dataHid string) string { + for _, line := range strings.Split(html, "\n") { + if strings.Contains(line, `data-hid="`+dataHid+`"`) { + return line + } + } + return "" +} + +// Node版には og:description のメタタグとして +// (1) property="og:description" を持つパターン +// (2) 持たないパターン +// の2種類の検索文字列が存在する(実際のビルド済みHTMLにはどちらか一方しか +// 含まれない想定だが、両方とも正しく置換できることを個別に確認する)。 +func TestOgpRewrite_OgDescriptionWithoutPropertyAttribute(t *testing.T) { + var baseURL string + baseSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + html := ` + + + + + + + +` + _, _ = w.Write([]byte(html)) + })) + defer baseSrv.Close() + + baseURL = baseSrv.URL + "/" + t.Setenv("FUNC_BASE_URL", baseURL) + t.Setenv("OGP_PROJECT_ID", "d-shrine-dev") + + req := httptest.NewRequest(http.MethodGet, "/u/octocat", nil) + rec := httptest.NewRecorder() + ogpRewriteHandler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", rec.Code, rec.Body.String()) + } + ogDescTag := extractMetaTag(rec.Body.String(), "og:description") + if !strings.Contains(ogDescTag, "octocatの でばっぐのうりょくだ!") { + t.Errorf("og:description (no property attr) not rewritten: %q", ogDescTag) + } +} + +func TestOgpRewrite_PathMismatch(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/other/path", nil) + rec := httptest.NewRecorder() + ogpRewriteHandler(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", rec.Code) + } +} + +func TestOgpRewrite_BaseFetchFailure(t *testing.T) { + t.Setenv("FUNC_BASE_URL", "http://127.0.0.1:1/unreachable") + t.Setenv("OGP_PROJECT_ID", "d-shrine-dev") + + req := httptest.NewRequest(http.MethodGet, "/u/octocat", nil) + rec := httptest.NewRecorder() + ogpRewriteHandler(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rec.Code) + } + if rec.Body.String() != "failed" { + t.Errorf("body = %q, want failed", rec.Body.String()) + } +} diff --git a/app/functions-go/ranking.go b/app/functions-go/ranking.go new file mode 100644 index 0000000..de2a8e2 --- /dev/null +++ b/app/functions-go/ranking.go @@ -0,0 +1,151 @@ +// ランキング表示(ranking)エンドポイントのGo実装。 +// +// Node版(app/functions/index.js の exports.ranking)からの移植であり、 +// コールドスタートを短縮するために Go/Cloud Run functions として個別に +// デプロイする(関数名は rankingGo。既存の ranking(Node) とは別関数として +// 共存させ、フロントエンドの切替タイミングを制御できるようにしている)。 +// +// 挙動はNode版と同一にすることを優先し、独自の改善は入れていない +// (詳細は docs/backend.md「ranking エンドポイントのGo移植」を参照)。 +package gofunctions + +import ( + "context" + "fmt" + "log" + "net/http" + "time" + + "cloud.google.com/go/firestore" + "github.com/GoogleCloudPlatform/functions-framework-go/functions" +) + +func init() { + functions.HTTP("RankingGo", rankingHandler) +} + +// rankingEntry は cache_data/ranking_cache ドキュメントの ranking 配列の1要素。 +type rankingEntry struct { + DisplayName string `firestore:"display_name" json:"display_name"` + ScreenName string `firestore:"screen_name" json:"screen_name"` + ImagePath string `firestore:"image_path" json:"image_path"` + BattlePoint int64 `firestore:"battle_point" json:"battle_point"` + Rank int64 `firestore:"rank" json:"rank"` +} + +// rankingCacheDoc は cache_data/ranking_cache ドキュメントの形状。 +// latest_update(Firestore Timestamp)はNode版のJSON化形式 +// (`_seconds`/`_nanoseconds`)を再現するため DataTo ではなく DataAt で個別に取得する +// (extractTimestampField 参照)。 +type rankingCacheDoc struct { + Ranking []rankingEntry `firestore:"ranking"` +} + +// firestoreTimestampRaw は Node版の Firestore Timestamp を JSON.stringify した際の +// 形状 `{"_seconds":..., "_nanoseconds":...}` を再現するための型 +// (@google-cloud/firestore の Timestamp クラスは toJSON を持たず、 +// プライベートでない `_seconds`/`_nanoseconds` フィールドがそのまま出力される)。 +// フロントエンド(web/components/ranking.vue)は現状この値を表示に使っていないが、 +// レスポンス形状の等価性のために踏襲する。 +type firestoreTimestampRaw struct { + Seconds int64 `json:"_seconds"` + Nanoseconds int64 `json:"_nanoseconds"` +} + +type rankingResponse struct { + Ranking []rankingEntry `json:"ranking"` + LatestUpdate *firestoreTimestampRaw `json:"latest_update,omitempty"` + MyRank *rankingEntry `json:"my_rank,omitempty"` +} + +func rankingHandler(w http.ResponseWriter, r *http.Request) { + setCORSHeaders(w, r) + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "GET,OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization") + w.WriteHeader(http.StatusNoContent) + return + } + + ctx := r.Context() + client, err := getFirestoreClient(ctx) + if err != nil { + log.Printf("ranking: getFirestoreClient error: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + screenName := r.URL.Query().Get("screen_name") + + resp, err := buildRankingResponse(ctx, client, screenName) + if err != nil { + // Node版は cache_data/ranking_cache が未作成の場合に例外化させる処理を + // 特に catch していない(rankingUpdate スケジュール関数が定期的に作成する + // 前提)。Go版もこのケースは異常系として internal error を返す。 + log.Printf("ranking: buildRankingResponse error: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + writeJSON(w, http.StatusOK, resp) +} + +func buildRankingResponse(ctx context.Context, client *firestore.Client, screenName string) (rankingResponse, error) { + return buildRankingResponseFromDoc(ctx, client, "ranking_cache", screenName) +} + +// buildRankingResponseFromDoc は buildRankingResponse の本体。ドキュメントIDを +// 引数にしているのはテストで独立したドキュメントを使えるようにするため。 +func buildRankingResponseFromDoc(ctx context.Context, client *firestore.Client, docID, screenName string) (rankingResponse, error) { + snap, err := client.Collection("cache_data").Doc(docID).Get(ctx) + if err != nil { + return rankingResponse{}, err + } + + var doc rankingCacheDoc + rawSeconds, rawNanos, hasLatestUpdate := extractTimestampField(snap, "latest_update") + if err := snap.DataTo(&doc); err != nil { + return rankingResponse{}, err + } + if doc.Ranking == nil { + // Node版は rankingData.ranking が undefined だと `.slice()` 呼び出しで + // 例外化する(未捕捉、呼び出し元で500になる)。Go版が `ranking: null` を + // 200で返すと「キャッシュ破損を握りつぶして正常応答する」ことになり + // Node版よりも実害を隠してしまうため、同様にエラーとして扱う。 + return rankingResponse{}, fmt.Errorf("ranking_cache document %q has no ranking field", docID) + } + + top := doc.Ranking + if len(top) > 100 { + top = top[:100] + } + + resp := rankingResponse{Ranking: top} + if hasLatestUpdate { + resp.LatestUpdate = &firestoreTimestampRaw{Seconds: rawSeconds, Nanoseconds: rawNanos} + } + if screenName != "" { + for i := range doc.Ranking { + if doc.Ranking[i].ScreenName == screenName { + resp.MyRank = &doc.Ranking[i] + break + } + } + } + return resp, nil +} + +// extractTimestampField は DocumentSnapshot から生の Firestore Timestamp +// (秒・ナノ秒)を取り出す。Node版がJSON化した際の `_seconds`/`_nanoseconds` +// 形式を再現するために time.Time の内部値をそのまま使う。 +func extractTimestampField(snap *firestore.DocumentSnapshot, field string) (seconds, nanoseconds int64, ok bool) { + v, err := snap.DataAt(field) + if err != nil { + return 0, 0, false + } + t, ok := v.(time.Time) + if !ok { + return 0, 0, false + } + return t.Unix(), int64(t.Nanosecond()), true +} diff --git a/app/functions-go/ranking_cache.go b/app/functions-go/ranking_cache.go new file mode 100644 index 0000000..fe97c5a --- /dev/null +++ b/app/functions-go/ranking_cache.go @@ -0,0 +1,108 @@ +// ポイントランキング初期値作成(rankingCache)スケジュール関数のGo実装。 +// +// Node版(app/functions/index.js の exports.rankingCache、120分毎)からの移植。 +// 既に point_ranking/{id} が存在するユーザーはスキップし、無いユーザーには +// 初期値(rank:0)を作成する。 +// +// 挙動をNode版と揃えつつ、以下の1点は構造的に変更している(詳細は +// docs/backend.md「スケジュール関数のGo移植」を参照): +// +// - Node版は `snapshot.forEach(async (item) => {...})` を使っており、 +// forEachはコールバックのPromiseを待たない(fire-and-forget)。そのため +// 理論上は全ユーザー分の書き込みが完了する前に関数の実行が終了したと +// みなされ得る(Cloud Functionsがコンテナを凍結した場合、書き込みが +// 欠落するリスクがある)。これは意図された仕様ではなく実装上の不備と +// 判断し、Go版では各ユーザーの処理を順番にawaitして確実に完了させる。 +// 結果として書き込まれるデータの内容(既存ユーザーはスキップ、無い +// ユーザーはrank:0で作成)自体はNode版と同一。 +package gofunctions + +import ( + "context" + "log" + + "cloud.google.com/go/firestore" + "github.com/GoogleCloudPlatform/functions-framework-go/functions" + cloudevents "github.com/cloudevents/sdk-go/v2" + "google.golang.org/api/iterator" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" +) + +func init() { + functions.CloudEvent("RankingCacheGo", rankingCacheHandler) +} + +func rankingCacheHandler(ctx context.Context, _ cloudevents.Event) error { + client, err := getFirestoreClient(ctx) + if err != nil { + log.Printf("rankingCache: getFirestoreClient error: %v", err) + return err + } + return runRankingCache(ctx, client) +} + +type rankingCacheUserDoc struct { + DisplayName string `firestore:"display_name"` + ScreenName string `firestore:"screen_name"` + Status *struct { + Total int64 `firestore:"total"` + } `firestore:"status"` +} + +func runRankingCache(ctx context.Context, client *firestore.Client) error { + iter := client.Collection("users").Documents(ctx) + defer iter.Stop() + + for { + doc, err := iter.Next() + if err == iterator.Done { + break + } + if err != nil { + return err + } + + var u rankingCacheUserDoc + if err := doc.DataTo(&u); err != nil { + return err + } + if u.Status == nil { + // Node版は `item.data().status.total` で undefined 参照の例外になり、 + // forEach内の非同期コールバックなので他ユーザーの処理には影響しない + // (このユーザーの処理だけが失敗して次のユーザーに進む)。Go版も同様に + // このユーザーをスキップして処理を続ける。 + // + // 注意: `status` は存在するが `status.total` だけが欠けている + // (通常運用では起こらないはずの壊れたデータ)場合、Node版は + // 同様にundefined参照で例外になりスキップされるが、Go版は + // u.Status.Total がゼロ値の0になり `battle_point:0` として + // 書き込まれてしまう。user_formatted_performance/UserFormattedPerformance + // は必ず total を設定するため実運用では発生しない想定だが、 + // 手動編集や移行中データ等で万一 total が欠けた場合の挙動は + // Node版と厳密には一致しない。 + log.Printf("rankingCache: skip user %q: status not computed yet", doc.Ref.ID) + continue + } + + rankingRef := client.Collection("point_ranking").Doc(doc.Ref.ID) + _, err = rankingRef.Get(ctx) + if err == nil { + // 既にpoint_rankingドキュメントが存在する: Node版と同様にスキップ。 + continue + } + if grpcstatus.Code(err) != codes.NotFound { + return err + } + + if _, err := rankingRef.Set(ctx, map[string]interface{}{ + "display_name": u.DisplayName, + "screen_name": u.ScreenName, + "battle_point": u.Status.Total, + "rank": 0, + }, firestore.MergeAll); err != nil { + return err + } + } + return nil +} diff --git a/app/functions-go/ranking_cache_test.go b/app/functions-go/ranking_cache_test.go new file mode 100644 index 0000000..affb187 --- /dev/null +++ b/app/functions-go/ranking_cache_test.go @@ -0,0 +1,80 @@ +package gofunctions + +import ( + "context" + "testing" +) + +func TestRankingCache_CreatesMissingEntriesAndSkipsExisting(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + + newUserID := "TestRankingCache_new_user" + if _, err := client.Collection("users").Doc(newUserID).Set(ctx, map[string]interface{}{ + "display_name": "new user", + "screen_name": "new_user", + "status": map[string]interface{}{"total": int64(42)}, + }); err != nil { + t.Fatalf("failed to seed new user: %v", err) + } + + existingUserID := "TestRankingCache_existing_user" + if _, err := client.Collection("users").Doc(existingUserID).Set(ctx, map[string]interface{}{ + "display_name": "existing user", + "screen_name": "existing_user", + "status": map[string]interface{}{"total": int64(999)}, + }); err != nil { + t.Fatalf("failed to seed existing user: %v", err) + } + if _, err := client.Collection("point_ranking").Doc(existingUserID).Set(ctx, map[string]interface{}{ + "display_name": "existing user", + "screen_name": "existing_user", + "battle_point": int64(1), // 既存の値。上書きされないことを確認する。 + "rank": int64(5), + }); err != nil { + t.Fatalf("failed to seed existing point_ranking: %v", err) + } + + noStatusUserID := "TestRankingCache_no_status_user" + if _, err := client.Collection("users").Doc(noStatusUserID).Set(ctx, map[string]interface{}{ + "display_name": "no status", + "screen_name": "no_status_user", + }); err != nil { + t.Fatalf("failed to seed no-status user: %v", err) + } + + if err := runRankingCache(ctx, client); err != nil { + t.Fatalf("runRankingCache: %v", err) + } + + newDoc, err := client.Collection("point_ranking").Doc(newUserID).Get(ctx) + if err != nil { + t.Fatalf("failed to read new point_ranking doc: %v", err) + } + var newData map[string]interface{} + if err := newDoc.DataTo(&newData); err != nil { + t.Fatalf("DataTo: %v", err) + } + if newData["battle_point"] != int64(42) { + t.Errorf("new point_ranking battle_point = %v, want 42", newData["battle_point"]) + } + if newData["rank"] != int64(0) { + t.Errorf("new point_ranking rank = %v, want 0", newData["rank"]) + } + + existingDoc, err := client.Collection("point_ranking").Doc(existingUserID).Get(ctx) + if err != nil { + t.Fatalf("failed to read existing point_ranking doc: %v", err) + } + var existingData map[string]interface{} + if err := existingDoc.DataTo(&existingData); err != nil { + t.Fatalf("DataTo: %v", err) + } + if existingData["battle_point"] != int64(1) { + t.Errorf("existing point_ranking should be untouched, battle_point = %v, want 1", existingData["battle_point"]) + } + + if _, err := client.Collection("point_ranking").Doc(noStatusUserID).Get(ctx); err == nil { + t.Error("status未計算のユーザーに対してpoint_rankingドキュメントが作成されるべきではない") + } +} diff --git a/app/functions-go/ranking_test.go b/app/functions-go/ranking_test.go new file mode 100644 index 0000000..86c88f0 --- /dev/null +++ b/app/functions-go/ranking_test.go @@ -0,0 +1,159 @@ +package gofunctions + +import ( + "context" + "encoding/json" + "strconv" + "testing" + "time" +) + +func TestRanking_TopListAndMyRank(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + + entries := []map[string]interface{}{} + for i := 1; i <= 120; i++ { + n := strconv.Itoa(i) + entries = append(entries, map[string]interface{}{ + "display_name": "user" + n, + "screen_name": "user" + n, + "image_path": "https://example.com/" + n + ".png", + "battle_point": int64(1000 - i), + "rank": int64(i), + }) + } + now := time.Date(2026, 1, 1, 0, 0, 0, 123000000, time.UTC) + docID := "ranking_cache_TestRanking_TopListAndMyRank" + if _, err := client.Collection("cache_data").Doc(docID).Set(ctx, map[string]interface{}{ + "ranking": entries, + "latest_update": now, + }); err != nil { + t.Fatalf("failed to seed ranking cache: %v", err) + } + + out, err := buildRankingResponseFromDoc(ctx, client, docID, "user50") + if err != nil { + t.Fatalf("buildRankingResponseFromDoc: %v", err) + } + if len(out.Ranking) != 100 { + t.Errorf("ranking length = %d, want 100", len(out.Ranking)) + } + if out.Ranking[0].ScreenName != "user1" { + t.Errorf("ranking[0].screen_name = %q, want user1", out.Ranking[0].ScreenName) + } + if out.MyRank == nil || out.MyRank.ScreenName != "user50" { + t.Fatalf("my_rank = %+v, want screen_name user50", out.MyRank) + } + if out.LatestUpdate == nil || out.LatestUpdate.Seconds != now.Unix() { + t.Errorf("latest_update = %+v, want seconds=%d", out.LatestUpdate, now.Unix()) + } +} + +func TestRanking_NoScreenNameOmitsMyRank(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + + docID := "ranking_cache_TestRanking_NoScreenNameOmitsMyRank" + if _, err := client.Collection("cache_data").Doc(docID).Set(ctx, map[string]interface{}{ + "ranking": []map[string]interface{}{ + {"display_name": "a", "screen_name": "a", "image_path": "", "battle_point": int64(1), "rank": int64(1)}, + }, + "latest_update": time.Now(), + }); err != nil { + t.Fatalf("failed to seed ranking cache: %v", err) + } + + out, err := buildRankingResponseFromDoc(ctx, client, docID, "") + if err != nil { + t.Fatalf("buildRankingResponseFromDoc: %v", err) + } + if out.MyRank != nil { + t.Errorf("my_rank should be nil when screen_name is not given, got: %+v", out.MyRank) + } +} + +func TestRanking_MissingRankingFieldIsError(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + + docID := "ranking_cache_TestRanking_MissingRankingFieldIsError" + if _, err := client.Collection("cache_data").Doc(docID).Set(ctx, map[string]interface{}{ + "latest_update": time.Now(), + }); err != nil { + t.Fatalf("failed to seed ranking cache: %v", err) + } + + if _, err := buildRankingResponseFromDoc(ctx, client, docID, ""); err == nil { + t.Fatal("expected an error when ranking field is missing, got nil") + } +} + +func TestRanking_ResponseJSONFieldNames(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + + docID := "ranking_cache_TestRanking_ResponseJSONFieldNames" + now := time.Date(2026, 2, 2, 3, 4, 5, 600000000, time.UTC) + if _, err := client.Collection("cache_data").Doc(docID).Set(ctx, map[string]interface{}{ + "ranking": []map[string]interface{}{ + {"display_name": "a", "screen_name": "a", "image_path": "", "battle_point": int64(1), "rank": int64(1)}, + }, + "latest_update": now, + }); err != nil { + t.Fatalf("failed to seed ranking cache: %v", err) + } + + out, err := buildRankingResponseFromDoc(ctx, client, docID, "a") + if err != nil { + t.Fatalf("buildRankingResponseFromDoc: %v", err) + } + raw, err := json.Marshal(out) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + var asMap map[string]interface{} + if err := json.Unmarshal(raw, &asMap); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + latestUpdate, ok := asMap["latest_update"].(map[string]interface{}) + if !ok { + t.Fatalf("latest_update is not an object: %v", asMap["latest_update"]) + } + if _, ok := latestUpdate["_seconds"]; !ok { + t.Errorf("latest_update JSON is missing _seconds key: %v", latestUpdate) + } + if _, ok := latestUpdate["_nanoseconds"]; !ok { + t.Errorf("latest_update JSON is missing _nanoseconds key: %v", latestUpdate) + } + myRank, ok := asMap["my_rank"].(map[string]interface{}) + if !ok { + t.Fatalf("my_rank is not an object: %v", asMap["my_rank"]) + } + if myRank["screen_name"] != "a" { + t.Errorf("my_rank.screen_name = %v, want a", myRank["screen_name"]) + } +} + +func TestRanking_ScreenNameNotFound(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + + docID := "ranking_cache_TestRanking_ScreenNameNotFound" + if _, err := client.Collection("cache_data").Doc(docID).Set(ctx, map[string]interface{}{ + "ranking": []map[string]interface{}{ + {"display_name": "a", "screen_name": "a", "image_path": "", "battle_point": int64(1), "rank": int64(1)}, + }, + "latest_update": time.Now(), + }); err != nil { + t.Fatalf("failed to seed ranking cache: %v", err) + } + + out, err := buildRankingResponseFromDoc(ctx, client, docID, "not-in-ranking") + if err != nil { + t.Fatalf("buildRankingResponseFromDoc: %v", err) + } + if out.MyRank != nil { + t.Errorf("my_rank should be nil when screen_name is not found in ranking, got: %+v", out.MyRank) + } +} diff --git a/app/functions-go/ranking_update.go b/app/functions-go/ranking_update.go new file mode 100644 index 0000000..67fef3b --- /dev/null +++ b/app/functions-go/ranking_update.go @@ -0,0 +1,115 @@ +// ランキングキャッシュ更新(rankingUpdate)スケジュール関数のGo実装。 +// +// Node版(app/functions/index.js の exports.rankingUpdate、60分毎)からの移植。 +// ユーザーが待つHTTPエンドポイントではないためコールドスタート短縮の恩恵は +// 無いが、実行時間短縮による課金削減とコード基盤の統一を目的に移植する +// (詳細は docs/backend.md「スケジュール関数のGo移植」を参照)。 +// +// Pub/Sub(Cloud Scheduler経由)トリガーのためHTTPトリガーの他エンドポイントとは +// デプロイ方法が異なる(--trigger-topic。functions-go/README.md参照)。 +package gofunctions + +import ( + "context" + "log" + + "cloud.google.com/go/firestore" + "github.com/GoogleCloudPlatform/functions-framework-go/functions" + cloudevents "github.com/cloudevents/sdk-go/v2" + "google.golang.org/api/iterator" +) + +func init() { + functions.CloudEvent("RankingUpdateGo", rankingUpdateHandler) +} + +func rankingUpdateHandler(ctx context.Context, _ cloudevents.Event) error { + client, err := getFirestoreClient(ctx) + if err != nil { + log.Printf("rankingUpdate: getFirestoreClient error: %v", err) + return err + } + return runRankingUpdate(ctx, client) +} + +type rankingUpdateUserDoc struct { + DisplayName string `firestore:"display_name"` + ScreenName string `firestore:"screen_name"` + ImagePath string `firestore:"image_path"` + Status struct { + Total int64 `firestore:"total"` + } `firestore:"status"` +} + +func runRankingUpdate(ctx context.Context, client *firestore.Client) error { + // Node版は `.orderBy("status.total","desc")` の後にもう一度 + // `.sort((a,b) => b.battlePoint - a.battlePoint)` しているが、 + // `battlePoint`(camelCase)は存在しないフィールド名のタイプミスで、 + // 常に `undefined - undefined = NaN` を返す。V8の配列ソートは + // 比較関数がNaNを返す場合、実質的に要素の順序を変えない(既存の並び= + // Firestoreクエリのorderby順をそのまま維持する)ため、この再ソートは + // 事実上のno-opになっている。Go版は最初から不要な再ソートを行わず、 + // Firestoreクエリの結果順(status.total降順)をそのまま使う。 + // + // また `orderBy("status.total", ...)` は Firestore の仕様上、対象フィールド + // (`status.total`)を持たないドキュメントを自動的に除外するため、 + // `status` 未計算のユーザーによる例外(Node版で言う `.status.total` の + // undefined参照)は発生し得ない。 + iter := client.Collection("users").OrderBy("status.total", firestore.Desc).Documents(ctx) + defer iter.Stop() + + type rankingEntryMutable struct { + DisplayName string + ScreenName string + ImagePath string + BattlePoint int64 + Rank int64 + } + var table []rankingEntryMutable + for { + doc, err := iter.Next() + if err == iterator.Done { + break + } + if err != nil { + return err + } + var u rankingUpdateUserDoc + if err := doc.DataTo(&u); err != nil { + return err + } + table = append(table, rankingEntryMutable{ + DisplayName: u.DisplayName, + ScreenName: u.ScreenName, + ImagePath: u.ImagePath, + BattlePoint: u.Status.Total, + }) + } + + tempRank := int64(1) + tempPoint := int64(-1) + for i := range table { + if tempPoint != table[i].BattlePoint { + tempRank = int64(i) + 1 + tempPoint = table[i].BattlePoint + } + table[i].Rank = tempRank + } + + ranking := make([]rankingEntry, len(table)) + for i, t := range table { + ranking[i] = rankingEntry{ + DisplayName: t.DisplayName, + ScreenName: t.ScreenName, + ImagePath: t.ImagePath, + BattlePoint: t.BattlePoint, + Rank: t.Rank, + } + } + + _, err := client.Collection("cache_data").Doc("ranking_cache").Set(ctx, map[string]interface{}{ + "ranking": ranking, + "latest_update": firestore.ServerTimestamp, + }, firestore.MergeAll) + return err +} diff --git a/app/functions-go/ranking_update_test.go b/app/functions-go/ranking_update_test.go new file mode 100644 index 0000000..149bb33 --- /dev/null +++ b/app/functions-go/ranking_update_test.go @@ -0,0 +1,86 @@ +package gofunctions + +import ( + "context" + "strconv" + "testing" +) + +func TestRankingUpdate_WritesSortedRankingCache(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + + // このテスト専用のプレフィックスでユーザーを作成し、他テストと衝突しないようにする。 + prefix := "TestRankingUpdate_" + points := []int64{300, 300, 100, 500} + for i, p := range points { + id := prefix + strconv.Itoa(i) + if _, err := client.Collection("users").Doc(id).Set(ctx, map[string]interface{}{ + "display_name": "name" + id, + "screen_name": "screen" + id, + "image_path": "https://example.com/" + id + ".png", + "status": map[string]interface{}{"total": p}, + }); err != nil { + t.Fatalf("failed to seed user %s: %v", id, err) + } + } + // status未計算のユーザーはNode版同様、orderByのフィールド欠落除外でスキップされるはず。 + noStatusID := prefix + "no_status" + if _, err := client.Collection("users").Doc(noStatusID).Set(ctx, map[string]interface{}{ + "display_name": "no status", + "screen_name": "no_status", + }); err != nil { + t.Fatalf("failed to seed no-status user: %v", err) + } + + if err := runRankingUpdate(ctx, client); err != nil { + t.Fatalf("runRankingUpdate: %v", err) + } + + doc, err := client.Collection("cache_data").Doc("ranking_cache").Get(ctx) + if err != nil { + t.Fatalf("failed to read ranking_cache: %v", err) + } + var out struct { + Ranking []rankingEntry `firestore:"ranking"` + } + if err := doc.DataTo(&out); err != nil { + t.Fatalf("DataTo: %v", err) + } + + // このテストで作成した4ユーザーだけを screen_name -> entry で抽出する + // (実運用データ・他テストのユーザーが同居する共有Firestoreを使うテストのため、 + // rankの絶対値や同点ユーザー間の並び順(Firestoreの仕様上不定)には依存しない)。 + byScreenName := map[string]rankingEntry{} + for _, e := range out.Ranking { + for i := range points { + if e.ScreenName == "screen"+prefix+strconv.Itoa(i) { + byScreenName[e.ScreenName] = e + } + } + } + if len(byScreenName) != len(points) { + t.Fatalf("filtered ranking length = %d, want %d (got %+v)", len(byScreenName), len(points), byScreenName) + } + + e0 := byScreenName["screen"+prefix+"0"] // 300pt + e1 := byScreenName["screen"+prefix+"1"] // 300pt (e0と同点) + e2 := byScreenName["screen"+prefix+"2"] // 100pt (最下位) + e3 := byScreenName["screen"+prefix+"3"] // 500pt (最上位) + + if e0.Rank != e1.Rank { + t.Errorf("同点(300pt)のユーザーは同じrankになるべき: e0.Rank=%d, e1.Rank=%d", e0.Rank, e1.Rank) + } + if !(e3.Rank < e0.Rank) { + t.Errorf("500ptのrank(%d)は300ptのrank(%d)より良い(小さい)べき", e3.Rank, e0.Rank) + } + if !(e0.Rank < e2.Rank) { + t.Errorf("300ptのrank(%d)は100ptのrank(%d)より良い(小さい)べき", e0.Rank, e2.Rank) + } + + for _, e := range out.Ranking { + if e.ScreenName == "no_status" { + t.Errorf("status未計算のユーザーがrankingに含まれるべきではない: %+v", e) + } + } +} diff --git a/app/functions-go/register.go b/app/functions-go/register.go new file mode 100644 index 0000000..ee7b25f --- /dev/null +++ b/app/functions-go/register.go @@ -0,0 +1,140 @@ +// ユーザー登録(register)エンドポイントのGo実装。 +// +// Node版(app/functions/index.js の exports.register)からの移植であり、 +// コールドスタートを短縮するために Go/Cloud Run functions として個別に +// デプロイする(関数名は registerGo。既存の register(Node) とは別関数として +// 共存させ、フロントエンドの切替タイミングを制御できるようにしている)。 +// +// 挙動はNode版と同一にすることを優先し、独自の改善は入れていない +// (詳細は docs/backend.md「register エンドポイントのGo移植」を参照)。 +package gofunctions + +import ( + "context" + "log" + "net/http" + + "cloud.google.com/go/firestore" + "github.com/GoogleCloudPlatform/functions-framework-go/functions" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" +) + +func init() { + functions.HTTP("RegisterGo", registerHandler) +} + +type registerRequestBody struct { + GithubID string `json:"github_id"` + DisplayName string `json:"display_name"` + ScreenName string `json:"screen_name"` + ImagePath string `json:"image_path"` +} + +func registerHandler(w http.ResponseWriter, r *http.Request) { + setCORSHeaders(w, r) + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "POST,OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization") + w.WriteHeader(http.StatusNoContent) + return + } + + ctx := r.Context() + + // Node版はExpressのbody-parserがルートハンドラ本体(メソッドチェックや + // 認証チェックより前)でリクエストボディをパースするため、不正なJSONの + // 場合はそれらに到達する前に400を返す。Go版もその順序を揃える。 + var body registerRequestBody + if err := decodeJSONBody(r, &body); err != nil { + log.Printf("register: decodeJSONBody error: %v", err) + writeJSON(w, http.StatusBadRequest, map[string]string{"status": "missing request"}) + return + } + + if r.Method != http.MethodPost { + writeJSON(w, http.StatusBadRequest, map[string]string{"status": "missing request"}) + return + } + + token, ok := extractBearerToken(r) + if !ok { + writeJSON(w, http.StatusUnauthorized, map[string]string{"status": "authorization missing."}) + return + } + + authClient, err := getFirebaseAuthClient(ctx) + if err != nil { + log.Printf("register: getFirebaseAuthClient error: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + decoded, err := authClient.VerifyIDToken(ctx, token) + if err != nil { + log.Printf("register: VerifyIDToken error: %v", err) + writeJSON(w, http.StatusForbidden, map[string]string{"status": "authorization missing."}) + return + } + + if body.GithubID == "" || body.DisplayName == "" || body.ScreenName == "" || body.ImagePath == "" { + writeJSON(w, http.StatusOK, map[string]string{"status": "failed parameter"}) + return + } + + client, err := getFirestoreClient(ctx) + if err != nil { + log.Printf("register: getFirestoreClient error: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + if err := runRegister(ctx, w, client, body, decoded.UID); err != nil { + log.Printf("register: runRegister error: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + } +} + +func runRegister(ctx context.Context, w http.ResponseWriter, client *firestore.Client, body registerRequestBody, authUID string) error { + userRef := client.Collection("users").Doc(body.GithubID) + userSnap, err := userRef.Get(ctx) + if err != nil && grpcstatus.Code(err) != codes.NotFound { + return err + } + + if err != nil { + // codes.NotFound: 未登録ユーザー + if _, err := userRef.Set(ctx, map[string]interface{}{ + "github_id": body.GithubID, + "display_name": body.DisplayName, + "screen_name": body.ScreenName, + "image_path": body.ImagePath, + "create_at": firestore.ServerTimestamp, + "exp": 10, + "auth_user_uid": authUID, + }); err != nil { + return err + } + writeJSON(w, http.StatusOK, map[string]string{"status": "success"}) + return nil + } + + var userData struct { + AuthUserUID string `firestore:"auth_user_uid"` + } + if err := userSnap.DataTo(&userData); err != nil { + return err + } + + if userData.AuthUserUID == "" { + if _, err := userRef.Update(ctx, []firestore.Update{ + {Path: "auth_user_uid", Value: authUID}, + }); err != nil { + return err + } + writeJSON(w, http.StatusOK, map[string]string{"status": "updated", "message": "auth_user_uid"}) + return nil + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "registered"}) + return nil +} diff --git a/app/functions-go/register_test.go b/app/functions-go/register_test.go new file mode 100644 index 0000000..7154d32 --- /dev/null +++ b/app/functions-go/register_test.go @@ -0,0 +1,134 @@ +package gofunctions + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestRegister_NewUser(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + githubID := fmt.Sprintf("register-test-new-%d", time.Now().UnixNano()) + + rec := httptest.NewRecorder() + body := registerRequestBody{GithubID: githubID, DisplayName: "d", ScreenName: "s", ImagePath: "https://example.com/i.png"} + if err := runRegister(ctx, rec, client, body, "auth-uid-1"); err != nil { + t.Fatalf("runRegister error: %v", err) + } + assertJSONStatus(t, rec, "success") + + snap, err := client.Collection("users").Doc(githubID).Get(ctx) + if err != nil { + t.Fatalf("failed to fetch created doc: %v", err) + } + var stored struct { + AuthUserUID string `firestore:"auth_user_uid"` + Exp int64 `firestore:"exp"` + } + if err := snap.DataTo(&stored); err != nil { + t.Fatalf("DataTo: %v", err) + } + if stored.AuthUserUID != "auth-uid-1" { + t.Errorf("auth_user_uid = %q, want auth-uid-1", stored.AuthUserUID) + } + if stored.Exp != 10 { + t.Errorf("exp = %d, want 10", stored.Exp) + } +} + +func TestRegister_ExistingUserWithoutAuthUID(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + githubID := fmt.Sprintf("register-test-noauth-%d", time.Now().UnixNano()) + + if _, err := client.Collection("users").Doc(githubID).Set(ctx, map[string]interface{}{ + "display_name": "d", "screen_name": "s", "image_path": "", "exp": 0, + }); err != nil { + t.Fatalf("failed to seed user: %v", err) + } + + rec := httptest.NewRecorder() + body := registerRequestBody{GithubID: githubID, DisplayName: "d", ScreenName: "s", ImagePath: "p"} + if err := runRegister(ctx, rec, client, body, "auth-uid-2"); err != nil { + t.Fatalf("runRegister error: %v", err) + } + assertJSONStatus(t, rec, "updated") +} + +func TestRegister_AlreadyRegistered(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + githubID := fmt.Sprintf("register-test-registered-%d", time.Now().UnixNano()) + + if _, err := client.Collection("users").Doc(githubID).Set(ctx, map[string]interface{}{ + "display_name": "d", "screen_name": "s", "image_path": "", "exp": 0, "auth_user_uid": "existing-uid", + }); err != nil { + t.Fatalf("failed to seed user: %v", err) + } + + rec := httptest.NewRecorder() + body := registerRequestBody{GithubID: githubID, DisplayName: "d", ScreenName: "s", ImagePath: "p"} + if err := runRegister(ctx, rec, client, body, "auth-uid-3"); err != nil { + t.Fatalf("runRegister error: %v", err) + } + assertJSONStatus(t, rec, "registered") +} + +func TestRegisterHandler_NonPostMethod(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/register", nil) + rec := httptest.NewRecorder() + registerHandler(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rec.Code) + } + assertJSONStatus(t, rec, "missing request") +} + +func TestRegisterHandler_MissingAuthorizationHeader(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/register", strings.NewReader(`{}`)) + rec := httptest.NewRecorder() + registerHandler(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } + assertJSONStatus(t, rec, "authorization missing.") +} + +func TestRegisterHandler_MalformedBearerHeader(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/register", strings.NewReader(`{}`)) + req.Header.Set("Authorization", "Token abc") + rec := httptest.NewRecorder() + registerHandler(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } + assertJSONStatus(t, rec, "authorization missing.") +} + +func TestRegisterHandler_MalformedJSONBody(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/register", strings.NewReader(`{invalid`)) + req.Header.Set("Authorization", "Bearer some-token") + rec := httptest.NewRecorder() + registerHandler(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rec.Code) + } + assertJSONStatus(t, rec, "missing request") +} + +func assertJSONStatus(t *testing.T, rec *httptest.ResponseRecorder, want string) { + t.Helper() + var out map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("failed to decode response %q: %v", rec.Body.String(), err) + } + if out["status"] != want { + t.Fatalf("status = %v, want %q (body=%s)", out["status"], want, rec.Body.String()) + } +} diff --git a/app/functions-go/sanpai.go b/app/functions-go/sanpai.go new file mode 100644 index 0000000..142701f --- /dev/null +++ b/app/functions-go/sanpai.go @@ -0,0 +1,463 @@ +// 参拝処理(sanpai)エンドポイントのGo実装。 +// +// Node版(app/functions/index.js の exports.sanpai)からの移植であり、 +// コールドスタートを短縮するために Go/Cloud Run functions として個別に +// デプロイする(関数名は sanpaiGo。既存の sanpai(Node) とは別関数として +// 共存させ、フロントエンドの切替タイミングを制御できるようにしている)。 +// +// 挙動はNode版と同一にすることを優先し、独自の改善は入れていない。 +// 検証中に見つかった既存(Node版)の挙動上の注意点は docs/backend.md +// 「sanpai エンドポイントのGo移植」を参照。 +package gofunctions + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "cloud.google.com/go/firestore" + firebase "firebase.google.com/go/v4" + "firebase.google.com/go/v4/auth" + "github.com/GoogleCloudPlatform/functions-framework-go/functions" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" + + "github.com/428lab/debug-shrine/functions-go/internal/performance" +) + +func init() { + functions.HTTP("SanpaiGo", sanpaiHandler) +} + +// bonusBranches はNode版 bonus_branchs と同一のパターン一覧。 +// 各パターンは "^" + pattern + "$" として repo.name に照合する。 +var bonusBranches = []string{ + "428lab/.*", + "nostr-jp/.*", + "penpenpng/rx-nostr", + "akiomik/nosvelte", +} + +var bonusBranchRegexps = compileBonusBranchRegexps(bonusBranches) + +func compileBonusBranchRegexps(patterns []string) []*regexp.Regexp { + res := make([]*regexp.Regexp, 0, len(patterns)) + for _, p := range patterns { + res = append(res, regexp.MustCompile("^"+p+"$")) + } + return res +} + +func matchesBonusBranch(repoName string) bool { + for _, re := range bonusBranchRegexps { + if re.MatchString(repoName) { + return true + } + } + return false +} + +// sanpaiConfig は Node版 sanpai 定数(add_point/next_time)相当。 +// next_time はプロジェクト(dev/prod)ごとに値が異なるため、Node版の +// `projectID == 'd-shrine' ? 300 : 60` を再現する代わりに、デプロイ時に +// 環境変数で明示的に注入する(dev: 60, prod: 300)。 +type sanpaiConfig struct { + AddPoint int + NextTime int // seconds +} + +func loadSanpaiConfig() sanpaiConfig { + nextTime := 60 + if v := os.Getenv("SANPAI_NEXT_TIME_SECONDS"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + nextTime = n + } + } + return sanpaiConfig{AddPoint: 1, NextTime: nextTime} +} + +var ( + firebaseAuthOnce sync.Once + firebaseAuthClient *auth.Client + firebaseAuthErr error +) + +func getFirebaseAuthClient(ctx context.Context) (*auth.Client, error) { + firebaseAuthOnce.Do(func() { + app, err := firebase.NewApp(ctx, nil) + if err != nil { + firebaseAuthErr = err + return + } + firebaseAuthClient, firebaseAuthErr = app.Auth(ctx) + }) + return firebaseAuthClient, firebaseAuthErr +} + +// Node版(axios)はクライアント側タイムアウトを設定していないため、Go版も揃える +// (呼び出し全体の上限は Cloud Run functions のリクエストタイムアウト設定で担保する)。 +var githubHTTPClient = &http.Client{} + +// githubAPIBaseURL はテストでモックサーバーに差し替えるためのフック。 +var githubAPIBaseURL = "https://api.github.com" + +// githubEvent は GitHub Events API のイベントのうち、sanpai のロジックが +// 参照するフィールドのみを表す(保存には元のraw JSONバイト列をそのまま使う)。 +type githubEvent struct { + ID string `json:"id"` + Type string `json:"type"` + CreatedAt string `json:"created_at"` + Payload any `json:"payload"` + Repo struct { + Name string `json:"name"` + } `json:"repo"` +} + +// feedItem は1件のアクティビティを、フィルタ・保存双方に必要な形でまとめたもの。 +type feedItem struct { + Raw json.RawMessage + Event githubEvent +} + +// fetchGitHubFeed は GitHub Events API から公開アクティビティを取得する +// (Node版 get_feed と同一のURL・クエリパラメータ)。 +func fetchGitHubFeed(ctx context.Context, username string) ([]feedItem, error) { + clientID := os.Getenv("GITHUB_CLIENT_ID") + clientSecret := os.Getenv("GITHUB_CLIENT_SECRET") + + reqURL := fmt.Sprintf( + "%s/users/%s/events/public?per_page=100&client_id=%s&client_secret=%s", + githubAPIBaseURL, url.PathEscape(username), url.QueryEscape(clientID), url.QueryEscape(clientSecret), + ) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, err + } + // GitHub API は User-Agent ヘッダーが無いリクエストを拒否するため必須。 + req.Header.Set("User-Agent", "debug-shrine-sanpaiGo") + + resp, err := githubHTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + log.Printf( + "GitHub X-RateLimit-Limit: %s, X-RateLimit-Reset: %s, X-RateLimit-Used: %s", + resp.Header.Get("X-RateLimit-Limit"), resp.Header.Get("X-RateLimit-Reset"), resp.Header.Get("X-RateLimit-Used"), + ) + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("github events api: status %d: %s", resp.StatusCode, string(respBody)) + } + + var raws []json.RawMessage + if err := json.Unmarshal(respBody, &raws); err != nil { + return nil, err + } + items := make([]feedItem, len(raws)) + for i, r := range raws { + items[i].Raw = r + if err := json.Unmarshal(r, &items[i].Event); err != nil { + return nil, err + } + } + return items, nil +} + +type sanpaiRequestBody struct { + GithubID string `json:"github_id"` + ScreenName string `json:"screen_name"` +} + +func sanpaiHandler(w http.ResponseWriter, r *http.Request) { + setCORSHeaders(w, r) + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "POST,OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization") + w.WriteHeader(http.StatusNoContent) + return + } + + ctx := r.Context() + + // Node版はExpressのbody-parserがルートハンドラ本体(メソッドチェックや + // 認証チェックより前)でリクエストボディをパースするため、不正なJSONの + // 場合はそれらに到達する前に400を返す。Go版もその順序を揃える。 + var body sanpaiRequestBody + if err := decodeJSONBody(r, &body); err != nil { + log.Printf("sanpai: decodeJSONBody error: %v", err) + writeJSON(w, http.StatusBadRequest, map[string]string{"status": "failed"}) + return + } + + if r.Method != http.MethodPost { + writeJSON(w, http.StatusOK, map[string]string{"status": "failed"}) + return + } + + token, ok := extractBearerToken(r) + if !ok { + writeJSON(w, http.StatusUnauthorized, map[string]string{"status": "authorization missing."}) + return + } + + authClient, err := getFirebaseAuthClient(ctx) + if err != nil { + log.Printf("sanpai: getFirebaseAuthClient error: %v", err) + writeJSON(w, http.StatusOK, map[string]string{"status": "missing server error."}) + return + } + if _, err := authClient.VerifyIDToken(ctx, token); err != nil { + log.Printf("sanpai: VerifyIDToken error: %v", err) + writeJSON(w, http.StatusForbidden, map[string]string{"status": "authorization missing."}) + return + } + + if body.GithubID == "" || body.ScreenName == "" { + writeJSON(w, http.StatusOK, map[string]string{"status": "failed parameter"}) + return + } + + client, err := getFirestoreClient(ctx) + if err != nil { + log.Printf("sanpai: getFirestoreClient error: %v", err) + writeJSON(w, http.StatusOK, map[string]string{"status": "missing server error."}) + return + } + + if err := runSanpai(ctx, w, client, body); err != nil { + log.Printf("sanpai: transaction failure: %v", err) + writeJSON(w, http.StatusOK, map[string]string{"status": "missing server error."}) + } +} + +func extractBearerToken(r *http.Request) (string, bool) { + h := r.Header.Get("Authorization") + if h == "" { + return "", false + } + const prefix = "Bearer " + if !strings.HasPrefix(h, prefix) { + return "", false + } + return strings.TrimPrefix(h, prefix), true +} + +// sanpaiUserDocument は users/{id} ドキュメントのうち sanpai が参照するフィールド。 +type sanpaiUserDocument struct { + DisplayName string `firestore:"display_name"` + ScreenName string `firestore:"screen_name"` + ImagePath string `firestore:"image_path"` + Exp int64 `firestore:"exp"` + LastSanpai time.Time `firestore:"last_sanpai"` + LastActivityCreatedAt string `firestore:"last_activity_created_at"` + Status *firestoreStatus `firestore:"status"` + StatusVersion int64 `firestore:"status_version"` +} + +// runSanpai は参拝処理の本体。エラーを返した場合は呼び出し元で +// "missing server error." として応答する(Node版の外側try/catchに相当)。 +// 正常系(failed/expire/noaction/successいずれも)のレスポンス送信はこの関数内で完結する。 +func runSanpai(ctx context.Context, w http.ResponseWriter, client *firestore.Client, body sanpaiRequestBody) error { + cfg := loadSanpaiConfig() + userRef := client.Collection("users").Doc(body.GithubID) + + userSnap, err := userRef.Get(ctx) + if err != nil { + if grpcstatus.Code(err) == codes.NotFound { + writeJSON(w, http.StatusOK, map[string]string{"status": "failed", "message": "not registered"}) + return nil + } + return err + } + + var userData sanpaiUserDocument + if err := userSnap.DataTo(&userData); err != nil { + return err + } + + addExp := cfg.AddPoint + hasLastSanpai := !userData.LastSanpai.IsZero() + + if hasLastSanpai { + // Node版は Firestore Timestamp の整数秒(.seconds、ナノ秒切り捨て)同士を比較するため、 + // Go側も time.Time の丸め込みではなく Unix()(整数秒)で揃える。 + if userData.LastSanpai.Unix()+int64(cfg.NextTime) > time.Now().Unix() { + writeJSON(w, http.StatusOK, map[string]interface{}{"status": "expire", "add_exp": 0}) + return nil + } + } + + feed, err := fetchGitHubFeed(ctx, userData.ScreenName) + if err != nil { + // Node版はGitHub取得失敗時に例外化し外側catchで "missing server error." になる。 + return err + } + + var since time.Time + if hasLastSanpai { + since = userData.LastSanpai + } else { + since, _ = time.Parse(time.RFC3339, "2008-04-01T00:00:00Z") + } + + var splited []feedItem + for _, it := range feed { + created, err := time.Parse(time.RFC3339, it.Event.CreatedAt) + if err != nil { + continue + } + if created.After(since) { + splited = append(splited, it) + } + } + + addExp += len(splited) / 5 + for _, it := range splited { + if matchesBonusBranch(it.Event.Repo.Name) { + addExp++ + } + } + + if len(splited) == 0 { + writeJSON(w, http.StatusOK, map[string]interface{}{"status": "noaction", "add_exp": 0}) + return nil + } + + // アクティビティ反映 + batch := client.Batch() + activityColl := userRef.Collection("github_activities") + for _, it := range splited { + docRef := activityColl.Doc(it.Event.ID) + batch.Set(docRef, map[string]interface{}{ + "id": it.Event.ID, + "type": it.Event.Type, + "created_at": it.Event.CreatedAt, + "raw": string(it.Raw), + }) + } + if _, err := batch.Commit(ctx); err != nil { + return err + } + + // 意図的な省略: Node版にはここに「2022/1/1〜1/3はポイント3倍」という + // 期間限定ボーナス(get_bonus_mag/msg)があるが、判定基準の date_now が + // Node側でコールドスタート時刻に固定される実装のため、対象期間(2022年)を + // 過ぎた現在は常に等倍(bonus_mag=1, msg="")になり実害がない。将来にわたり + // 再度真になることのない期間限定ロジックのため、Go版では意図的に移植せず + // msg は常に空文字とする(詳細は docs/backend.md 参照)。 + + // 参拝可能時間のロックのため last_sanpai を先に確定させる + // (exp/status は計算後の下の update でまとめて反映する) + if _, err := userRef.Update(ctx, []firestore.Update{ + {Path: "last_sanpai", Value: firestore.ServerTimestamp}, + }); err != nil { + return err + } + + if _, _, err := userRef.Collection("sanpai_logs").Add(ctx, map[string]interface{}{ + "add_point": addExp, + "timestamp": firestore.ServerTimestamp, + }); err != nil { + return err + } + + newExp := int(userData.Exp) + addExp + + activities := make([]performance.Activity, len(splited)) + for i, it := range splited { + activities[i] = performance.Activity{Type: it.Event.Type, CreatedAt: it.Event.CreatedAt, Payload: it.Event.Payload} + } + + var rawUserData performance.RawUserData + var lastActivityCreatedAt string + if statusCacheIsCurrent(userData.Status, userData.StatusVersion) && userData.LastActivityCreatedAt != "" { + // 保存済みステータスに新着分だけを加算(全件再集計しない)。 + // splited は「created_at > last_sanpai」で抽出した未集計イベントのみ、 + // last_activity_created_at は累積済みイベントの最大時刻であり、 + // ComputePerformanceIncrement の不変条件(新着は累積分より後)を満たす。 + // + // status_version が古いキャッシュ(旧ロジックで計算)を基準に増分すると + // 過去分の誤りを現行バージョンとして固定化してしまうため、その場合は + // この分岐に入らず下の全件再計算パスに落として基準ごと作り直す。 + baseUserData := performance.RawUserDataFromStatus(fromFirestoreStatus(*userData.Status).FormattedPerformance, userData.ScreenName) + inc := performance.ComputePerformanceIncrement(baseUserData, activities, userData.LastActivityCreatedAt) + rawUserData = inc.UserData + lastActivityCreatedAt = inc.LastCreatedAt + } else { + // 初回(status未保存)は全アクティビティから計算し、増分計算の基準を初期化する + allActivities, err := loadActivities(ctx, userRef) + if err != nil { + return err + } + rawUserData = performance.UserPerformance(allActivities, userData.ScreenName) + lastActivityCreatedAt = performance.LatestActivityCreatedAt(allActivities) + } + + formatted := performance.UserFormattedPerformance(rawUserData, performance.AppendData{ + Exp: newExp, + User: performance.UserInfo{ + DisplayName: userData.DisplayName, + ScreenName: userData.ScreenName, + GithubImagePath: userData.ImagePath, + }, + }) + + if _, err := userRef.Update(ctx, []firestore.Update{ + {Path: "last_sanpai", Value: firestore.ServerTimestamp}, + {Path: "exp", Value: firestore.Increment(addExp)}, + {Path: "status", Value: toFirestoreStatus(formatted, "")}, + {Path: "status_version", Value: performance.StatusLogicVersion}, + {Path: "last_activity_created_at", Value: lastActivityCreatedAt}, + }); err != nil { + return err + } + + // 参拝による変化(before/after)を算出してフロントの変化表示に渡す + powerBefore := 0 + if userData.Status != nil { + powerBefore = int(userData.Status.Total) + } + pointsBefore := int(userData.Exp) + levelBefore := performance.GetLevel(powerBefore) + + updatedRepos := map[string]struct{}{} + for _, it := range splited { + if it.Event.Repo.Name != "" { + updatedRepos[it.Event.Repo.Name] = struct{}{} + } + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "status": "success", + "add_exp": addExp, + "level": formatted.Level, + "exp": formatted.Points, + "next_exp": formatted.NextExp, + "msg": "", + "points_before": pointsBefore, + "points_after": formatted.Points, + "power_before": powerBefore, + "power_after": formatted.Total, + "level_before": levelBefore, + "level_after": formatted.Level, + "updated_repo_count": len(updatedRepos), + "action_count": len(splited), + }) + return nil +} diff --git a/app/functions-go/sanpai_test.go b/app/functions-go/sanpai_test.go new file mode 100644 index 0000000..5c3884e --- /dev/null +++ b/app/functions-go/sanpai_test.go @@ -0,0 +1,308 @@ +package gofunctions + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "cloud.google.com/go/firestore" + + "github.com/428lab/debug-shrine/functions-go/internal/performance" +) + +// このテストはFirestoreエミュレータが必要なため、FIRESTORE_EMULATOR_HOST が +// 設定されていない場合(通常のCI実行時)は自動的にスキップする。 +// ローカルでの実行方法は functions-go/README.md を参照。 +func emulatorClient(t *testing.T) *firestore.Client { + t.Helper() + if os.Getenv("FIRESTORE_EMULATOR_HOST") == "" { + t.Skip("FIRESTORE_EMULATOR_HOST not set; skipping Firestore emulator integration test") + } + ctx := context.Background() + client, err := firestore.NewClient(ctx, "d-shrine-dev") + if err != nil { + t.Fatalf("firestore.NewClient: %v", err) + } + t.Cleanup(func() { client.Close() }) + return client +} + +func mockEvent(id, eventType, repoName, createdAt string) map[string]interface{} { + return map[string]interface{}{ + "id": id, + "type": eventType, + "repo": map[string]interface{}{"name": repoName}, + "created_at": createdAt, + "payload": map[string]interface{}{}, + } +} + +func newMockGitHubServer(t *testing.T, events []map[string]interface{}) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(events) + })) + t.Cleanup(srv.Close) + return srv +} + +func withMockGitHub(t *testing.T, srv *httptest.Server) { + t.Helper() + orig := githubAPIBaseURL + githubAPIBaseURL = srv.URL + t.Cleanup(func() { githubAPIBaseURL = orig }) +} + +func postSanpai(t *testing.T, ctx context.Context, client *firestore.Client, githubID, screenName string) map[string]interface{} { + t.Helper() + rec := httptest.NewRecorder() + err := runSanpai(ctx, rec, client, sanpaiRequestBody{GithubID: githubID, ScreenName: screenName}) + if err != nil { + t.Fatalf("runSanpai returned error: %v", err) + } + var out map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("failed to decode response %q: %v", rec.Body.String(), err) + } + return out +} + +func setupTestUser(t *testing.T, ctx context.Context, client *firestore.Client, githubID string, data map[string]interface{}) { + t.Helper() + if _, err := client.Collection("users").Doc(githubID).Set(ctx, data); err != nil { + t.Fatalf("failed to seed user doc: %v", err) + } +} + +func TestSanpai_NotRegistered(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + out := postSanpai(t, ctx, client, "no-such-user-999", "no-such-user-999") + if out["status"] != "failed" || out["message"] != "not registered" { + t.Fatalf("unexpected response: %+v", out) + } +} + +func TestSanpai_FirstTime_FullCalculation(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + githubID := fmt.Sprintf("sanpai-test-first-%d", time.Now().UnixNano()) + + setupTestUser(t, ctx, client, githubID, map[string]interface{}{ + "display_name": "Test User", + "screen_name": githubID, + "image_path": "https://example.com/icon.png", + "exp": 10, + }) + + events := []map[string]interface{}{ + mockEvent("1", "PushEvent", "428lab/foo", "2024-01-01T00:00:00Z"), + mockEvent("2", "ForkEvent", "someone/bar", "2024-01-01T00:10:00Z"), + mockEvent("3", "PullRequestEvent", "someone/bar", "2024-01-01T00:20:00Z"), + } + withMockGitHub(t, newMockGitHubServer(t, events)) + + out := postSanpai(t, ctx, client, githubID, githubID) + if out["status"] != "success" { + t.Fatalf("unexpected response: %+v", out) + } + // add_exp = 1(base) + floor(3/5)=0 + bonus_branch match("428lab/foo")=1 => 2 + if got := int(out["add_exp"].(float64)); got != 2 { + t.Errorf("add_exp = %d, want 2", got) + } + if got := int(out["action_count"].(float64)); got != 3 { + t.Errorf("action_count = %d, want 3", got) + } + if got := int(out["updated_repo_count"].(float64)); got != 2 { + t.Errorf("updated_repo_count = %d, want 2", got) + } + if got := int(out["points_before"].(float64)); got != 10 { + t.Errorf("points_before = %d, want 10", got) + } + if got := int(out["points_after"].(float64)); got != 12 { + t.Errorf("points_after = %d, want 12", got) + } + + snap, err := client.Collection("users").Doc(githubID).Get(ctx) + if err != nil { + t.Fatalf("failed to re-fetch user doc: %v", err) + } + var updated sanpaiUserDocument + if err := snap.DataTo(&updated); err != nil { + t.Fatalf("DataTo: %v", err) + } + if updated.Exp != 12 { + t.Errorf("stored exp = %d, want 12", updated.Exp) + } + if updated.Status == nil { + t.Fatalf("expected status cache to be written") + } + if updated.LastActivityCreatedAt != "2024-01-01T00:20:00Z" { + t.Errorf("last_activity_created_at = %q, want 2024-01-01T00:20:00Z", updated.LastActivityCreatedAt) + } + if updated.LastSanpai.IsZero() { + t.Errorf("expected last_sanpai to be set") + } + + iter := client.Collection("users").Doc(githubID).Collection("github_activities").Documents(ctx) + count := 0 + for { + _, err := iter.Next() + if err != nil { + break + } + count++ + } + if count != 3 { + t.Errorf("stored activity count = %d, want 3", count) + } +} + +func TestSanpai_Cooldown_Expire(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + githubID := fmt.Sprintf("sanpai-test-cooldown-%d", time.Now().UnixNano()) + + setupTestUser(t, ctx, client, githubID, map[string]interface{}{ + "display_name": "Test User", + "screen_name": githubID, + "image_path": "", + "exp": 0, + "last_sanpai": time.Now(), + }) + + out := postSanpai(t, ctx, client, githubID, githubID) + if out["status"] != "expire" { + t.Fatalf("unexpected response: %+v", out) + } + if got := int(out["add_exp"].(float64)); got != 0 { + t.Errorf("add_exp = %d, want 0", got) + } +} + +func TestSanpai_NoAction(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + githubID := fmt.Sprintf("sanpai-test-noaction-%d", time.Now().UnixNano()) + + past := time.Now().Add(-1 * time.Hour) + setupTestUser(t, ctx, client, githubID, map[string]interface{}{ + "display_name": "Test User", + "screen_name": githubID, + "image_path": "", + "exp": 0, + "last_sanpai": past, + }) + + // last_sanpaiより前のイベントしかない -> noaction + events := []map[string]interface{}{ + mockEvent("1", "PushEvent", "someone/bar", past.Add(-1*time.Hour).UTC().Format(time.RFC3339)), + } + withMockGitHub(t, newMockGitHubServer(t, events)) + + out := postSanpai(t, ctx, client, githubID, githubID) + if out["status"] != "noaction" { + t.Fatalf("unexpected response: %+v", out) + } +} + +func TestSanpai_Increment_MatchesFullRecalculation(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + githubID := fmt.Sprintf("sanpai-test-increment-%d", time.Now().UnixNano()) + + setupTestUser(t, ctx, client, githubID, map[string]interface{}{ + "display_name": "Test User", + "screen_name": githubID, + "image_path": "", + "exp": 5, + }) + + firstBatch := []map[string]interface{}{ + mockEvent("1", "PushEvent", "someone/bar", "2024-01-01T00:00:00Z"), + mockEvent("2", "ForkEvent", "someone/bar", "2024-01-01T00:05:00Z"), + } + withMockGitHub(t, newMockGitHubServer(t, firstBatch)) + first := postSanpai(t, ctx, client, githubID, githubID) + if first["status"] != "success" { + t.Fatalf("first sanpai failed: %+v", first) + } + + // クールダウンを回避するため last_sanpai を過去に巻き戻す(テスト用の直接操作)。 + // ただし1回目で処理済みのイベント(〜00:05:00Z)より後、2回目の新規イベント + // (00:10:00Z〜)より前にする必要がある(でないと既処理イベントを新着として + // 二重集計してしまい、本番では起きない不変条件違反を人為的に作ってしまう)。 + if _, err := client.Collection("users").Doc(githubID).Update(ctx, []firestore.Update{ + {Path: "last_sanpai", Value: time.Date(2024, 1, 1, 0, 6, 0, 0, time.UTC)}, + }); err != nil { + t.Fatalf("failed to rewind last_sanpai: %v", err) + } + + secondBatch := append(append([]map[string]interface{}{}, firstBatch...), + mockEvent("3", "PullRequestEvent", "someone/bar", "2024-01-01T00:10:00Z"), + mockEvent("4", "IssueCommentEvent", "someone/bar", "2024-01-01T00:15:00Z"), + ) + withMockGitHub(t, newMockGitHubServer(t, secondBatch)) + second := postSanpai(t, ctx, client, githubID, githubID) + if second["status"] != "success" { + t.Fatalf("second sanpai failed: %+v", second) + } + + // 全件(4件)を一発で計算した場合の戦闘力と、増分計算2回分の結果が一致することを確認する。 + allActivities := []performance.Activity{ + {Type: "PushEvent", CreatedAt: "2024-01-01T00:00:00Z"}, + {Type: "ForkEvent", CreatedAt: "2024-01-01T00:05:00Z"}, + {Type: "PullRequestEvent", CreatedAt: "2024-01-01T00:10:00Z"}, + {Type: "IssueCommentEvent", CreatedAt: "2024-01-01T00:15:00Z"}, + } + fullRaw := performance.UserPerformance(allActivities, githubID) + fullFormatted := performance.UserFormattedPerformance(fullRaw, performance.AppendData{}) + + if got := int(second["power_after"].(float64)); got != fullFormatted.Total { + t.Errorf("power_after(increment) = %d, want %d(full recalculation)", got, fullFormatted.Total) + } +} + +// エミュレータを必要としないハンドラレベルのテスト(認証・ボディパースの分岐)。 + +func TestSanpaiHandler_MalformedJSONBody(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/sanpai", strings.NewReader(`{invalid`)) + req.Header.Set("Authorization", "Bearer some-token") + rec := httptest.NewRecorder() + sanpaiHandler(rec, req) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rec.Code) + } + var out map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("failed to decode response %q: %v", rec.Body.String(), err) + } + if out["status"] != "failed" { + t.Errorf("status field = %v, want failed", out["status"]) + } +} + +func TestSanpaiHandler_NonPostMethod(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/sanpai", nil) + rec := httptest.NewRecorder() + sanpaiHandler(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } +} + +func TestSanpaiHandler_MissingAuthorizationHeader(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/sanpai", strings.NewReader(`{}`)) + rec := httptest.NewRecorder() + sanpaiHandler(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } +} diff --git a/app/functions-go/scheduled_ogp_delete.go b/app/functions-go/scheduled_ogp_delete.go new file mode 100644 index 0000000..e463ae6 --- /dev/null +++ b/app/functions-go/scheduled_ogp_delete.go @@ -0,0 +1,111 @@ +// OGP画像キャッシュの定期削除(scheduledOgpDelete)スケジュール関数のGo実装。 +// +// Node版(app/functions/index.js の exports.scheduledOgpDelete、毎時)からの移植。 +// Cloud Storageの `ogps/` プレフィックス配下のファイルを全て削除する +// (userOGPが生成するOGP画像キャッシュを定期的に作り直させるための削除処理)。 +// +// 挙動はNode版と同一にすることを優先し、独自の改善は入れていない +// (詳細は docs/backend.md「スケジュール関数のGo移植」を参照)。 +package gofunctions + +import ( + "context" + "errors" + "log" + "os" + "sync" + + "cloud.google.com/go/storage" + "github.com/GoogleCloudPlatform/functions-framework-go/functions" + cloudevents "github.com/cloudevents/sdk-go/v2" + "google.golang.org/api/iterator" +) + +func init() { + functions.CloudEvent("ScheduledOgpDeleteGo", scheduledOgpDeleteHandler) +} + +var ( + storageClientOnce sync.Once + storageClient *storage.Client + storageClientErr error +) + +func getStorageClient(ctx context.Context) (*storage.Client, error) { + storageClientOnce.Do(func() { + storageClient, storageClientErr = storage.NewClient(ctx) + }) + return storageClient, storageClientErr +} + +func scheduledOgpDeleteHandler(ctx context.Context, _ cloudevents.Event) error { + bucketName := os.Getenv("STORAGE_BUCKET_NAME") + if bucketName == "" { + // デプロイ時の --set-env-vars 漏れ等の設定ミスに早期に気づけるよう、 + // 空のバケット名でAPI呼び出しに進んでしまう前に明示的にエラーにする。 + return errors.New("scheduledOgpDelete: STORAGE_BUCKET_NAME is not set") + } + client, err := getStorageClient(ctx) + if err != nil { + log.Printf("scheduledOgpDelete: getStorageClient error: %v", err) + return err + } + return runScheduledOgpDelete(ctx, storageOgpBucket{bucket: client.Bucket(bucketName)}, "ogps/") +} + +// ogpBucket は Cloud Storage 操作を抽象化する(*storage.BucketHandle は具象型で +// モック差し替えができないため、テスト時にフェイク実装へ差し替えるための境界)。 +type ogpBucket interface { + listObjectNames(ctx context.Context, prefix string) ([]string, error) + deleteObject(ctx context.Context, name string) error +} + +type storageOgpBucket struct { + bucket *storage.BucketHandle +} + +func (b storageOgpBucket) listObjectNames(ctx context.Context, prefix string) ([]string, error) { + it := b.bucket.Objects(ctx, &storage.Query{Prefix: prefix}) + var names []string + for { + attrs, err := it.Next() + if err == iterator.Done { + break + } + if err != nil { + return nil, err + } + names = append(names, attrs.Name) + } + return names, nil +} + +func (b storageOgpBucket) deleteObject(ctx context.Context, name string) error { + return b.bucket.Object(name).Delete(ctx) +} + +func runScheduledOgpDelete(ctx context.Context, bucket ogpBucket, prefix string) error { + names, err := bucket.listObjectNames(ctx, prefix) + if err != nil { + return err + } + deleted := 0 + for _, name := range names { + if err := bucket.deleteObject(ctx, name); err != nil { + // Node版は `bucket.deleteFiles({prefix:"ogps/"})` の戻り値(Promise)を + // onRun内でawait/returnしておらず、削除失敗時の挙動は実質的に + // 「ログにも残らず不可視のunhandled rejectionになるだけ」という + // fire-and-forgetな状態(force未指定なのでNode内部的には最初の + // エラーで残りの削除処理は打ち切られるが、それを検知する仕組みが無い)。 + // この処理は1時間毎に再実行される冪等なクリーンアップジョブであり、 + // 削除し損ねたファイルは次回実行で再試行されるだけなので実害は無い。 + // Go版は挙動を不可視にせず、1ファイルの削除失敗をログに残して + // 他のファイルの削除は続行する(ジョブ全体を失敗させない)。 + log.Printf("scheduledOgpDelete: failed to delete %q: %v", name, err) + continue + } + deleted++ + } + log.Printf("scheduledOgpDelete: deleted %d files", deleted) + return nil +} diff --git a/app/functions-go/scheduled_ogp_delete_test.go b/app/functions-go/scheduled_ogp_delete_test.go new file mode 100644 index 0000000..f6ee031 --- /dev/null +++ b/app/functions-go/scheduled_ogp_delete_test.go @@ -0,0 +1,74 @@ +package gofunctions + +import ( + "context" + "errors" + "testing" +) + +type fakeOgpBucket struct { + objects map[string]bool + failNames map[string]bool +} + +func newFakeOgpBucket(names ...string) *fakeOgpBucket { + objects := map[string]bool{} + for _, n := range names { + objects[n] = true + } + return &fakeOgpBucket{objects: objects, failNames: map[string]bool{}} +} + +func (b *fakeOgpBucket) listObjectNames(_ context.Context, prefix string) ([]string, error) { + var names []string + for n := range b.objects { + if len(n) >= len(prefix) && n[:len(prefix)] == prefix { + names = append(names, n) + } + } + return names, nil +} + +func (b *fakeOgpBucket) deleteObject(_ context.Context, name string) error { + if b.failNames[name] { + return errors.New("simulated delete failure") + } + delete(b.objects, name) + return nil +} + +func TestScheduledOgpDelete_DeletesOnlyMatchingPrefix(t *testing.T) { + bucket := newFakeOgpBucket("ogps/user1.png", "ogps/user2.png", "base.png") + ctx := context.Background() + + if err := runScheduledOgpDelete(ctx, bucket, "ogps/"); err != nil { + t.Fatalf("runScheduledOgpDelete: %v", err) + } + + if _, ok := bucket.objects["ogps/user1.png"]; ok { + t.Error("ogps/user1.png should have been deleted") + } + if _, ok := bucket.objects["ogps/user2.png"]; ok { + t.Error("ogps/user2.png should have been deleted") + } + if _, ok := bucket.objects["base.png"]; !ok { + t.Error("base.png (prefix対象外) should not have been deleted") + } +} + +func TestScheduledOgpDelete_ContinuesAfterIndividualDeleteFailure(t *testing.T) { + bucket := newFakeOgpBucket("ogps/user1.png", "ogps/user2.png") + bucket.failNames["ogps/user1.png"] = true + ctx := context.Background() + + if err := runScheduledOgpDelete(ctx, bucket, "ogps/"); err != nil { + t.Fatalf("runScheduledOgpDelete should not fail the whole job on a single delete error: %v", err) + } + + if _, ok := bucket.objects["ogps/user1.png"]; !ok { + t.Error("delete失敗したファイルはそのまま残るはず") + } + if _, ok := bucket.objects["ogps/user2.png"]; ok { + t.Error("他のファイルの削除は継続されるべき") + } +} diff --git a/app/functions-go/status.go b/app/functions-go/status.go new file mode 100644 index 0000000..057accc --- /dev/null +++ b/app/functions-go/status.go @@ -0,0 +1,307 @@ +// マイページ/プロフィール表示で使われる status エンドポイントのGo実装。 +// +// Node版(app/functions/index.js の exports.status)からの移植であり、 +// コールドスタートを短縮するために Go/Cloud Run functions として個別に +// デプロイする(関数名は statusGo。既存の status(Node) とは別関数として +// 共存させ、フロントエンドの切替タイミングを制御できるようにしている)。 +// +// 挙動はNode版と同一にすることを優先し、独自の改善は入れていない +// (詳細は docs/backend.md の「status エンドポイントのGo移植」を参照)。 +package gofunctions + +import ( + "context" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "sync" + "time" + + "cloud.google.com/go/firestore" + "github.com/GoogleCloudPlatform/functions-framework-go/functions" + "google.golang.org/api/iterator" + + "github.com/428lab/debug-shrine/functions-go/internal/performance" +) + +func init() { + functions.HTTP("StatusGo", statusHandler) +} + +var ( + firestoreClientOnce sync.Once + firestoreClient *firestore.Client + firestoreClientErr error +) + +// getFirestoreClient はコールドスタート時に1回だけ初期化し、以降のリクエストで再利用する。 +// +// Cloud Run functions(2nd gen)の環境では GOOGLE_CLOUD_PROJECT 等の環境変数が +// 常に設定される保証がないため、firestore.DetectProjectID を使ってADC(Application +// Default Credentials)経由でプロジェクトIDを自動検出する +// (GOOGLE_CLOUD_PROJECT envvar -> ADCのcreds.ProjectID の順に試行される)。 +func getFirestoreClient(ctx context.Context) (*firestore.Client, error) { + firestoreClientOnce.Do(func() { + firestoreClient, firestoreClientErr = firestore.NewClient(ctx, firestore.DetectProjectID) + }) + return firestoreClient, firestoreClientErr +} + +// userDocument は users/{id} ドキュメントのうち status エンドポイントが参照するフィールド。 +type userDocument struct { + DisplayName string `firestore:"display_name"` + ScreenName string `firestore:"screen_name"` + ImagePath string `firestore:"image_path"` + Exp int64 `firestore:"exp"` + LastSanpai time.Time `firestore:"last_sanpai"` + Status *firestoreStatus `firestore:"status"` + StatusVersion int64 `firestore:"status_version"` +} + +// statusCacheIsCurrent は保存済み status キャッシュが現行の計算ロジックで作られたものか判定する。 +// status が無い、または status_version が現行(performance.StatusLogicVersion)より古い場合は +// false(=再計算が必要)。旧キャッシュには status_version フィールドが無く 0 として読まれるため +// 自動的に再計算対象になる。 +func statusCacheIsCurrent(status *firestoreStatus, statusVersion int64) bool { + return status != nil && statusVersion >= performance.StatusLogicVersion +} + +// firestoreStatus は users/{id}.status の形状(Node版 user_formatted_performance の +// 戻り値 + last_sanpai)。performance.FormattedPerformance とフィールド名を同一に保つこと。 +type firestoreStatus struct { + User performance.UserInfo `firestore:"user"` + Points int64 `firestore:"points"` + HP int64 `firestore:"hp"` + Power int64 `firestore:"power"` + Intelligence int64 `firestore:"intelligence"` + Defence int64 `firestore:"defence"` + Agility int64 `firestore:"agility"` + Total int64 `firestore:"total"` + Level int64 `firestore:"level"` + Exp int64 `firestore:"exp"` + NextExp int64 `firestore:"next_exp"` + Chart performance.Chart `firestore:"chart"` + LastSanpai string `firestore:"last_sanpai"` +} + +func toFirestoreStatus(f performance.FormattedPerformance, lastSanpai string) firestoreStatus { + return firestoreStatus{ + User: f.User, + Points: int64(f.Points), + HP: int64(f.HP), + Power: int64(f.Power), + Intelligence: int64(f.Intelligence), + Defence: int64(f.Defence), + Agility: int64(f.Agility), + Total: int64(f.Total), + Level: int64(f.Level), + Exp: int64(f.Exp), + NextExp: int64(f.NextExp), + Chart: f.Chart, + LastSanpai: lastSanpai, + } +} + +// StatusResponse はクライアントに返すJSONの形状(Node版 return_Data と同一)。 +type StatusResponse struct { + performance.FormattedPerformance + LastSanpai string `json:"last_sanpai"` +} + +func fromFirestoreStatus(fs firestoreStatus) StatusResponse { + return StatusResponse{ + FormattedPerformance: performance.FormattedPerformance{ + User: fs.User, + Points: int(fs.Points), + HP: int(fs.HP), + Power: int(fs.Power), + Intelligence: int(fs.Intelligence), + Defence: int(fs.Defence), + Agility: int(fs.Agility), + Total: int(fs.Total), + Level: int(fs.Level), + Exp: int(fs.Exp), + NextExp: int(fs.NextExp), + Chart: fs.Chart, + }, + LastSanpai: fs.LastSanpai, + } +} + +func setCORSHeaders(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin == "" { + origin = "*" + } + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(body); err != nil { + log.Printf("status: failed to encode response: %v", err) + } +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"status": "failed", "message": message}) +} + +// decodeJSONBody はリクエストボディをJSONとしてデコードする。 +// +// Node版(Firebase Functions)はExpressのbody-parserがハンドラ本体より前に +// リクエストボディをパースし、不正なJSONの場合はハンドラに到達する前に +// 400を返す(空ボディの場合はエラーにせず `{}` 扱いにする)。Go版は自前で +// デコードするため、同じ理由で失敗した場合は呼び出し元でハンドラ本体 +// (認証チェック等)より先に400を返すこと。 +func decodeJSONBody(r *http.Request, v any) error { + if r.Body == nil { + return nil + } + dec := json.NewDecoder(r.Body) + if err := dec.Decode(v); err != nil { + if errors.Is(err, io.EOF) { + // ボディが空(Content-Length: 0 等)。Express body-parserもエラーにしない。 + return nil + } + return err + } + return nil +} + +func statusHandler(w http.ResponseWriter, r *http.Request) { + setCORSHeaders(w, r) + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "GET,OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization") + w.WriteHeader(http.StatusNoContent) + return + } + + ctx := r.Context() + client, err := getFirestoreClient(ctx) + if err != nil { + log.Printf("status: getFirestoreClient error: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + screenName := r.URL.Query().Get("user") + userDoc, err := findUserByScreenName(ctx, client, screenName) + if err != nil { + log.Printf("status: findUserByScreenName error: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if userDoc == nil { + writeError(w, http.StatusNotFound, "user not registered.") + return + } + + var userData userDocument + if err := userDoc.DataTo(&userData); err != nil { + log.Printf("status: DataTo error: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + if statusCacheIsCurrent(userData.Status, userData.StatusVersion) { + resp := fromFirestoreStatus(*userData.Status) + resp.LastSanpai = formatLastSanpai(userData.LastSanpai) + writeJSON(w, http.StatusOK, resp) + return + } + + activities, err := loadActivities(ctx, userDoc.Ref) + if err != nil { + log.Printf("status: loadActivities error: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + raw := performance.UserPerformance(activities, screenName) + formatted := performance.UserFormattedPerformance(raw, performance.AppendData{ + Exp: int(userData.Exp), + User: performance.UserInfo{ + DisplayName: userData.DisplayName, + ScreenName: userData.ScreenName, + GithubImagePath: userData.ImagePath, + }, + }) + resp := StatusResponse{FormattedPerformance: formatted, LastSanpai: "参拝していないようです"} + + if _, err := userDoc.Ref.Update(ctx, []firestore.Update{ + {Path: "status", Value: toFirestoreStatus(formatted, resp.LastSanpai)}, + {Path: "status_version", Value: performance.StatusLogicVersion}, + }); err != nil { + log.Printf("status: cache write-back error: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + writeJSON(w, http.StatusOK, resp) +} + +func findUserByScreenName(ctx context.Context, client *firestore.Client, screenName string) (*firestore.DocumentSnapshot, error) { + if screenName == "" { + return nil, nil + } + iter := client.Collection("users").Where("screen_name", "==", screenName).Limit(1).Documents(ctx) + defer iter.Stop() + doc, err := iter.Next() + if errors.Is(err, iterator.Done) { + return nil, nil + } + if err != nil { + return nil, err + } + return doc, nil +} + +type activityDoc struct { + Raw string `firestore:"raw"` +} + +func loadActivities(ctx context.Context, userRef *firestore.DocumentRef) ([]performance.Activity, error) { + iter := userRef.Collection("github_activities").Documents(ctx) + defer iter.Stop() + var activities []performance.Activity + for { + doc, err := iter.Next() + if errors.Is(err, iterator.Done) { + break + } + if err != nil { + return nil, err + } + var raw activityDoc + if err := doc.DataTo(&raw); err != nil { + return nil, err + } + var a performance.Activity + if err := json.Unmarshal([]byte(raw.Raw), &a); err != nil { + return nil, err + } + activities = append(activities, a) + } + return activities, nil +} + +// formatLastSanpai は Node版の moment(...).format('YYYY年MM月DD日 HH:mm') と同一の文字列を返す。 +// Cloud Functions の実行環境はデフォルトタイムゾーンがUTCであるため、UTCとして整形する。 +// +// last_sanpai(トップレベル)が存在しない状態で status キャッシュだけが存在するユーザー +// (一度もsanpaiせずプロフィールを2回以上見ると発生し得る)に対しては、status未保存時の +// フル計算パスと同じく「参拝していないようです」を返す(未参拝ユーザーの正しい表示)。 +// ※移植前のNode版はこの場合 `undefined.toDate()` を呼び出して例外になるバグがあった。 +// 本移植に合わせてNode版も同じく修正済み。 +func formatLastSanpai(t time.Time) string { + if t.IsZero() { + return "参拝していないようです" + } + return t.UTC().Format("2006年01月02日 15:04") +} diff --git a/app/functions-go/status_cache_backfill.go b/app/functions-go/status_cache_backfill.go new file mode 100644 index 0000000..e15fa48 --- /dev/null +++ b/app/functions-go/status_cache_backfill.go @@ -0,0 +1,116 @@ +// マイページ表示キャッシュの事前計算(statusCacheBackfill)スケジュール関数のGo実装。 +// +// Node版(app/functions/index.js の exports.statusCacheBackfill、30分毎)からの +// 移植。直近6ヶ月以内に参拝したユーザーのうち status キャッシュが未計算の +// ユーザーを1回の実行につき最大10件だけ計算してキャッシュする。 +// +// status(statusGo)エンドポイントと同じ計算ロジック・Firestore読み込み +// (loadActivities/performance.UserPerformance等)を再利用しているため、 +// 挙動はNode版と同一(詳細は docs/backend.md「スケジュール関数のGo移植」を参照)。 +package gofunctions + +import ( + "context" + "log" + "time" + + "cloud.google.com/go/firestore" + "github.com/GoogleCloudPlatform/functions-framework-go/functions" + cloudevents "github.com/cloudevents/sdk-go/v2" + "google.golang.org/api/iterator" + + "github.com/428lab/debug-shrine/functions-go/internal/performance" +) + +func init() { + functions.CloudEvent("StatusCacheBackfillGo", statusCacheBackfillHandler) +} + +const statusCacheBackfillMaxPerRun = 10 + +func statusCacheBackfillHandler(ctx context.Context, _ cloudevents.Event) error { + client, err := getFirestoreClient(ctx) + if err != nil { + log.Printf("statusCacheBackfill: getFirestoreClient error: %v", err) + return err + } + return runStatusCacheBackfill(ctx, client, time.Now()) +} + +type backfillUserDoc struct { + DisplayName string `firestore:"display_name"` + ScreenName string `firestore:"screen_name"` + ImagePath string `firestore:"image_path"` + Exp int64 `firestore:"exp"` + Status *firestoreStatus `firestore:"status"` + StatusVersion int64 `firestore:"status_version"` +} + +func runStatusCacheBackfill(ctx context.Context, client *firestore.Client, now time.Time) error { + // 既知の差異: Node版は moment().subtract(6,"months") を使っており、 + // 月末日(29〜31日)を起点にすると対象月の末日にクランプされる + // (例: 8/31 の6ヶ月前は 2/28)。Go の time.AddDate は日付をオーバーフロー + // させて翌月に繰り越す(例: 8/31 の6ヶ月前は 3/3 になり得る)。この差は + // カットオフ日が最大数日ずれるだけで、「直近6ヶ月アクティブなユーザーを + // 対象にする」というこのジョブの目的(荒い足切り)には実害が無いため、 + // 追加の補正は行わない。 + activeSince := now.AddDate(0, -6, 0) + iter := client.Collection("users").Where("last_sanpai", ">=", activeSince).Documents(ctx) + defer iter.Stop() + + processed := 0 + for { + if processed >= statusCacheBackfillMaxPerRun { + break + } + doc, err := iter.Next() + if err == iterator.Done { + break + } + if err != nil { + return err + } + + var u backfillUserDoc + if err := doc.DataTo(&u); err != nil { + return err + } + // status が未計算、または status_version が古い(旧ロジックで計算された) + // キャッシュを再計算対象にする。現行バージョンのキャッシュはスキップ。 + if statusCacheIsCurrent(u.Status, u.StatusVersion) { + continue + } + + activities, err := loadActivities(ctx, doc.Ref) + if err != nil { + return err + } + raw := performance.UserPerformance(activities, u.ScreenName) + formatted := performance.UserFormattedPerformance(raw, performance.AppendData{ + Exp: int(u.Exp), + User: performance.UserInfo{ + DisplayName: u.DisplayName, + ScreenName: u.ScreenName, + GithubImagePath: u.ImagePath, + }, + }) + lastActivityCreatedAt := performance.LatestActivityCreatedAt(activities) + + // Node版はここで status.last_sanpai を設定しない(フィールド自体が + // 存在しない状態でキャッシュされる)。Go版は toFirestoreStatus の都合上 + // last_sanpai="" を明示的に書き込むが、status/statusGo の読み出し側は + // どちらの場合も users/{id}.last_sanpai (トップレベル)の値で必ず + // 上書きするため、観測できる挙動に差は無い。 + if _, err := doc.Ref.Update(ctx, []firestore.Update{ + {Path: "status", Value: toFirestoreStatus(formatted, "")}, + {Path: "status_version", Value: performance.StatusLogicVersion}, + {Path: "last_activity_created_at", Value: lastActivityCreatedAt}, + }); err != nil { + return err + } + processed++ + log.Printf("statusCacheBackfill: backfilled status for %s", u.ScreenName) + } + log.Printf("statusCacheBackfill: done, processed=%d", processed) + return nil +} diff --git a/app/functions-go/status_cache_backfill_test.go b/app/functions-go/status_cache_backfill_test.go new file mode 100644 index 0000000..992ebd7 --- /dev/null +++ b/app/functions-go/status_cache_backfill_test.go @@ -0,0 +1,227 @@ +package gofunctions + +import ( + "context" + "testing" + "time" + + "github.com/428lab/debug-shrine/functions-go/internal/performance" +) + +func TestStatusCacheBackfill_BackfillsRecentlyActiveUsersWithoutStatus(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + + targetID := "TestStatusCacheBackfill_target" + if _, err := client.Collection("users").Doc(targetID).Set(ctx, map[string]interface{}{ + "display_name": "target user", + "screen_name": "backfill_target", + "image_path": "https://example.com/target.png", + "last_sanpai": now.AddDate(0, 0, -1), + }); err != nil { + t.Fatalf("failed to seed target user: %v", err) + } + if _, err := client.Collection("users").Doc(targetID).Collection("github_activities").Doc("1").Set(ctx, map[string]interface{}{ + "raw": `{"id":"1","type":"PushEvent","created_at":"2026-05-31T00:00:00Z","payload":{"commits":[{"sha":"a"}]}}`, + }); err != nil { + t.Fatalf("failed to seed activity: %v", err) + } + + alreadyCachedID := "TestStatusCacheBackfill_already_cached" + if _, err := client.Collection("users").Doc(alreadyCachedID).Set(ctx, map[string]interface{}{ + "display_name": "cached user", + "screen_name": "backfill_cached", + "last_sanpai": now.AddDate(0, 0, -1), + "status": map[string]interface{}{"total": int64(1)}, + "status_version": performance.StatusLogicVersion, + }); err != nil { + t.Fatalf("failed to seed already-cached user: %v", err) + } + + dormantID := "TestStatusCacheBackfill_dormant" + if _, err := client.Collection("users").Doc(dormantID).Set(ctx, map[string]interface{}{ + "display_name": "dormant user", + "screen_name": "backfill_dormant", + "last_sanpai": now.AddDate(0, -7, 0), + }); err != nil { + t.Fatalf("failed to seed dormant user: %v", err) + } + + if err := runStatusCacheBackfill(ctx, client, now); err != nil { + t.Fatalf("runStatusCacheBackfill: %v", err) + } + + targetDoc, err := client.Collection("users").Doc(targetID).Get(ctx) + if err != nil { + t.Fatalf("failed to read target user: %v", err) + } + var targetData backfillUserDoc + if err := targetDoc.DataTo(&targetData); err != nil { + t.Fatalf("DataTo: %v", err) + } + if targetData.Status == nil { + t.Fatal("target user should have status cached after backfill") + } + if targetData.Status.User.ScreenName != "backfill_target" { + t.Errorf("cached status.user.screen_name = %q, want backfill_target", targetData.Status.User.ScreenName) + } + + cachedDoc, err := client.Collection("users").Doc(alreadyCachedID).Get(ctx) + if err != nil { + t.Fatalf("failed to read already-cached user: %v", err) + } + var cachedData backfillUserDoc + if err := cachedDoc.DataTo(&cachedData); err != nil { + t.Fatalf("DataTo: %v", err) + } + if cachedData.Status == nil || cachedData.Status.Total != 1 { + t.Errorf("already-cached user's status should be untouched, got %+v", cachedData.Status) + } + + dormantDoc, err := client.Collection("users").Doc(dormantID).Get(ctx) + if err != nil { + t.Fatalf("failed to read dormant user: %v", err) + } + var dormantData backfillUserDoc + if err := dormantDoc.DataTo(&dormantData); err != nil { + t.Fatalf("DataTo: %v", err) + } + if dormantData.Status != nil { + t.Errorf("dormant user (last_sanpai > 6 months ago) should not be backfilled, got status=%+v", dormantData.Status) + } +} + +// TestStatusCacheBackfill_RecomputesOldVersionStatus は、status は保存済みだが +// status_version が現行(performance.StatusLogicVersion)より古いユーザーが +// 再計算され、現行バージョンが刻まれることを検証する(案A: バージョン印による +// 自己修復の要)。共有エミュレータの他ユーザーが1回あたり最大10件の枠を消費し得るため、 +// 「対象が現行バージョンになるまで」バックフィルを繰り返し実行して確認する。 +func TestStatusCacheBackfill_RecomputesOldVersionStatus(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + + oldVersionID := "TestStatusCacheBackfill_old_version" + if _, err := client.Collection("users").Doc(oldVersionID).Set(ctx, map[string]interface{}{ + "display_name": "old version user", + "screen_name": "backfill_oldver", + "image_path": "https://example.com/oldver.png", + "last_sanpai": now.AddDate(0, 0, -1), + // 旧ロジックで計算された(status_version フィールドが存在しない)stale なキャッシュ。 + "status": map[string]interface{}{"total": int64(99999)}, + }); err != nil { + t.Fatalf("failed to seed old-version user: %v", err) + } + if _, err := client.Collection("users").Doc(oldVersionID).Collection("github_activities").Doc("1").Set(ctx, map[string]interface{}{ + "raw": `{"id":"1","type":"PushEvent","created_at":"2026-05-31T00:00:00Z","payload":{"commits":[{"sha":"a"}]}}`, + }); err != nil { + t.Fatalf("failed to seed activity: %v", err) + } + + readOldVersion := func() backfillUserDoc { + doc, err := client.Collection("users").Doc(oldVersionID).Get(ctx) + if err != nil { + t.Fatalf("failed to read old-version user: %v", err) + } + var data backfillUserDoc + if err := doc.DataTo(&data); err != nil { + t.Fatalf("DataTo: %v", err) + } + return data + } + + // バックフィルは1回あたり最大10件しか処理しないため、共有エミュレータに + // 未処理(非現行)ユーザーが多数いると対象に届くまで複数回必要になる。 + // 処理は単調(処理済みは次回スキップ)なので、対象が現行になるまで繰り返す。 + // 無限ループ防止に十分大きな上限を設ける(実際は数回で収束する)。 + const maxRuns = 100 + i := 0 + for ; i < maxRuns && readOldVersion().StatusVersion < performance.StatusLogicVersion; i++ { + if err := runStatusCacheBackfill(ctx, client, now); err != nil { + t.Fatalf("runStatusCacheBackfill: %v", err) + } + } + if i >= maxRuns { + t.Fatalf("old-version user was not recomputed within %d backfill runs", maxRuns) + } + + got := readOldVersion() + if got.StatusVersion != performance.StatusLogicVersion { + t.Fatalf("old-version user status_version = %d, want %d (再計算されていない)", got.StatusVersion, performance.StatusLogicVersion) + } + if got.Status == nil { + t.Fatal("old-version user should have recomputed status") + } + // stale 値(99999)が現行ロジックの再計算結果で上書きされていること。 + if got.Status.Total == 99999 { + t.Error("old-version user's stale status.total was not recomputed") + } + if got.Status.User.ScreenName != "backfill_oldver" { + t.Errorf("recomputed status.user.screen_name = %q, want backfill_oldver", got.Status.User.ScreenName) + } +} + +// TestStatusCacheBackfill_RespectsMaxPerRunLimit は、対象ユーザーがMAX_PER_RUNを +// 超えて存在する場合に「1回の実行では処理しきらない(=次回に持ち越す)」ことを検証する。 +// 同一Firestoreを共有する他テストの影響を受けても壊れないよう、厳密な処理件数の +// 一致ではなく「1回目で全件は終わらない」「複数回実行すれば最終的に全件終わる」 +// という不変条件で検証する。 +func TestStatusCacheBackfill_RespectsMaxPerRunLimit(t *testing.T) { + client := emulatorClient(t) + ctx := context.Background() + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + + prefix := "TestStatusCacheBackfill_MaxPerRun_" + total := statusCacheBackfillMaxPerRun + 3 + ids := make([]string, total) + for i := 0; i < total; i++ { + id := prefix + string(rune('a'+i)) + ids[i] = id + if _, err := client.Collection("users").Doc(id).Set(ctx, map[string]interface{}{ + "display_name": "user " + id, + "screen_name": id, + "last_sanpai": now.AddDate(0, 0, -1), + }); err != nil { + t.Fatalf("failed to seed user %s: %v", id, err) + } + } + + countBackfilled := func() int { + n := 0 + for _, id := range ids { + doc, err := client.Collection("users").Doc(id).Get(ctx) + if err != nil { + t.Fatalf("failed to read user %s: %v", id, err) + } + var data backfillUserDoc + if err := doc.DataTo(&data); err != nil { + t.Fatalf("DataTo: %v", err) + } + if data.Status != nil { + n++ + } + } + return n + } + + if err := runStatusCacheBackfill(ctx, client, now); err != nil { + t.Fatalf("runStatusCacheBackfill (1st run): %v", err) + } + afterFirstRun := countBackfilled() + if afterFirstRun >= total { + t.Errorf("1回目の実行で%d件全て処理されてしまった(MAX_PER_RUN=%dの上限が効いていない)", total, statusCacheBackfillMaxPerRun) + } + + // 他ユーザーとの共有状態による揺れを許容しつつ、複数回実行すれば + // 最終的に全員処理されることを確認する(上限が処理漏れを起こしていないこと)。 + const maxRuns = 5 + for i := 0; i < maxRuns && countBackfilled() < total; i++ { + if err := runStatusCacheBackfill(ctx, client, now); err != nil { + t.Fatalf("runStatusCacheBackfill (retry): %v", err) + } + } + if got := countBackfilled(); got != total { + t.Errorf("複数回実行後の処理済み件数 = %d, want %d", got, total) + } +} diff --git a/app/functions-go/status_test.go b/app/functions-go/status_test.go new file mode 100644 index 0000000..39fdbfe --- /dev/null +++ b/app/functions-go/status_test.go @@ -0,0 +1,41 @@ +package gofunctions + +import ( + "testing" + "time" + + "github.com/428lab/debug-shrine/functions-go/internal/performance" +) + +func TestStatusCacheIsCurrent(t *testing.T) { + // status 未設定(nil)は常に「非現行」=要再計算。 + if statusCacheIsCurrent(nil, performance.StatusLogicVersion) { + t.Error("nil status should not be current") + } + // 旧キャッシュ(status_version フィールドが無く 0 として読まれる)は非現行。 + if statusCacheIsCurrent(&firestoreStatus{}, 0) { + t.Error("status with version 0 (old cache) should not be current") + } + // 現行バージョンと一致するキャッシュは現行(再計算不要)。 + if !statusCacheIsCurrent(&firestoreStatus{}, performance.StatusLogicVersion) { + t.Error("status at current version should be current") + } + // 将来バージョン(現行より新しい)は現行以上とみなし再計算しない。 + if !statusCacheIsCurrent(&firestoreStatus{}, performance.StatusLogicVersion+1) { + t.Error("status at a newer version should be treated as current") + } +} + +func TestFormatLastSanpai(t *testing.T) { + // last_sanpai 未設定(ゼロ値)の場合は未参拝の文言を返す + // (Node版が undefined.toDate() でクラッシュしていたケースの修正)。 + if got := formatLastSanpai(time.Time{}); got != "参拝していないようです" { + t.Errorf("formatLastSanpai(zero) = %q, want %q", got, "参拝していないようです") + } + + // 設定済みの場合は UTC の "YYYY年MM月DD日 HH:mm" 形式で返す。 + ts := time.Date(2024, 2, 3, 4, 5, 6, 0, time.UTC) + if got := formatLastSanpai(ts); got != "2024年02月03日 04:05" { + t.Errorf("formatLastSanpai(%v) = %q, want %q", ts, got, "2024年02月03日 04:05") + } +} diff --git a/app/functions-go/userogp.go b/app/functions-go/userogp.go new file mode 100644 index 0000000..29f5141 --- /dev/null +++ b/app/functions-go/userogp.go @@ -0,0 +1,250 @@ +// ユーザーOGP画像生成(userOGP)エンドポイントのGo実装。 +// +// Node版(app/functions/index.js の exports.userOGP / createOgp)からの移植。 +// コールドスタート短縮とfunction起動課金削減のため Go/Cloud Run functions として +// デプロイする(関数名は userOGPGo)。ogpRewriteGo の og:image をこの関数に向ける。 +// +// Node版との差分(意図的な改善): +// - ベース画像とフォント(Noto Sans JP)をバイナリに同梱(internal/ogpimage)し、 +// 実行時のGCSからの base.png ダウンロードを廃止。 +// - レーダーチャートは chartjs-node-canvas(外部プロセス)ではなく +// Goネイティブ描画(internal/ogpimage)で再現。 +// - 表示名/アイコンは GitHub API ではなく Firestore の display_name / image_path を +// 使用し、GitHub APIへの往復(レート制限リスク)を排除。 +// - 出力を PNG から WebP(可逆VP8L)へ変更しファイルサイズを削減。 +// キャッシュオブジェクトは ogps/{user}.webp。 +package gofunctions + +import ( + "context" + "errors" + "fmt" + "image" + "io" + "log" + "net/http" + "net/url" + "os" + "time" + + "cloud.google.com/go/firestore" + "cloud.google.com/go/storage" + "github.com/GoogleCloudPlatform/functions-framework-go/functions" + + "github.com/428lab/debug-shrine/functions-go/internal/ogpimage" + "github.com/428lab/debug-shrine/functions-go/internal/performance" +) + +func init() { + functions.HTTP("UserOGPGo", userOGPHandler) +} + +var userOGPHTTPClient = &http.Client{Timeout: 10 * time.Second} + +func userOGPHandler(w http.ResponseWriter, r *http.Request) { + setCORSHeaders(w, r) + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "GET,OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type,Authorization") + w.WriteHeader(http.StatusNoContent) + return + } + + username := r.URL.Query().Get("user") + if username == "" { + // Node版と同じく user 未指定は404。 + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("user not found.")) + return + } + + ctx := r.Context() + bucketName := os.Getenv("STORAGE_BUCKET_NAME") + if bucketName == "" { + log.Printf("userOGP: STORAGE_BUCKET_NAME is not set") + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + storageCli, err := getStorageClient(ctx) + if err != nil { + log.Printf("userOGP: getStorageClient error: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + bucket := storageCli.Bucket(bucketName) + objectPath := fmt.Sprintf("ogps/%s.webp", username) + + // 既存キャッシュがあればそのURLへ。無ければ生成してからURLを得る。 + exists, err := objectExists(ctx, bucket, objectPath) + if err != nil { + log.Printf("userOGP: objectExists error: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + if !exists { + status, ok, err := generateAndUploadOGP(ctx, bucket, objectPath, username) + if err != nil { + log.Printf("userOGP: generate error: %v", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + if !ok { + // 未登録ユーザー。 + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("user not found.")) + return + } + _ = status + } + + imageURL := ogpImageURL(bucketName, username) + if os.Getenv("FUNCTIONS_EMULATOR") != "" { + // エミュレーター上はリダイレクトせずURL文字列を返す(Node版と同じ挙動)。 + _, _ = io.WriteString(w, imageURL) + return + } + http.Redirect(w, r, imageURL, http.StatusFound) +} + +func objectExists(ctx context.Context, bucket *storage.BucketHandle, name string) (bool, error) { + _, err := bucket.Object(name).Attrs(ctx) + if errors.Is(err, storage.ErrObjectNotExist) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// generateAndUploadOGP はユーザーのOGP画像を生成しGCSへアップロードする。 +// 未登録ユーザーの場合は ok=false を返す。 +func generateAndUploadOGP(ctx context.Context, bucket *storage.BucketHandle, objectPath, screenName string) (performance.FormattedPerformance, bool, error) { + client, err := getFirestoreClient(ctx) + if err != nil { + return performance.FormattedPerformance{}, false, err + } + userDoc, err := findUserByScreenName(ctx, client, screenName) + if err != nil { + return performance.FormattedPerformance{}, false, err + } + if userDoc == nil { + return performance.FormattedPerformance{}, false, nil + } + + var userData userDocument + if err := userDoc.DataTo(&userData); err != nil { + return performance.FormattedPerformance{}, false, err + } + + formatted, err := resolveUserStatus(ctx, userDoc, userData, screenName) + if err != nil { + return performance.FormattedPerformance{}, false, err + } + + avatar, err := fetchAvatar(ctx, userData.ImagePath) + if err != nil { + return performance.FormattedPerformance{}, false, fmt.Errorf("fetch avatar: %w", err) + } + + displayName := userData.DisplayName + if displayName == "" { + displayName = screenName + } + + webp, err := ogpimage.EncodeWebP(ogpimage.Params{ + DisplayName: displayName, + Avatar: avatar, + Level: formatted.Level, + Points: formatted.Points, + Total: formatted.Total, + HP: formatted.HP, + Power: formatted.Power, + Intelligence: formatted.Intelligence, + Defence: formatted.Defence, + Agility: formatted.Agility, + }) + if err != nil { + return performance.FormattedPerformance{}, false, err + } + + if err := uploadObject(ctx, bucket, objectPath, "image/webp", webp); err != nil { + return performance.FormattedPerformance{}, false, err + } + return formatted, true, nil +} + +// resolveUserStatus は status キャッシュを version 判定つきで解決する。 +// 現行バージョンのキャッシュがあれば再利用し、無い/旧版なら再計算して書き戻す +// (status エンドポイントおよびNode版 userOGP と同一のキャッシュ運用)。 +func resolveUserStatus(ctx context.Context, userDoc *firestore.DocumentSnapshot, userData userDocument, screenName string) (performance.FormattedPerformance, error) { + if statusCacheIsCurrent(userData.Status, userData.StatusVersion) { + return fromFirestoreStatus(*userData.Status).FormattedPerformance, nil + } + + activities, err := loadActivities(ctx, userDoc.Ref) + if err != nil { + return performance.FormattedPerformance{}, err + } + raw := performance.UserPerformance(activities, screenName) + formatted := performance.UserFormattedPerformance(raw, performance.AppendData{ + Exp: int(userData.Exp), + User: performance.UserInfo{ + DisplayName: userData.DisplayName, + ScreenName: userData.ScreenName, + GithubImagePath: userData.ImagePath, + }, + }) + lastSanpai := formatLastSanpai(userData.LastSanpai) + if _, err := userDoc.Ref.Update(ctx, []firestore.Update{ + {Path: "status", Value: toFirestoreStatus(formatted, lastSanpai)}, + {Path: "status_version", Value: performance.StatusLogicVersion}, + }); err != nil { + return performance.FormattedPerformance{}, err + } + return formatted, nil +} + +func fetchAvatar(ctx context.Context, avatarURL string) (image.Image, error) { + if avatarURL == "" { + return nil, errors.New("empty avatar url") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, avatarURL, nil) + if err != nil { + return nil, err + } + resp, err := userOGPHTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("avatar fetch status %d", resp.StatusCode) + } + img, _, err := image.Decode(resp.Body) + if err != nil { + return nil, err + } + return img, nil +} + +func uploadObject(ctx context.Context, bucket *storage.BucketHandle, name, contentType string, data []byte) error { + wc := bucket.Object(name).NewWriter(ctx) + wc.ContentType = contentType + if _, err := wc.Write(data); err != nil { + _ = wc.Close() + return err + } + return wc.Close() +} + +// ogpImageURL は Firebase Storage のダウンロードURLを返す(Node版 getOgpUrl 相当、拡張子はwebp)。 +func ogpImageURL(bucketName, username string) string { + objectPath := "ogps%2F" + url.QueryEscape(username) + ".webp" + if host := os.Getenv("FIREBASE_STORAGE_EMULATOR_HOST"); host != "" && os.Getenv("FUNCTIONS_EMULATOR") != "" { + return fmt.Sprintf("http://%s/download/storage/v1/b/%s/o/%s?alt=media", host, bucketName, objectPath) + } + return fmt.Sprintf("https://firebasestorage.googleapis.com/v0/b/%s/o/%s?alt=media", bucketName, objectPath) +} diff --git a/app/functions/index.js b/app/functions/index.js index 7cb7b8a..9ed33da 100644 --- a/app/functions/index.js +++ b/app/functions/index.js @@ -11,6 +11,7 @@ const cors = require("cors")({ origin: true }) const { + STATUS_LOGIC_VERSION, get_level, user_performance, user_formatted_performance, @@ -19,6 +20,14 @@ const { compute_performance_increment } = require("./performance") +// status_cache_is_current は保存済み status キャッシュが現行の計算ロジックで作られたものか判定する。 +// status が無い、または status_version が現行(STATUS_LOGIC_VERSION)より古い場合は +// false(=再計算が必要)。旧キャッシュには status_version フィールドが無く undefined として +// 読まれるため自動的に再計算対象になる。Go版 statusCacheIsCurrent と同一のロジック。 +function status_cache_is_current(userData) { + return !!(userData && userData.status) && userData.status_version >= STATUS_LOGIC_VERSION +} + const projectID = process.env.GCLOUD_PROJECT const buggetName = `${projectID}.appspot.com` @@ -263,7 +272,9 @@ exports.statusCacheBackfill = functions.runWith({ for (const userDoc of snapshot.docs) { if (processed >= MAX_PER_RUN) break const userData = userDoc.data() - if (userData.status) continue + // status が未計算、または status_version が古い(旧ロジックで計算された) + // キャッシュを再計算対象にする。現行バージョンのキャッシュはスキップ。 + if (status_cache_is_current(userData)) continue const userRef = userDoc.ref const raw_activities_list = await get_activity_list(userRef) @@ -280,6 +291,7 @@ exports.statusCacheBackfill = functions.runWith({ const status = user_formatted_performance(user_data, appendData) await userRef.update({ status: status, + status_version: STATUS_LOGIC_VERSION, last_activity_created_at: latest_activity_created_at(raw_activities_list) }) processed++ @@ -323,16 +335,23 @@ exports.status = functions.https.onRequest(async (request, response) => { const userRef = await get_user_ref(db, userData.github_id, request.query.user) let return_Data - if (userData.status) { + // status_version が現行のキャッシュのみ即返却する。未計算または旧ロジックの + // キャッシュは下でフル再計算し、現行バージョンを刻んで書き戻す。 + if (status_cache_is_current(userData)) { return_Data = userData.status - return_Data.last_sanpai = moment(userData.last_sanpai.toDate()).format('YYYY年MM月DD日 HH:mm'); + // last_sanpai が未設定(未参拝でプロフィールを2回以上表示した場合など)だと + // undefined.toDate() で例外になるため、未参拝時は下のフル計算パスと同じ文言を返す。 + return_Data.last_sanpai = userData.last_sanpai + ? moment(userData.last_sanpai.toDate()).format('YYYY年MM月DD日 HH:mm') + : "参拝していないようです"; } else { const raw_activities_list = await get_activity_list(userRef) const user_data = user_performance(raw_activities_list, request.query.user) return_Data = user_formatted_performance(user_data, appendData) return_Data.last_sanpai = "参拝していないようです" await userRef.update({ - status: return_Data + status: return_Data, + status_version: STATUS_LOGIC_VERSION }) } @@ -461,13 +480,16 @@ async function createOgp(username, request, response) { const userRef = await get_user_ref(db, userData.id, username) let userFeedData - if (userData.status) { + // status_version が現行のキャッシュのみ再利用する。未計算または旧ロジックの + // キャッシュはフル再計算し、現行バージョンを刻んで書き戻す。 + if (status_cache_is_current(userData)) { return_Data = userData.status } else { const raw_activities_list = await get_activity_list(userRef) userFeedData = user_formatted_performance(user_performance(raw_activities_list, username), appendData) await userRef.update({ - status: userFeedData + status: userFeedData, + status_version: STATUS_LOGIC_VERSION }) } @@ -915,11 +937,15 @@ exports.sanpai = functions.https.onRequest(async(request, response) => { let raw_user_data let last_activity_created_at - if (userData.status && userData.last_activity_created_at) { + if (status_cache_is_current(userData) && userData.last_activity_created_at) { // 保存済みステータスに新着分だけを加算(全件再集計しない)。 // splited_items は「created_at > last_sanpai」で抽出した未集計イベントのみ、 // last_activity_created_at は累積済みイベントの最大時刻であり、 // compute_performance_increment の不変条件(新着は累積分より後)を満たす。 + // + // status_version が古いキャッシュ(旧ロジックで計算)を基準に増分すると + // 過去分の誤りを現行バージョンとして固定化してしまうため、その場合は + // この分岐に入らず下の全件再計算パスに落として基準ごと作り直す。 const base_user_data = raw_user_data_from_status(userData.status, userData.screen_name) const increment = compute_performance_increment(base_user_data, splited_items, userData.last_activity_created_at) raw_user_data = increment.user_data @@ -937,6 +963,7 @@ exports.sanpai = functions.https.onRequest(async(request, response) => { last_sanpai: FieldValue.serverTimestamp(), exp: FieldValue.increment(add_exp), status: userStatusData, + status_version: STATUS_LOGIC_VERSION, last_activity_created_at: last_activity_created_at }) diff --git a/app/functions/performance.js b/app/functions/performance.js index 6714356..2d7a5d7 100644 --- a/app/functions/performance.js +++ b/app/functions/performance.js @@ -3,6 +3,19 @@ // 副作用(Firestore/HTTP/認証)を持たず、ユニットテスト可能な単位として index.js から分離している。 const moment = require("moment") +// STATUS_LOGIC_VERSION は能力解析(performance)の計算ロジックのバージョン。 +// 計算式(加点テーブルや判定条件など、同一アクティビティに対する算出結果)を変えたら +// 必ずインクリメントすること。users/{id}.status_version に保存され、キャッシュ済み +// status がこのバージョン未満なら再計算対象になる(status/sanpai/statusCacheBackfill)。 +// +// 履歴: +// 1: IssuesEvent を payload.action で加点するよう修正(それ以前は常に未加点だった)。 +// 未設定(フィールドが存在しない旧キャッシュ)は undefined として扱われ再計算される。 +// +// Go版 (app/functions-go/internal/performance/performance.go) の StatusLogicVersion と +// 必ず一致させること。 +const STATUS_LOGIC_VERSION = 1 + const target_points = [0,5,11,19,30,45,65,91,124,166,218,281,357,447,553,676,818,981,1167,1378,1616,1884,2184,2519,2892,3306,3764,4269,4825,5436,6106,6840,7643,8520,9477,10520,11656,12892,14236,15696,17281,19001,20867,22891,25086,27466,30046,32842,35872,39156] function get_level(points) { @@ -78,7 +91,9 @@ function user_performance(items, username) { user_data.power += 3 break case "IssuesEvent": - switch (item.payload) { + // GitHub Events API の payload はオブジェクトで、開閉種別は payload.action + // ("opened"/"closed"/...) に入る。opened で intelligence+3, closed で defence+5。 + switch (item.payload && item.payload.action) { case "opened": user_data.intelligence += 3 break @@ -238,7 +253,9 @@ function compute_performance_increment(base_user_data, new_items, previous_creat user_data.power += 3 break case "IssuesEvent": - switch (item.payload) { + // GitHub Events API の payload はオブジェクトで、開閉種別は payload.action + // ("opened"/"closed"/...) に入る。opened で intelligence+3, closed で defence+5。 + switch (item.payload && item.payload.action) { case "opened": user_data.intelligence += 3 break @@ -269,6 +286,7 @@ function compute_performance_increment(base_user_data, new_items, previous_creat } module.exports = { + STATUS_LOGIC_VERSION, target_points, get_level, get_next_leve_exp, diff --git a/app/functions/test/performance.test.js b/app/functions/test/performance.test.js index 2d17c50..5d306fd 100644 --- a/app/functions/test/performance.test.js +++ b/app/functions/test/performance.test.js @@ -60,14 +60,19 @@ test("user_performance: 未対応イベントは加点しない", () => { assert.strictEqual(r.power + r.defence + r.intelligence + r.agility + r.hp, 0) }) -test("user_performance: IssuesEvent は payload が文字列の時のみ加点(既存仕様)", () => { - // 文字列 payload の場合のみ switch にマッチする - assert.strictEqual(user_performance([item("IssuesEvent", 1000, "opened")], "u").intelligence, 3) - assert.strictEqual(user_performance([item("IssuesEvent", 1000, "closed")], "u").defence, 5) - // GitHub API 実体のオブジェクト payload はマッチせず加点されない(既存挙動) - const objR = user_performance([item("IssuesEvent", 1000, { action: "opened" })], "u") - assert.strictEqual(objR.intelligence, 0) - assert.strictEqual(objR.defence, 0) +test("user_performance: IssuesEvent は payload.action で加点する", () => { + // GitHub Events API の payload は {action:"opened"} 等のオブジェクト。 + // payload.action を見て加点する(opened -> intelligence+3, closed -> defence+5)。 + assert.strictEqual(user_performance([item("IssuesEvent", 1000, { action: "opened" })], "u").intelligence, 3) + assert.strictEqual(user_performance([item("IssuesEvent", 1000, { action: "closed" })], "u").defence, 5) + // opened/closed 以外(reopened等)は加点されない + const reopened = user_performance([item("IssuesEvent", 1000, { action: "reopened" })], "u") + assert.strictEqual(reopened.intelligence, 0) + assert.strictEqual(reopened.defence, 0) + // payload がオブジェクトでない(文字列/null)場合は action を取れず加点されない + const strR = user_performance([item("IssuesEvent", 1000, "opened")], "u") + assert.strictEqual(strR.intelligence, 0) + assert.strictEqual(strR.defence, 0) }) // ============================================================ diff --git a/docs/backend.md b/docs/backend.md index 4f20c3d..901fc87 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -43,16 +43,295 @@ GitHubのアクティビティを取得して更新 `github_activities` を全件読み込んで `user_performance` でフル再計算し、結果を保存する (重い同期処理)。`sanpai` 成功時にも `status` と `last_activity_created_at` を更新する。 +### `IssuesEvent` 加点バグの修正(Go移植に合わせて Node版も修正) + +能力解析(`performance.js` / `internal/performance`)の `IssuesEvent` の加点は、 +移植前は `switch (item.payload)` のように payload そのものを文字列 +"opened"/"closed" と比較していた。しかし GitHub Events API の `payload` は +オブジェクトで、開閉種別は `payload.action`("opened"/"closed"/"reopened"/ +"labeled"/... の文字列)に入る(公式ドキュメント API version 2022-11-28、 +および実データで確認済み)。このため比較は決して一致せず、**Issue のオープン +(intelligence+3)・クローズ(defence+5)が一度も加点されていなかった**。 + +移植を機に `payload.action` を参照するよう Go/Node 両実装を修正した。影響として、 +Issue 活動のあるユーザーは intelligence/defence/total(戦闘力・ランキング)が +本来の値に増える。既にキャッシュ済みの `status` は下記 `status_version` の仕組みで +自動的に再計算される(データ破壊はしない)。 + +### 解析ロジックのバージョン管理と自己修復キャッシュ (`status_version`) + +上記のように `performance` の計算ロジックを修正すると、修正前に保存された `status` +キャッシュは古い(誤った)値のまま残る。これを再計算なしに放置すると誤った戦闘力・ +ランキングが表示され続けるため、キャッシュにロジックのバージョン印を持たせて自己修復する。 + +- 計算ロジックのバージョンを定数として持つ: + Go は `performance.StatusLogicVersion`、Node は `performance.js` の `STATUS_LOGIC_VERSION` + (両者は必ず一致させる)。**計算式を変えたら必ずインクリメントする。** + 現在は `1`(`IssuesEvent` 修正を含むロジック)。 +- `status` を書き込む処理(`status`/`sanpai`/`statusCacheBackfill`/`userOGP`。 + Go版は `statusGo`/`sanpaiGo`/`statusCacheBackfillGo`/`userOGPGo`)は + 同時にユーザードキュメントのトップレベル `status_version` に現行バージョンを刻む。 + `status` オブジェクト自体には含めない(API レスポンス形状は不変)。 +- キャッシュを使うか再計算するかの判定は全経路で共通ヘルパで行う + (Go: `statusCacheIsCurrent`、Node: `status_cache_is_current`)。 + `status` が存在し **かつ** `status_version >= 現行バージョン` のときのみキャッシュを再利用し、 + それ以外(未保存、または `status_version` が古い=フィールドが無く 0/undefined 扱い)は + フル再計算して現行バージョンを刻んで書き戻す。 +- `sanpai` の増分計算は基準キャッシュが現行バージョンのときだけ行う。古いバージョンの + キャッシュを基準に増分すると過去分の誤りを「現行バージョン」として固定化してしまうため、 + その場合は増分せず全件再計算して基準ごと作り直す。 +- 結果として、修正前の旧キャッシュは①参拝(`sanpai`)時、②マイページ/OGP 表示時、 + ③`statusCacheBackfill`(直近半年アクティブなユーザーを1実行10件ずつ)で順次 + 再計算され、破壊的操作(キャッシュ全削除など)なしに正しい値へ収束する。 + ### `statusCacheBackfill` (スケジュール関数) 過去に参拝済み(`last_sanpai` あり)だが解析キャッシュ(`status`)が未保存のレガシー ユーザーは、マイページ初回表示でフル再計算が走り遅くなる。これを事前解消するための スケジュール関数。 -- 対象: 直近半年以内に `last_sanpai`(参拝=活動)があり、かつ `status` を持たないユーザーのみ +- 対象: 直近半年以内に `last_sanpai`(参拝=活動)があり、かつ `status` が未保存 + **または `status_version` が現行より古い**ユーザー (`where("last_sanpai", ">=", 半年前)` で休眠ユーザーを除外し全件走査も回避) - 1 実行あたり最大 `MAX_PER_RUN` 件まで処理(タイムアウト回避のため上限あり) - 各対象ユーザーの解析を `status` エンドポイント/`sanpai` と同一ロジックで計算し、 - `status` と `last_activity_created_at` を追記更新する(既存データは削除しない) -- 冪等。全レガシーユーザーの埋め込み完了後はスキップのみで何もしない + `status`・`status_version`・`last_activity_created_at` を追記更新する(既存データは削除しない) +- 冪等。全ユーザーが現行バージョンで埋まった後はスキップのみで何もしない + +## `status` エンドポイントのGo移植 (`statusGo`) + +マイページ表示のレイテンシーをさらに削減するため、`status` エンドポイントを +Go(Cloud Run functions)で再実装し、`statusGo` という別関数名でデプロイしている +(実装は `app/functions-go/`、設計の詳細は同ディレクトリの README を参照)。 + +- Go は Node.js よりコールドスタートが大幅に短い(目安: Go 100〜300ms + vs Node.js 300〜800ms)。解析キャッシュ導入でウォーム時のレイテンシーは + 既に改善済みだが、コールドスタート自体はランタイム由来のオーバーヘッドが + 残るため、まずこの `status` エンドポイントから移植した。 +- `firebase-functions` SDK自体はGoを未サポートのため、Firebase Functionsとは + 別に `gcloud functions deploy --gen2 --runtime=go125` で同一GCPプロジェクトに + 直接デプロイしている(CI: `.github/workflows/dev-deploy.yml`)。 +- 既存の `status`(Node)とは意図的に別関数として共存させ、フロントエンドの + 呼び出し先切替タイミングを制御できるようにしている(切替・Node側削除は + 別途提案のうえ実施)。 +- Node版との入出力の等価性は、Firestoreエミュレータに同一データを投入し、 + 両ハンドラの応答を比較して確認済み。 + +### `last_sanpai` 未設定時のクラッシュバグの修正(Go/Node両方) + +`status` キャッシュ(`userData.status`)は存在するが `last_sanpai`(トップレベル)が +存在しないユーザー(一度も参拝せずプロフィールを2回以上表示した場合に発生し得る)に +対して、移植前のNode版は `undefined.toDate()` を呼び出して例外になるバグがあった。 +移植に合わせて、この場合は status 未保存時のフル計算パスと同じく +「参拝していないようです」を返すよう Go版・Node版の両方を修正した(未参拝ユーザーの +正しい表示。クラッシュしない)。 + +## `sanpai` エンドポイントのGo移植 (`sanpaiGo`) + +参拝処理(書き込み系のメインエンドポイント)についても `statusGo` と同じ方針で +Go(Cloud Run functions)に移植し、`sanpaiGo` という別関数名でデプロイしている +(実装は `app/functions-go/sanpai.go`)。挙動はNode版(`exports.sanpai`)と同一にする +ことを優先し、独自の改善は入れていない。 + +- Firebase IDトークンの検証には Firebase Admin Go SDK + (`firebase.google.com/go/v4/auth`)を使用する。`firebase-functions` の + `functions.config()` はGo未対応のため使えず、GitHub OAuth Appの + `client_id`/`client_secret` はデプロイ時に環境変数(`GITHUB_CLIENT_ID` / + `GITHUB_CLIENT_SECRET`)として注入する。値自体は新規のGitHub Secretsを + 増やさず、CI(`.github/workflows/dev-deploy.yml`)が + `firebase functions:config:get github` で既存の設定値を取得し、 + ログに出さないよう `::add-mask::` でマスクした上で + `gcloud functions deploy --set-env-vars` に渡している。 +- 参拝のクールダウン時間(Node版 `sanpai.next_time`: prod 300秒 / dev 60秒)も + 同様にプロジェクトIDでの分岐ではなく、環境変数 `SANPAI_NEXT_TIME_SECONDS` で + デプロイ時に明示的に指定する(dev: 60。prod移植時は300を設定すること)。 +- 増分計算(`compute_performance_increment`)・`raw_user_data_from_status`・ + `latest_activity_created_at` は `app/functions-go/internal/performance/` に + Node版と同一ロジックとしてポート済み。全件計算と増分計算が一致することは + プロパティテスト(`performance_test.go` の `TestIncrementEqualsFullCalculation_*`、 + 各2000/1000ケースをランダム生成して検証)で確認している。 +- Firestoreへの書き込み(アクティビティのバッチ登録、`sanpai_logs` 追記、 + `last_sanpai`/`exp`/`status`/`last_activity_created_at` の更新)は、Firestore + エミュレータ + モックGitHub APIサーバーを使ったGoテスト(`sanpai_test.go`。 + ローカルでは `FIRESTORE_EMULATOR_HOST` 未設定時は自動スキップされるが、 + CIでは `firebase emulators:exec` でFirestoreエミュレータを起動した状態で + `go test ./...` を実行しているためスキップされず検証される)で、 + 初回参拝・増分参拝・クールダウン中・新着なし・未登録の各分岐を確認済み。 + +### 既知の挙動(Node側からそのまま引き継いだ、あえて修正していない点) + +- **参拝可能ユーザーのなりすまし**: `sanpai` はFirebase IDトークンを検証するが、 + トークンの `uid` とリクエストボディの `github_id`/`screen_name` が一致するかは + Node版・Go版いずれもチェックしていない(有効なトークンさえあれば理論上は + 任意の`github_id`を指定できてしまう、既存の設計)。本移植のスコープ外につき + 修正していない。 +- **`exp` 表示キャッシュの非原子性**: `exp` フィールド自体は + `FieldValue.increment`(Go: `firestore.Increment`)で原子的に更新するが、 + キャッシュ用の `status.points` はハンドラ内で読み取った古い `exp` に + 加算した値を使っており、同一ユーザーからの並行リクエストがあると理論上 + わずかにズレ得る(Node版に元からある非トランザクションな実装をそのまま + 踏襲。修正しない)。 +- **GitHub取得失敗時のレスポンス**: GitHub Events APIの取得に失敗した場合、 + Node版は例外を握りつぶした `undefined` を後続処理に渡した結果、外側の + try/catchで `{"status":"missing server error."}` になる。Go版もこの場合に + 同じレスポンスを返すよう実装している(意図的な仕様ではなく結果的な挙動の + 踏襲)。 +- **リクエストボディが不正なJSONの場合のステータスコード**: Node版は + Expressのbody-parserがルートハンドラ本体(メソッド判定・認証チェックより + 前)で不正なJSONを検出し`400`を返す。Go版も`decodeJSONBody`ヘルパーで + 同じ順序(メソッド判定・認証チェックより前)にボディをパースし、失敗時に + `400`を返すことで挙動を揃えている(空ボディの場合はエラーにせず`{}`相当 + として扱う点もExpressのbody-parserと同じ)。 + +### 意図的に移植しなかった機能 + +- **期間限定ボーナス(`get_bonus_mag`/`msg`)**: Node版には「2022/1/1〜1/3は + ポイント3倍」という一度きりのキャンペーンロジックがある。判定基準の + `date_now` はNode側の実装上コールドスタート時刻に固定されるため、対象期間 + (2022年)を過ぎた現在は常に等倍(`bonus_mag=1`, `msg=""`)にしかならず、 + 将来にわたって再度trueになることもない。そのためGo版(`sanpaiGo`)では + 意図的に移植せず、`msg` は常に空文字を返す。仮に同種の期間限定キャンペーンを + 再度行う場合は、Go版にも該当ロジックを別途追加すること。 + +## `ranking` エンドポイントのGo移植 (`rankingGo`) + +ランキング表示(`app/functions-go/ranking.go`)をNode版(`exports.ranking`)と +同一の入出力になるよう移植している。`cache_data/ranking_cache` を読み、上位100件と +(指定があれば)自分の順位を返す。 + +- `latest_update` は Node版が Firestore の `Timestamp` オブジェクトをそのまま + `response.json()` した際の `{"_seconds":..., "_nanoseconds":...}` という形状を + 再現している(`@google-cloud/firestore` の `Timestamp` は `toJSON` を実装して + おらず、プライベートでない `_seconds`/`_nanoseconds` フィールドがそのまま + シリアライズされるため)。フロントエンド(`web/components/ranking.vue`)は + 現状この値を表示に使っていない。 +- `screen_name` 未指定、または指定した `screen_name` がランキングに存在しない + 場合は、Node版と同様に `my_rank` キー自体をレスポンスに含めない + (`omitempty`)。 +- `cache_data/ranking_cache` が未作成の場合(`rankingUpdate` スケジュール関数が + 一度も実行されていない状態)、Node版はここで例外化する処理を特にcatchして + いないため、Go版も同様に internal error(500)として扱う。ドキュメント自体は + 存在するが `ranking` フィールドが欠落している場合も同様に internal error + として扱う(Node版は`.slice()`呼び出しで例外化するのに対し、Go版が + `{"ranking":null}`を200で返すと壊れたキャッシュを黙って正常応答してしまう + ため、あえてエラーとして扱っている)。 + +## `register` エンドポイントのGo移植 (`registerGo`) + +ユーザー登録(`app/functions-go/register.go`)をNode版(`exports.register`)と +同一の入出力になるよう移植している。Firebase IDトークンを検証し、 +`users/{github_id}` が無ければ新規作成、あれば `auth_user_uid` の有無に応じて +`updated`/`registered` を返す。 + +- POST以外のメソッドは Node版と同じく `400 {"status":"missing request"}` を返す + (`sanpai`/`status` は同じ状況でも既定の200を返すため関数ごとに挙動が異なる点に注意。 + これはNode版に元からある不整合をそのまま踏襲したもの)。 + +## `ogpRewrite` エンドポイントのGo移植 (`ogpRewriteGo`) + +プロフィールページのOGPメタタグ書き換え(`app/functions-go/ogp_rewrite.go`)を +Node版(`exports.ogpRewrite`)と同一の入出力になるよう移植している。Firebase +Hostingの `/u/*` リライト経由で呼ばれ、SPAのビルド済みHTML(`base_url`)を取得して +OGP/Twitterカード用のメタタグを書き換える。 + +- `base_url`・`GCLOUD_PROJECT` はNode版では `functions.config().func.base_url` / + `process.env.GCLOUD_PROJECT` から取得しているが、Go版はデプロイ時に + 環境変数 `FUNC_BASE_URL` / `OGP_PROJECT_ID` として明示的に渡す + (`FUNC_BASE_URL` は `sanpaiGo` のGitHub資格情報と同様、CIが + `firebase functions:config:get func` から取得する)。 +- **既知の挙動(Node側の既存バグをそのまま踏襲)**: リクエストパスに `/u/` を + 含まない場合、Node版は `req_path.match("/u/(.+)")` が `null` を返した後の + 分岐で `null.length` を参照して例外化する(意図せず到達しないコード)。 + Firebase Hostingの `/u/*` リライト経由でのみ呼ばれる前提のため実運用では + 到達しないが、Go版もこの場合は正常系として扱わず internal error(500)を返す。 +- `og:image`/`twitter:image` は Go版のOGP画像生成関数 `userOGPGo` を指す + (下記「`userOGP` エンドポイントのGo移植」参照)。`userOGPGo` は WebP(1200×630)を + 返すため、`og:image` の直後に `og:image:type=image/webp` と + `og:image:width=1200`/`og:image:height=630` のメタタグを注入している。 + +## スケジュール関数のGo移植 + +`rankingUpdate`/`rankingCache`/`statusCacheBackfill`/`scheduledOgpDelete` は +いずれも Pub/Sub(Cloud Scheduler)トリガーで、ユーザーがブラウザで結果を +待つものではないためコールドスタート短縮による体感速度の改善効果は無いが、 +実行時間短縮による課金削減とコード基盤の統一を目的にGoへ移植した +(`rankingUpdateGo`/`rankingCacheGo`/`statusCacheBackfillGo`/`scheduledOgpDeleteGo`)。 +他のGo移植と同様、Node版とは別関数名・別Pub/Subトピック・別Cloud Schedulerジョブで +完全に独立してデプロイしており、安定稼働を確認してからNode版を停止する。 + +- **`rankingUpdateGo`**: 全ユーザーを `status.total` 降順で取得し、同点は同順位に + なるよう順位を付け直して `cache_data/ranking_cache` に書き込む。 + Node版は `orderBy` 済みの配列に対して `sort((a,b)=>b.battlePoint-a.battlePoint)` + を再度呼んでいるが、`battlePoint`(camelCase)は存在しないフィールド名の + タイプミスで常に `NaN` を返す比較関数になっており、V8の配列ソートの挙動上 + 実質的に無意味な処理(既存のFirestoreクエリ順をそのまま維持するno-op)に + なっている。Go版はこの無意味な再ソートを行わず、Firestoreクエリの結果順を + そのまま使う(観測できる出力はNode版と同一)。 + また `orderBy("status.total", ...)` はFirestoreの仕様上、対象フィールドを + 持たないドキュメント(status未計算のユーザー)を自動的に除外するため、 + Node版で起こり得る `.status.total` のundefined参照は発生しない。 +- **`rankingCacheGo`**: 全ユーザーのうち `point_ranking/{id}` が未作成のものに + 初期値(`rank:0`)を作成する。Node版は `snapshot.forEach(async (item) => {...})` + という非同期コールバックをawaitしないfire-and-forgetな実装になっており、 + 理論上は全ユーザー分の書き込みが完了する前に関数の実行が終了したとみなされ得る + (書き込み欠落のリスクがある)。これは意図された仕様ではなく実装上の不備と + 判断し、Go版では各ユーザーの処理を順番に確実に完了させる。書き込まれる + データの内容自体(既存ユーザーはスキップ、無いユーザーはrank:0で作成)は同一。 +- **`statusCacheBackfillGo`**: `status`/`statusGo` と同じ集計ロジック + (`loadActivities`/`performance.UserPerformance`等)を再利用しており、 + 直近6ヶ月以内に参拝したユーザーのうちstatus未計算のものを1回の実行につき + 最大10件だけ計算してキャッシュする、という挙動はNode版と同一。 +- **`scheduledOgpDeleteGo`**: Cloud Storageの `ogps/` プレフィックス配下の + ファイルを全て削除する。Node版は `bucket.deleteFiles({prefix:"ogps/"})` の + 戻り値をawait/returnしておらず、削除失敗時の挙動は実質的に不可視な + fire-and-forgetになっている(このジョブ自体は1時間毎に再実行される + 冪等なクリーンアップ処理のため実害は無い)。Go版は1ファイルの削除失敗を + ログに残しつつ他のファイルの削除は継続する(ジョブ全体を失敗させない)。 +- デプロイ方法(Pub/Subトリガー・Cloud Scheduler設定)は + `app/functions-go/README.md` を参照。 + +## `userOGP` エンドポイントのGo移植 (`userOGPGo`) + +OGP画像生成(`app/functions-go/userogp.go` + `internal/ogpimage`)を Go へ移植した +(`userOGPGo`)。Node版(`exports.userOGP` / `createOgp`)は `canvas` + `chartjs-node-canvas` +でベース画像にカードを合成していたが、Goネイティブ実装で再現している。画像描画ロジックは +再利用・単体テストしやすいよう `internal/ogpimage` パッケージに分離している +(パッケージ設計は `app/functions-go/internal/ogpimage/README.md` を参照)。 + +処理の流れは Node版と同じく「GCSに `ogps/{user}` のキャッシュがあればそのURLへ +リダイレクト、無ければ生成してアップロード後にリダイレクト」。生成時は +`screen_name` で Firestore を検索し、`status`(version判定つき)を解決してから +カードを合成する。 + +Node版との差分(意図的な改善): + +- **アセット同梱**: ベース画像(`base.png`, 2500×1313)と Noto Sans JP フォントを + `go:embed` でバイナリに同梱。Node版のように実行時にGCSから `base.png` を + ダウンロードする往復が不要になり高速化する。 +- **レーダーチャート**: `chartjs-node-canvas`(外部プロセス)ではなく Goネイティブ + 描画で再現(Chart.jsの radar 設定=5軸・min0/max150・stepSize10・色指定に対応)。 +- **表示名/アイコンの取得元**: Node版は GitHub API(`get_user`)から `name`/`avatar_url` + を取得していたが、Go版は Firestore の `display_name` / `image_path` を使用し、 + GitHub API への往復(レート制限リスク)を排除した。 +- **余白クロップ**: 元の `base.png` は上下左右に暗い背景の余白が広く、そのまま + OGPにするとカード=文字が小さく読みづらい。そこで **元解像度(2500×1313)で + レンダリング → カード領域(塗り実測bbox+小マージン)を OG比(1200:630)でクロップ + → 最後に一度だけ 1200×630 へ高品質縮小(CatmullRom)** する。縮小前にクロップ + することで再拡大による画質劣化を避けつつ、余白除去でカード/文字が相対的に + 大きくなる。カードの外接矩形は `base.png` から実測した定数で、差し替え時は + 再計測が必要(`internal/ogpimage/ogpimage.go` に注記)。 +- **出力形式**: PNG → **WebP(可逆VP8L, 純Go `nativewebp`)** に変更しファイルサイズを + 削減。キャッシュオブジェクトは `ogps/{user}.webp`(Content-Type: `image/webp`)。 + なお X(Twitter) は WebP の `og:image` でカードを生成しない場合があるため、 + X対応を優先する場合はエンコーダを差し替えてPNG出力に戻せる(変更は1箇所)。 +- キャッシュ運用(`status_version` による自己修復)は `status`/`statusGo` と同一。 +- デプロイ設定(メモリ512Mi・タイムアウト60s・`STORAGE_BUCKET_NAME`)は + `app/functions-go/README.md` / `.github/workflows/dev-deploy.yml` を参照。 + +### OGP画像のQC + +`internal/ogpimage` の `TestWriteQCArtifacts` は環境変数 `OGP_QC_OUT` が指すディレクトリに +サンプルOGP(PNG/WebP)を書き出す。CI(`dev-deploy`)ではこのテストを実行して生成物を +`actions/upload-artifact`(名前 `ogp-sample`)でアーティファクト化し、描画の破綻を +デプロイ前に目視確認できるようにしている。 diff --git a/web/components/ranking.vue b/web/components/ranking.vue index f3a45ca..cecb9a9 100644 --- a/web/components/ranking.vue +++ b/web/components/ranking.vue @@ -70,7 +70,9 @@ export default { if (this.isLogin) { params.screen_name = this.user.screen_name; } - let response = await this.$axios.get("/ranking", { params: params }); + // Go版(rankingGo)はコールドスタートが短くランキング表示が速くなるため + // 使用する(Node版のrankingとレスポンス形式は同一。docs/backend.md参照) + let response = await this.$axios.get("/rankingGo", { params: params }); this.ranking = response.data.ranking; this.myRanking = response.data.my_rank; this.latestUpdate = response.data.latest_update; diff --git a/web/pages/dashboard/index.vue b/web/pages/dashboard/index.vue index b1d4456..ae4e1b1 100644 --- a/web/pages/dashboard/index.vue +++ b/web/pages/dashboard/index.vue @@ -138,8 +138,10 @@ export default { }; }, async mounted() { + // Go版(statusGo)はコールドスタートが短くマイページ表示が速くなるため使用する + // (Node版のstatusとレスポンス形式は同一。docs/backend.md参照) let response = await this.$axios.get( - `status?user=${this.user.screen_name}` + `statusGo?user=${this.user.screen_name}` ); // 登録してなかったらエラーが出るのでエラー対応よろ let userChart = []; diff --git a/web/pages/index.vue b/web/pages/index.vue index 1221978..2bae3dd 100644 --- a/web/pages/index.vue +++ b/web/pages/index.vue @@ -178,7 +178,10 @@ export default { .currentUser.getIdToken() .then((token) => { this.$store.commit("setToken", token); - this.$axios.post("register", userData, { + // Go版(registerGo)はコールドスタートが短くログイン直後の登録が + // 速くなるため使用する(Node版のregisterとレスポンス形式は同一。 + // docs/backend.md参照) + this.$axios.post("registerGo", userData, { headers: { Authorization: `Bearer ${token}`, }, diff --git a/web/pages/sanpai.vue b/web/pages/sanpai.vue index d353776..5bd4788 100644 --- a/web/pages/sanpai.vue +++ b/web/pages/sanpai.vue @@ -192,7 +192,9 @@ export default { github_id: this.user.github_id, screen_name: this.user.screen_name, }; - let response = await this.$axios.post("sanpai", + // Go版(sanpaiGo)はコールドスタートが短く参拝処理が速くなるため使用する + // (Node版のsanpaiとレスポンス形式は同一。docs/backend.md参照) + let response = await this.$axios.post("sanpaiGo", payload, { headers: { diff --git a/web/pages/u/_userName.vue b/web/pages/u/_userName.vue index 9e636d0..7ed27b8 100644 --- a/web/pages/u/_userName.vue +++ b/web/pages/u/_userName.vue @@ -169,7 +169,9 @@ export default { this.$nuxt.error({ statusCode: 404 }); return; } - let response = await this.$axios.get("status?user=" + this.$route.params.userName); + // Go版(statusGo)はコールドスタートが短く表示が速くなるため使用する + // (Node版のstatusとレスポンス形式は同一。docs/backend.md参照) + let response = await this.$axios.get("statusGo?user=" + this.$route.params.userName); let userChart = []; userChart.push(response.data.chart.hp); userChart.push(response.data.chart.power);