Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/dev-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <app-engine-location>` エラーになる)。
Expand Down
19 changes: 17 additions & 2 deletions app/functions-go/internal/performance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 の時間差バケット境界値

## テスト
Expand Down
41 changes: 31 additions & 10 deletions app/functions-go/internal/performance/performance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down
18 changes: 11 additions & 7 deletions app/functions-go/internal/performance/performance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
8 changes: 7 additions & 1 deletion app/functions-go/sanpai.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 は参拝処理の本体。エラーを返した場合は呼び出し元で
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
37 changes: 23 additions & 14 deletions app/functions-go/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 の
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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")
}
16 changes: 10 additions & 6 deletions app/functions-go/status_cache_backfill.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand All @@ -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
Expand Down
Loading
Loading