From 52e8fe11c6dd7e9a7e15f8c4c44c50a3c83c5be5 Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 8 Oct 2026 12:51:39 +0700 Subject: [PATCH] feat(enakgame): filter play history by game and status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /customer/enakgame/sessions takes optional game_id and status, so a game reloaded mid-play finds the session it was running (status=STARTED) instead of starting a new one and charging EnakCoin again. An invalid game_id or status is refused. integration-enakgame.md §4.4 now describes recovery after a reload: keep the session_id in sessionStorage, continue a STARTED session before expires_at, and call complete again for a COMPLETED one to get the full answer, prize included. The mobile guide and RFC §11 mention the filters. Co-Authored-By: Claude Opus 5.5 --- docs/integration-enakgame.md | 46 ++++++++++++++++--- docs/integration-mobile-customer.md | 3 ++ docs/rfc-enakgame.md | 2 +- internal/handler/enakgame_customer_handler.go | 10 ++-- internal/models/enakgame.go | 10 ++++ internal/processor/enakgame_db_test.go | 26 ++++++++++- internal/processor/game_session_processor.go | 24 ++++++++-- .../repository/enakgame_repository_test.go | 2 +- .../repository/game_session_repository.go | 24 ++++++++-- internal/service/enakgame_service.go | 6 +-- 10 files changed, 129 insertions(+), 24 deletions(-) diff --git a/docs/integration-enakgame.md b/docs/integration-enakgame.md index 21236e4..5e611fb 100644 --- a/docs/integration-enakgame.md +++ b/docs/integration-enakgame.md @@ -30,7 +30,8 @@ Alasan di balik aturannya ada di [`rfc-enakgame.md`](./rfc-enakgame.md) dan selalu `reward_total` dari backend. 3. **Satu tap "Main" = satu `Idempotency-Key`.** Retry memakai key yang sama. 4. **Token customer adalah rahasia.** Hanya diterima lewat bridge, disimpan di memori, - tidak pernah ditaruh di URL, `localStorage`, cookie, log, atau analytics. + tidak pernah ditaruh di URL, `localStorage`, `sessionStorage`, cookie, log, atau + analytics. (`session_id` boleh disimpan di `sessionStorage`, §4.4.) 5. **Semua jumlah bilangan bulat.** Tidak ada pecahan EnakCoin. 6. **Main game tidak butuh PIN.** @@ -94,7 +95,8 @@ tunggu `token`, lalu ulangi request yang sama. ## 4. Alur satu kali main ``` -init ─► GET /customer/enakgame/games ─► tampilkan biaya (dan roda, untuk spin) +init ─► cek session yang masih berjalan (§4.4) + ─► GET /customer/enakgame/games ─► tampilkan biaya (dan roda, untuk spin) ─► tap Main ─► POST /customer/enakgame/sessions (EnakCoin dipotong) ─► permainan berjalan (batas waktu: expires_at) ─► POST /customer/enakgame/sessions/:id/complete (server menghitung hadiah) @@ -227,9 +229,13 @@ sepakati daftarnya dengan tim backoffice. | `310` | `score` bukan bilangan bulat atau `outcome` bukan string | Bug di game | | `404` | Session tidak ada / milik customer lain | Pesan umum | -### 4.4 Cek status — `GET /customer/enakgame/sessions/:id` +### 4.4 Pemulihan setelah reload -Untuk memulihkan keadaan, mis. game dimuat ulang saat session masih berjalan: +Webview bisa memuat ulang halaman game (aplikasi ke background, memori habis, crash) +saat customer sedang main. EnakCoin sudah terpotong, jadi game wajib menemukan lagi +session-nya. Dua endpoint dipakai: + +**Satu session** — `GET /customer/enakgame/sessions/:id` ```json { @@ -238,8 +244,35 @@ Untuk memulihkan keadaan, mis. game dimuat ulang saat session masih berjalan: } ``` -`status`: `STARTED`, `COMPLETED`, `REFUNDED`, atau `EXPIRED`. Riwayat main customer ada -di `GET /customer/enakgame/sessions?page=1&limit=20` (dipakai aplikasi, bukan game). +`status`: `STARTED`, `COMPLETED`, `REFUNDED`, atau `EXPIRED`. Response ini tidak memuat +`prize` atau rincian hadiah; untuk itu kirim ulang complete (lihat di bawah). + +**Mencari session** — `GET /customer/enakgame/sessions?game_id=8a1f…&status=STARTED&limit=1` + +Bentuk item sama dengan di atas, dibungkus `data` + `pagination`, terbaru di atas. +Semua query opsional: `game_id`, `status` (`STARTED`, `COMPLETED`, `REFUNDED`, +`EXPIRED`), `page`, `limit`. `status` atau `game_id` yang tidak valid ditolak `304`. + +**Alurnya, setiap kali menerima `init`:** + +1. Simpan `session_id` di **`sessionStorage`** setiap kali start berhasil, dan hapus + setelah hasilnya ditampilkan. `session_id` bukan rahasia; token tetap hanya di + memori (§1). +2. Bila ada `session_id` tersimpan, panggil `GET /sessions/:id`. Bila tidak ada (mis. + webview dibuka ulang dari awal), panggil + `GET /sessions?game_id=&status=STARTED&limit=1`. +3. Tindak lanjuti sesuai status: + +| Keadaan | Yang dilakukan game | +|---|---| +| `STARTED`, sekarang sebelum `expires_at` | **Lanjutkan** session itu: jangan start baru (EnakCoin akan terpotong lagi). Spin: langsung kirim complete `{}` dan tampilkan hasilnya. Game lain: progres main hilang, jadi mulai ulang permainan di session yang sama dengan timer sampai `expires_at`, lalu kirim complete | +| `STARTED`, `expires_at` sudah lewat | Anggap selesai. Server mengubahnya menjadi `EXPIRED` (atau merefund bila complete sebelumnya gagal karena error server) dalam ±1 menit. Tampilkan "Waktu bermain habis", lalu customer boleh start baru | +| `COMPLETED` | Hasil sudah dihitung tapi mungkin belum ditampilkan. Kirim ulang `POST /sessions/:id/complete` dengan body apa saja (`{}`): server mengembalikan jawaban yang sama persis, termasuk `prize`, tanpa hadiah dobel. Tampilkan hasilnya | +| `REFUNDED` | "EnakCoin kamu dikembalikan." | +| `EXPIRED` | "Waktu bermain habis." | +| Tidak ada session | Tampilkan layar awal seperti biasa | + +Riwayat main lengkap (tanpa filter) dipakai aplikasi customer, bukan game. --- @@ -289,6 +322,7 @@ saja. - [ ] Bridge sesuai kontrak §2 yang sudah disepakati dengan tim aplikasi. - [ ] Token hanya di memori; tidak ada di URL, storage, log, atau analytics. +- [ ] Pemulihan setelah reload (§4.4): session `STARTED` dilanjutkan, bukan start baru; `COMPLETED` ditampilkan lewat complete ulang. - [ ] Biaya main dan label event tampil sebelum main. - [ ] Satu `Idempotency-Key` per tap Main, dipakai ulang saat retry. - [ ] Complete hanya mengirim `score` / `outcome` / `data`, tidak pernah hadiah. diff --git a/docs/integration-mobile-customer.md b/docs/integration-mobile-customer.md index 9257448..e1ee629 100644 --- a/docs/integration-mobile-customer.md +++ b/docs/integration-mobile-customer.md @@ -616,6 +616,9 @@ kembali", lalu tutup. Tidak perlu mengirim pesan ke game. ### 8.4 Riwayat main — `GET /customer/enakgame/sessions?page=1&limit=20` +Query opsional `game_id` (riwayat satu game) dan `status` (`STARTED`, `COMPLETED`, +`REFUNDED`, `EXPIRED`) untuk filter atau tab. + ```json { "data": [ diff --git a/docs/rfc-enakgame.md b/docs/rfc-enakgame.md index ecebe2a..b2f8c8b 100644 --- a/docs/rfc-enakgame.md +++ b/docs/rfc-enakgame.md @@ -842,7 +842,7 @@ tambahkan snapshot harian, bukan cache yang di-invalidate. | `POST` | `/sessions` | §7.1. Wajib `Idempotency-Key` | | `POST` | `/sessions/:id/complete` | §7.2. Idempotent tanpa header | | `GET` | `/sessions/:id` | Status dan hasil | -| `GET` | `/sessions` | Riwayat main | +| `GET` | `/sessions` | Riwayat main; filter opsional `game_id`, `status` (dipakai game untuk menemukan session `STARTED` setelah reload) | Prefix `/enakgame` dipakai karena `/customer/games` sudah dipakai alur spin lama. diff --git a/internal/handler/enakgame_customer_handler.go b/internal/handler/enakgame_customer_handler.go index 1c40b12..9eafccb 100644 --- a/internal/handler/enakgame_customer_handler.go +++ b/internal/handler/enakgame_customer_handler.go @@ -50,16 +50,18 @@ func (h *EnakGameCustomerHandler) StartSession(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, h.service.StartSession(c.Request.Context(), customerID, &req, idempotencyKey(c)), method) } -// ListSessions is GET /customer/enakgame/sessions?page=&limit=. +// ListSessions is GET /customer/enakgame/sessions?game_id=&status=&page=&limit=. func (h *EnakGameCustomerHandler) ListSessions(c *gin.Context) { const method = "EnakGameCustomerHandler::ListSessions" customerID, ok := customerIDFromGin(c, method) if !ok { return } - page, _ := strconv.Atoi(c.Query("page")) - limit, _ := strconv.Atoi(c.Query("limit")) - util.HandleResponse(c.Writer, c.Request, h.service.ListSessions(c.Request.Context(), customerID, page, limit), method) + var q models.GameSessionListQuery + if !bindQuery(c, &q, method) { + return + } + util.HandleResponse(c.Writer, c.Request, h.service.ListSessions(c.Request.Context(), customerID, q), method) } // GetSession is GET /customer/enakgame/sessions/:id. diff --git a/internal/models/enakgame.go b/internal/models/enakgame.go index e342aef..aaa1207 100644 --- a/internal/models/enakgame.go +++ b/internal/models/enakgame.go @@ -189,6 +189,16 @@ type CustomerGameSession struct { RefundReason *string `json:"refund_reason"` } +// GameSessionListQuery filters a customer's play history. The game client uses +// game_id with status=STARTED to find the play it was running before a reload. +type GameSessionListQuery struct { + GameID string `form:"game_id"` + // STARTED, COMPLETED, REFUNDED or EXPIRED; empty for all. + Status string `form:"status"` + Page int `form:"page"` + Limit int `form:"limit"` +} + // GameSessionCompleteInput is what the client reports at the end of a play (§7.2): data // only. Anything else it sends, a reward amount above all, is ignored (P1). type GameSessionCompleteInput struct { diff --git a/internal/processor/enakgame_db_test.go b/internal/processor/enakgame_db_test.go index dab2f87..f66d514 100644 --- a/internal/processor/enakgame_db_test.go +++ b/internal/processor/enakgame_db_test.go @@ -675,12 +675,34 @@ func TestGameSession_CustomerReads(t *testing.T) { _, err = e.sessions.GetSession(ctx, e.bob, started.SessionID) assert.ErrorIs(t, err, repository.ErrGameSessionNotFound, "another customer's session") - page, err := e.sessions.ListSessions(ctx, e.alice, 1, 10) + page, err := e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{Page: 1, Limit: 10}) require.NoError(t, err) assert.EqualValues(t, 1, page.Pagination.Total) - page, err = e.sessions.ListSessions(ctx, e.bob, 1, 10) + page, err = e.sessions.ListSessions(ctx, e.bob, models.GameSessionListQuery{Page: 1, Limit: 10}) require.NoError(t, err) assert.Empty(t, page.Data) + + // After a reload, the game finds the play it was running by game and status. + other := e.playableGame(e.orgA, "other", 1) + otherStarted, err := e.sessions.Start(ctx, e.alice, other.ID, "k2") + require.NoError(t, err) + _, err = e.sessions.Complete(ctx, e.alice, otherStarted.SessionID, models.GameSessionCompleteInput{}) + require.NoError(t, err) + running, err := e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{GameID: game.ID.String(), Status: "started"}) + require.NoError(t, err) + require.Len(t, running.Data, 1) + assert.Equal(t, started.SessionID, running.Data[0].ID) + running, err = e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{GameID: other.ID.String(), Status: constants.GameSessionStatusStarted}) + require.NoError(t, err) + assert.Empty(t, running.Data, "the other game's play is completed") + all, err := e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{}) + require.NoError(t, err) + assert.EqualValues(t, 2, all.Pagination.Total) + + _, err = e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{Status: "PLAYING"}) + assert.ErrorIs(t, err, ErrGameSessionRejected) + _, err = e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{GameID: "runner"}) + assert.ErrorIs(t, err, ErrGameSessionRejected) } // gameWith makes an ACTIVE game of org A with the given result rules and an active diff --git a/internal/processor/game_session_processor.go b/internal/processor/game_session_processor.go index 387d6df..76f653c 100644 --- a/internal/processor/game_session_processor.go +++ b/internal/processor/game_session_processor.go @@ -419,10 +419,26 @@ func (p *GameSessionProcessor) ListGames(ctx context.Context, customerID uuid.UU return out, nil } -// ListSessions returns a page of the customer's sessions, newest first. -func (p *GameSessionProcessor) ListSessions(ctx context.Context, customerID uuid.UUID, page, limit int) (*models.PaginatedResponse[models.CustomerGameSession], error) { - page, limit = enakGamePage(page, limit) - sessions, total, err := p.sessions.ListCustomerSessions(ctx, customerID, (page-1)*limit, limit) +// ListSessions returns a page of the customer's sessions, newest first, of one game +// or status when asked. +func (p *GameSessionProcessor) ListSessions(ctx context.Context, customerID uuid.UUID, q models.GameSessionListQuery) (*models.PaginatedResponse[models.CustomerGameSession], error) { + page, limit := enakGamePage(q.Page, q.Limit) + filter := repository.CustomerSessionFilter{CustomerID: customerID, Offset: (page - 1) * limit, Limit: limit} + if s := strings.TrimSpace(q.GameID); s != "" { + id, err := uuid.Parse(s) + if err != nil { + return nil, gameSessionRejected("game_id must be a UUID") + } + filter.GameID = &id + } + switch status := strings.ToUpper(strings.TrimSpace(q.Status)); status { + case "", constants.GameSessionStatusStarted, constants.GameSessionStatusCompleted, + constants.GameSessionStatusRefunded, constants.GameSessionStatusExpired: + filter.Status = status + default: + return nil, gameSessionRejected("status must be STARTED, COMPLETED, REFUNDED or EXPIRED") + } + sessions, total, err := p.sessions.ListCustomerSessions(ctx, filter) if err != nil { return nil, err } diff --git a/internal/repository/enakgame_repository_test.go b/internal/repository/enakgame_repository_test.go index 8205dd9..8cddc89 100644 --- a/internal/repository/enakgame_repository_test.go +++ b/internal/repository/enakgame_repository_test.go @@ -287,7 +287,7 @@ func TestGameSessionRepository_ReadsAndMoves(t *testing.T) { assert.True(t, got.Flagged) assert.NotNil(t, got.CompletionFailedAt) - sessions, total, err := f.sessions.ListCustomerSessions(ctx, f.customer, 0, 1) + sessions, total, err := f.sessions.ListCustomerSessions(ctx, CustomerSessionFilter{CustomerID: f.customer, Limit: 1}) require.NoError(t, err) assert.EqualValues(t, 2, total) assert.Len(t, sessions, 1) diff --git a/internal/repository/game_session_repository.go b/internal/repository/game_session_repository.go index 5af53d7..e5bb062 100644 --- a/internal/repository/game_session_repository.go +++ b/internal/repository/game_session_repository.go @@ -50,7 +50,7 @@ type GameSessionRepository interface { GetSessionBySpendTransaction(ctx context.Context, spendTransactionID uuid.UUID) (*entities.GameSession, error) // ListCustomerSessions returns a page of a customer's sessions, newest first, and // the total. - ListCustomerSessions(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]entities.GameSession, int64, error) + ListCustomerSessions(ctx context.Context, filter CustomerSessionFilter) ([]entities.GameSession, int64, error) CompleteSession(ctx context.Context, id uuid.UUID, completion GameSessionCompletion) (bool, error) RefundSession(ctx context.Context, id, refundTransactionID uuid.UUID, reason string, endedAt time.Time) (bool, error) @@ -116,8 +116,26 @@ func (r *gameSessionRepository) GetSessionBySpendTransaction(ctx context.Context return r.first(DBFromContext(ctx, r.db).WithContext(ctx).Where("spend_transaction_id = ?", spendTransactionID)) } -func (r *gameSessionRepository) ListCustomerSessions(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]entities.GameSession, int64, error) { - q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GameSession{}).Where("customer_id = ?", customerID) +// CustomerSessionFilter selects a customer's sessions. +type CustomerSessionFilter struct { + CustomerID uuid.UUID + // Nil for every game. + GameID *uuid.UUID + // Empty for every status. + Status string + Offset int + Limit int +} + +func (r *gameSessionRepository) ListCustomerSessions(ctx context.Context, filter CustomerSessionFilter) ([]entities.GameSession, int64, error) { + q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GameSession{}).Where("customer_id = ?", filter.CustomerID) + if filter.GameID != nil { + q = q.Where("game_id = ?", *filter.GameID) + } + if filter.Status != "" { + q = q.Where("status = ?", filter.Status) + } + offset, limit := filter.Offset, filter.Limit var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("failed to count game sessions: %w", err) diff --git a/internal/service/enakgame_service.go b/internal/service/enakgame_service.go index b240d78..dbc2767 100644 --- a/internal/service/enakgame_service.go +++ b/internal/service/enakgame_service.go @@ -72,7 +72,7 @@ type EnakGameCustomerService interface { ListGames(ctx context.Context, customerID uuid.UUID) *contract.Response StartSession(ctx context.Context, customerID uuid.UUID, req *contract.StartGameSessionRequest, idempotencyKey string) *contract.Response CompleteSession(ctx context.Context, customerID, sessionID uuid.UUID, in models.GameSessionCompleteInput) *contract.Response - ListSessions(ctx context.Context, customerID uuid.UUID, page, limit int) *contract.Response + ListSessions(ctx context.Context, customerID uuid.UUID, q models.GameSessionListQuery) *contract.Response GetSession(ctx context.Context, customerID, sessionID uuid.UUID) *contract.Response ListVouchers(ctx context.Context, customerID uuid.UUID) *contract.Response @@ -371,8 +371,8 @@ func (s *EnakGameCustomerServiceImpl) CompleteSession(ctx context.Context, custo return respond(ctx, completion, err) } -func (s *EnakGameCustomerServiceImpl) ListSessions(ctx context.Context, customerID uuid.UUID, page, limit int) *contract.Response { - sessions, err := s.sessions.ListSessions(ctx, customerID, page, limit) +func (s *EnakGameCustomerServiceImpl) ListSessions(ctx context.Context, customerID uuid.UUID, q models.GameSessionListQuery) *contract.Response { + sessions, err := s.sessions.ListSessions(ctx, customerID, q) return respond(ctx, sessions, err) }