diff --git a/.github/workflows/dev-deploy.yml b/.github/workflows/dev-deploy.yml index 395680e..c728ecc 100644 --- a/.github/workflows/dev-deploy.yml +++ b/.github/workflows/dev-deploy.yml @@ -241,8 +241,6 @@ jobs: # 安定稼働を確認してからNode版を止める(詳細は docs/backend.md # 「スケジュール関数のGo移植」を参照)。 # - # --trigger-topic に指定したPub/SubトピックはCloud Functions側が - # 存在しなければ自動作成するため、事前のトピック作成手順は不要。 # Cloud SchedulerジョブはApp Engineのロケーションに紐づくため、 # 既存プロジェクトの設定値を動的に取得して使う。 - name: enable required GCP services for scheduled Go functions @@ -253,6 +251,18 @@ jobs: eventarc.googleapis.com \ --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 + # Cloud SchedulerジョブのlocationはプロジェクトのApp Engineアプリと # 同一でなければならない(App Engineが存在するプロジェクトでは、それ以外の # リージョンを指定すると `Location must equal ` エラーになる)。 diff --git a/app/functions-go/internal/performance/README.md b/app/functions-go/internal/performance/README.md index 5d3da3e..4cd1511 100644 --- a/app/functions-go/internal/performance/README.md +++ b/app/functions-go/internal/performance/README.md @@ -20,12 +20,27 @@ `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 判定は文字列との厳密等価のみ(GitHub実データの - オブジェクトpayloadとは一致しない、既存Node版の挙動をそのまま踏襲) +- `IssuesEvent` の加点は `payload.action`("opened"→intelligence+3 / + "closed"→defence+5)で判定する。GitHub Events API の payload はオブジェクトで + action フィールドに開閉種別が入るため(公式ドキュメント/実データで確認済み)。 + ※移植前のNode版は `payload`(オブジェクト)を文字列 "opened"/"closed" と + 直接比較しており、GitHub実データのオブジェクトpayloadとは決して一致しない + ため Issue のオープン/クローズが一切加点されないバグがあった。移植に合わせて + 両実装で修正済み。 - agility/hp の時間差バケット境界値 ## テスト diff --git a/app/functions-go/internal/performance/performance.go b/app/functions-go/internal/performance/performance.go index 0e7f209..9698e4e 100644 --- a/app/functions-go/internal/performance/performance.go +++ b/app/functions-go/internal/performance/performance.go @@ -12,6 +12,19 @@ import ( "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, @@ -52,18 +65,24 @@ func GetNextLevelExp(points int) NextLevelExp { } // Activity はGitHub Events APIの1イベント(Firestoreにキャッシュされた raw JSON)を表す。 -// Payload は文字列/オブジェクト/nullのいずれもあり得るため any として保持し、 -// PayloadEquals で型を厳密に比較する(Node版の switch(item.payload){case "opened":...} の -// 厳密等価(===)と同じ挙動: GitHub実データのオブジェクトpayloadは文字列と決して一致しない)。 +// 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"` } -func payloadEquals(payload any, s string) bool { - str, ok := payload.(string) - return ok && str == s +// 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 { @@ -133,9 +152,10 @@ func UserPerformance(items []Activity, username string) RawUserData { case "PullRequestEvent": data.Power += 3 case "IssuesEvent": - if payloadEquals(item.Payload, "opened") { + switch payloadAction(item.Payload) { + case "opened": data.Intelligence += 3 - } else if payloadEquals(item.Payload, "closed") { + case "closed": data.Defence += 5 } case "IssueCommentEvent": @@ -302,9 +322,10 @@ func ComputePerformanceIncrement(baseUserData RawUserData, newItems []Activity, case "PullRequestEvent": data.Power += 3 case "IssuesEvent": - if payloadEquals(item.Payload, "opened") { + switch payloadAction(item.Payload) { + case "opened": data.Intelligence += 3 - } else if payloadEquals(item.Payload, "closed") { + case "closed": data.Defence += 5 } case "IssueCommentEvent": diff --git a/app/functions-go/internal/performance/performance_test.go b/app/functions-go/internal/performance/performance_test.go index bd5e53e..8e81952 100644 --- a/app/functions-go/internal/performance/performance_test.go +++ b/app/functions-go/internal/performance/performance_test.go @@ -92,17 +92,21 @@ func TestUserPerformance_UnsupportedEvent(t *testing.T) { } func TestUserPerformance_IssuesEventPayload(t *testing.T) { - // 文字列 payload の場合のみ switch にマッチする(既存仕様) - if r := UserPerformance([]Activity{item("IssuesEvent", 1000, "opened")}, "u"); r.Intelligence != 3 { + // 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, "closed")}, "u"); r.Defence != 5 { + 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) } - // GitHub API 実体のオブジェクトpayloadはマッチせず加点されない(既存挙動) - objR := UserPerformance([]Activity{item("IssuesEvent", 1000, map[string]any{"action": "opened"})}, "u") - if objR.Intelligence != 0 || objR.Defence != 0 { - t.Errorf("object payload intelligence=%d defence=%d, want 0,0", objR.Intelligence, objR.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) } } diff --git a/app/functions-go/sanpai.go b/app/functions-go/sanpai.go index b930f37..142701f 100644 --- a/app/functions-go/sanpai.go +++ b/app/functions-go/sanpai.go @@ -267,6 +267,7 @@ type sanpaiUserDocument struct { LastSanpai time.Time `firestore:"last_sanpai"` LastActivityCreatedAt string `firestore:"last_activity_created_at"` Status *firestoreStatus `firestore:"status"` + StatusVersion int64 `firestore:"status_version"` } // runSanpai は参拝処理の本体。エラーを返した場合は呼び出し元で @@ -385,11 +386,15 @@ func runSanpai(ctx context.Context, w http.ResponseWriter, client *firestore.Cli var rawUserData performance.RawUserData var lastActivityCreatedAt string - if userData.Status != nil && userData.LastActivityCreatedAt != "" { + 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 @@ -417,6 +422,7 @@ func runSanpai(ctx context.Context, w http.ResponseWriter, client *firestore.Cli {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 diff --git a/app/functions-go/status.go b/app/functions-go/status.go index 687c395..057accc 100644 --- a/app/functions-go/status.go +++ b/app/functions-go/status.go @@ -51,12 +51,21 @@ func getFirestoreClient(ctx context.Context) (*firestore.Client, error) { // 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"` + 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 の @@ -200,7 +209,7 @@ func statusHandler(w http.ResponseWriter, r *http.Request) { return } - if userData.Status != nil { + if statusCacheIsCurrent(userData.Status, userData.StatusVersion) { resp := fromFirestoreStatus(*userData.Status) resp.LastSanpai = formatLastSanpai(userData.LastSanpai) writeJSON(w, http.StatusOK, resp) @@ -227,6 +236,7 @@ func statusHandler(w http.ResponseWriter, r *http.Request) { 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") @@ -284,15 +294,14 @@ func loadActivities(ctx context.Context, userRef *firestore.DocumentRef) ([]perf // formatLastSanpai は Node版の moment(...).format('YYYY年MM月DD日 HH:mm') と同一の文字列を返す。 // Cloud Functions の実行環境はデフォルトタイムゾーンがUTCであるため、UTCとして整形する。 // -// 注意(既知のNode側の挙動との差異): last_sanpai(トップレベル)が存在しない状態で -// status キャッシュだけが存在するユーザー(一度もsanpaiせずプロフィールを2回以上 -// 見ると発生し得る)に対して、Node版はここで `undefined.toDate()` を呼び出して -// 例外になる(未検証の既存バグ、本移植の対象外につき修正しない)。 -// Goでは time.Time のゼロ値を安全に扱えるため、この場合は空文字を返す -// (クラッシュしないという意味で安全側だが、意図的な仕様変更ではない)。 +// last_sanpai(トップレベル)が存在しない状態で status キャッシュだけが存在するユーザー +// (一度もsanpaiせずプロフィールを2回以上見ると発生し得る)に対しては、status未保存時の +// フル計算パスと同じく「参拝していないようです」を返す(未参拝ユーザーの正しい表示)。 +// ※移植前のNode版はこの場合 `undefined.toDate()` を呼び出して例外になるバグがあった。 +// 本移植に合わせてNode版も同じく修正済み。 func formatLastSanpai(t time.Time) string { if t.IsZero() { - return "" + 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 index 20f2990..e15fa48 100644 --- a/app/functions-go/status_cache_backfill.go +++ b/app/functions-go/status_cache_backfill.go @@ -38,11 +38,12 @@ func statusCacheBackfillHandler(ctx context.Context, _ cloudevents.Event) error } 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"` + 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 { @@ -74,7 +75,9 @@ func runStatusCacheBackfill(ctx context.Context, client *firestore.Client, now t if err := doc.DataTo(&u); err != nil { return err } - if u.Status != nil { + // status が未計算、または status_version が古い(旧ロジックで計算された) + // キャッシュを再計算対象にする。現行バージョンのキャッシュはスキップ。 + if statusCacheIsCurrent(u.Status, u.StatusVersion) { continue } @@ -100,6 +103,7 @@ func runStatusCacheBackfill(ctx context.Context, client *firestore.Client, now t // 上書きするため、観測できる挙動に差は無い。 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 diff --git a/app/functions-go/status_cache_backfill_test.go b/app/functions-go/status_cache_backfill_test.go index 1b8c37a..992ebd7 100644 --- a/app/functions-go/status_cache_backfill_test.go +++ b/app/functions-go/status_cache_backfill_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" "time" + + "github.com/428lab/debug-shrine/functions-go/internal/performance" ) func TestStatusCacheBackfill_BackfillsRecentlyActiveUsersWithoutStatus(t *testing.T) { @@ -28,10 +30,11 @@ func TestStatusCacheBackfill_BackfillsRecentlyActiveUsersWithoutStatus(t *testin 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)}, + "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) } @@ -89,6 +92,76 @@ func TestStatusCacheBackfill_BackfillsRecentlyActiveUsersWithoutStatus(t *testin } } +// 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を共有する他テストの影響を受けても壊れないよう、厳密な処理件数の 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/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 cdf75e2..8ce9a49 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -43,18 +43,59 @@ 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`、および Node の `userOGP`)は + 同時にユーザードキュメントのトップレベル `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`) @@ -75,13 +116,14 @@ Go(Cloud Run functions)で再実装し、`statusGo` という別関数名でデ - Node版との入出力の等価性は、Firestoreエミュレータに同一データを投入し、 両ハンドラの応答を比較して確認済み。 -### 既知の挙動差異(Node側の未修正バグに起因) +### `last_sanpai` 未設定時のクラッシュバグの修正(Go/Node両方) `status` キャッシュ(`userData.status`)は存在するが `last_sanpai`(トップレベル)が 存在しないユーザー(一度も参拝せずプロフィールを2回以上表示した場合に発生し得る)に -対して、Node版は `undefined.toDate()` を呼び出して例外になる既存バグがある -(本移植の対象外につき、Node側は修正していない)。Go版はこの場合 `last_sanpai` を -空文字として返す(クラッシュしない)。 +対して、移植前のNode版は `undefined.toDate()` を呼び出して例外になるバグがあった。 +移植に合わせて、この場合は status 未保存時のフル計算パスと同じく +「参拝していないようです」を返すよう Go版・Node版の両方を修正した(未参拝ユーザーの +正しい表示。クラッシュしない)。 ## `sanpai` エンドポイントのGo移植 (`sanpaiGo`)