Merge pull request 'feat(enakgame): weekly leaderboard' (#47) from feat/enakgame into staging

Reviewed-on: https://gits.altru.id/apksel-dev/apskel-pos-backend/pulls/47
This commit was merged in pull request #47.
This commit is contained in:
2026-10-12 11:19:18 +02:00
10 changed files with 398 additions and 4 deletions
+47 -3
View File
@@ -1,6 +1,6 @@
# Integrasi EnakGame: Game Client (Phaser)
**Untuk:** tim game EnakGame (client Phaser) · **Base URL:** `/api/v1` · **Per:** 9 Okt 2026
**Untuk:** tim game EnakGame (client Phaser) · **Base URL:** `/api/v1` · **Per:** 12 Okt 2026
Kamu mengerjakan **game EnakGame**: game web (Phaser) yang dibuka aplikasi customer di
dalam webview dari `game_url` sebuah game. Game inilah yang menjalankan satu kali main
@@ -13,7 +13,7 @@ Pembagian tugas dengan aplikasi customer:
| Aplikasi customer ([`integration-mobile-customer.md`](./integration-mobile-customer.md)) | Game EnakGame (dokumen ini) |
|---|---|
| Login customer, menyimpan token | Menerima token dari aplikasi lewat bridge (§4); bila tidak ada, meminta customer login (§5) |
| Daftar game, membuka `game_url` di webview | Start session, main, complete, tampilkan hadiah |
| Daftar game, membuka `game_url` di webview | Start session, main, complete, tampilkan hadiah, leaderboard mingguan (§10) |
| Saldo, riwayat, voucher, PIN | Memberi tahu aplikasi saat saldo berubah atau game ditutup |
Alasan di balik aturannya ada di [`rfc-enakgame.md`](./rfc-enakgame.md) dan
@@ -106,6 +106,7 @@ memakai header `Authorization: Bearer <token>`. Request ber-body memakai
| 3 | `POST /customer/enakgame/sessions/:id/complete` | – | `{ "score"?, "outcome"?, "data"? }` | Permainan selesai | §6.3 |
| 4 | `GET /customer/enakgame/sessions/:id` | – | – | Pemulihan setelah reload, bila `session_id` tersimpan | §6.4 |
| 5 | `GET /customer/enakgame/sessions?game_id=&status=&page=&limit=` | – | – | Pemulihan setelah reload, bila `session_id` tidak tersimpan | §6.4 |
| 6 | `GET /customer/enakgame/leaderboard?date=&game_id=&limit=` | – | – | Layar leaderboard; muat ulang setelah complete | §10 |
Game tidak memanggil endpoint lain. Registrasi, saldo, riwayat, voucher, dan PIN
adalah tugas aplikasi customer.
@@ -601,7 +602,49 @@ saja.
---
## 10. Checklist
## 10. Leaderboard mingguan — `GET /customer/enakgame/leaderboard`
Peringkat customer satu organisasi menurut EnakCoin yang dimenangkan dari game dalam
satu minggu, Senin 00:00 sampai Minggu 23:59 WIB. Yang dihitung hanya session
`COMPLETED`, menurut minggu saat session dimulai; entry cost tidak mengurangi. Total
yang sama diurutkan menurut siapa yang mencapainya lebih dulu, jadi tidak ada peringkat
kembar.
| Query | Default | Keterangan |
|---|---|---|
| `date` | Minggu ini | Hari mana pun dalam minggu yang diminta, `YYYY-MM-DD`. Untuk minggu lalu, kirim tanggal 7 hari sebelum `week_start` minggu ini |
| `game_id` | Semua game | Leaderboard satu game, mis. `game_id` dari `init` untuk "peringkat game ini" |
| `limit` | 10 | Jumlah peringkat teratas, maks 100 |
```json
{
"week_start": "2026-10-12",
"week_end": "2026-10-18",
"game_id": null,
"entries": [
{ "rank": 1, "name": "Budi S******", "coins": 120, "is_me": false },
{ "rank": 2, "name": "Siti N** A*****", "coins": 90, "is_me": true }
],
"me": { "rank": 2, "name": "Siti N** A*****", "coins": 90, "is_me": true }
}
```
- Nama customer lain sudah disamarkan server: kata pertama utuh, kata berikutnya hanya
huruf pertama. Tampilkan apa adanya; untuk baris `is_me: true` tampilkan "Kamu".
- `me` adalah posisi customer yang login, juga bila di luar `limit`: tampilkan sebagai
baris tersendiri di bawah daftar. `null` bila belum menang EnakCoin minggu itu
("Main untuk masuk leaderboard").
- `entries` kosong (`[]`, tidak pernah `null`) bila belum ada yang menang minggu itu.
- Tampilkan rentang minggu dari `week_start`–`week_end`; jangan menghitung minggu
sendiri dari jam perangkat.
- Leaderboard tidak berubah sendiri. Muat ulang setelah complete berhasil, atau saat
layar leaderboard dibuka.
- `date` yang bukan `YYYY-MM-DD` atau `game_id` yang bukan UUID ditolak `304` (bug di
game; pesan umum).
---
## 11. Checklist
- [ ] Game baru sudah didaftarkan bersama tim backoffice (§2.3); `slug` diperiksa saat `init` (§6.1).
- [ ] Body complete sesuai jenis hadiah game (§2.2).
@@ -616,5 +659,6 @@ saja.
- [ ] Complete hanya mengirim `score` / `outcome` / `data`, tidak pernah hadiah.
- [ ] Hadiah di layar dari `reward_total`; `limited_by` dan `REFUNDED` ditangani.
- [ ] Spin berhenti di `prize.entry`.
- [ ] Leaderboard (§10): rentang minggu dari `week_start`/`week_end`, baris `is_me` sebagai "Kamu", `me` di luar `limit` tampil di bawah daftar, dimuat ulang setelah complete.
- [ ] Complete diulang dengan aman saat gagal; timeout `expires_at` ditangani.
- [ ] `balance_changed` dikirim setelah start dan complete; `close` saat keluar.
+3 -1
View File
@@ -843,6 +843,7 @@ tambahkan snapshot harian, bukan cache yang di-invalidate.
| `POST` | `/sessions/:id/complete` | §7.2. Idempotent tanpa header |
| `GET` | `/sessions/:id` | Status dan hasil |
| `GET` | `/sessions` | Riwayat main; filter opsional `game_id`, `status` (dipakai game untuk menemukan session `STARTED` setelah reload) |
| `GET` | `/leaderboard` | Peringkat mingguan (Senin–Minggu, Asia/Jakarta) org customer menurut EnakCoin yang dimenangkan dari game. Query opsional `date` (hari mana pun dalam minggu itu), `game_id`, `limit` (default 10, maks 100) |
Prefix `/enakgame` dipakai karena `/customer/games` sudah dipakai alur spin lama.
@@ -940,7 +941,8 @@ Ditemukan saat memetakan code. Tidak memblokir RFC ini, tapi sebagian berdampak
## 16. Di Luar Scope
- **Mission** (PRD §3.1, §19). PRD belum mendefinisikan aturannya.
- **Leaderboard** (PRD §15).
- **Leaderboard event** (PRD §15). Leaderboard mingguan umum sudah ada (§11, ditambahkan
2026-10-12); leaderboard per event dengan aturannya sendiri belum.
- **Reward `TIERED`** (§8).
- **Budget Controller automatic mode** (D9).
- **Hosting dan build Phaser.** RFC ini hanya mendefinisikan API yang dipanggil game.
@@ -78,6 +78,20 @@ func (h *EnakGameCustomerHandler) GetSession(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, h.service.GetSession(c.Request.Context(), customerID, id), method)
}
// Leaderboard is GET /customer/enakgame/leaderboard?date=&game_id=&limit=.
func (h *EnakGameCustomerHandler) Leaderboard(c *gin.Context) {
const method = "EnakGameCustomerHandler::Leaderboard"
customerID, ok := customerIDFromGin(c, method)
if !ok {
return
}
var q models.EnakGameLeaderboardQuery
if !bindQuery(c, &q, method) {
return
}
util.HandleResponse(c.Writer, c.Request, h.service.Leaderboard(c.Request.Context(), customerID, q), method)
}
// CompleteSession is POST /customer/enakgame/sessions/:id/complete. The body is read
// leniently on purpose: a reward amount, or anything else the backend does not take
// from the client, is ignored rather than refused (P1). An empty body is a result
+28
View File
@@ -199,6 +199,34 @@ type GameSessionListQuery struct {
Limit int `form:"limit"`
}
// EnakGameLeaderboardQuery picks a week of the leaderboard, and optionally one game.
type EnakGameLeaderboardQuery struct {
// Any day of the week, YYYY-MM-DD in Asia/Jakarta; this week when empty.
Date string `form:"date"`
GameID string `form:"game_id"`
Limit int `form:"limit"`
}
// EnakGameLeaderboard ranks the customers of an organization by the EnakCoin they won
// from games in one week, Monday to Sunday in Asia/Jakarta.
type EnakGameLeaderboard struct {
WeekStart string `json:"week_start"`
WeekEnd string `json:"week_end"`
GameID *uuid.UUID `json:"game_id"`
Entries []EnakGameLeaderboardEntry `json:"entries"`
// The customer asking, wherever they rank; null when they won nothing that week.
Me *EnakGameLeaderboardEntry `json:"me"`
}
// EnakGameLeaderboardEntry is one customer on the leaderboard. Other customers are
// shown by a masked name only.
type EnakGameLeaderboardEntry struct {
Rank int `json:"rank"`
Name string `json:"name"`
Coins int64 `json:"coins"`
IsMe bool `json:"is_me"`
}
// 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 {
+98
View File
@@ -0,0 +1,98 @@
package processor
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
const (
// leaderboardLimit is how many ranks a leaderboard shows unless asked, and
// leaderboardMaxLimit the most it shows.
leaderboardLimit = 10
leaderboardMaxLimit = 100
)
// Leaderboard ranks the customers of the customer's organization by the EnakCoin they
// won from games in one week, Monday to Sunday in Asia/Jakarta: this week, or the one
// holding q.Date. A session counts in the week it started. Refunded, expired and
// flagged sessions count for nothing.
func (p *GameSessionProcessor) Leaderboard(ctx context.Context, customerID uuid.UUID, q models.EnakGameLeaderboardQuery) (*models.EnakGameLeaderboard, error) {
day := walletDay(p.now())
if s := strings.TrimSpace(q.Date); s != "" {
d, err := time.ParseInLocation("2006-01-02", s, walletDisplayLocation)
if err != nil {
return nil, gameSessionRejected("date must be YYYY-MM-DD")
}
day = d
}
weekStart := leaderboardWeekStart(day)
filter := repository.LeaderboardFilter{
CustomerID: customerID, From: weekStart, To: weekStart.AddDate(0, 0, 7), Limit: q.Limit,
}
if filter.Limit < 1 || filter.Limit > leaderboardMaxLimit {
filter.Limit = leaderboardLimit
}
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
}
customer, err := p.customers.GetCustomer(ctx, customerID)
if err != nil {
return nil, err
}
filter.OrganizationID = customer.OrganizationID
rows, err := p.sessions.Leaderboard(ctx, filter)
if err != nil {
return nil, err
}
board := &models.EnakGameLeaderboard{
WeekStart: weekStart.Format("2006-01-02"), WeekEnd: weekStart.AddDate(0, 0, 6).Format("2006-01-02"),
GameID: filter.GameID, Entries: []models.EnakGameLeaderboardEntry{},
}
for _, row := range rows {
entry := models.EnakGameLeaderboardEntry{
Rank: row.Rank, Name: maskCustomerName(row.Name), Coins: row.Coins, IsMe: row.CustomerID == customerID,
}
if row.Rank <= filter.Limit {
board.Entries = append(board.Entries, entry)
}
if entry.IsMe {
me := entry
board.Me = &me
}
}
return board, nil
}
// leaderboardWeekStart is the Monday, at midnight in Asia/Jakarta, of the week of day.
func leaderboardWeekStart(day time.Time) time.Time {
day = walletDay(day)
return day.AddDate(0, 0, -((int(day.Weekday()) + 6) % 7))
}
// maskCustomerName keeps the first word of a name and the first letter of every other
// word: "Budi Santoso" is shown as "Budi S******". A word with a digit, as when a phone
// number was used for the name, keeps only its first character, the first word too.
func maskCustomerName(name string) string {
words := strings.Fields(name)
if len(words) == 0 {
return "Pemain"
}
for i, w := range words {
if i == 0 && !strings.ContainsAny(w, "0123456789") {
continue
}
r := []rune(w)
words[i] = string(r[0]) + strings.Repeat("*", len(r)-1)
}
return strings.Join(words, " ")
}
@@ -0,0 +1,95 @@
package processor
import (
"fmt"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/models"
)
// The weekly leaderboard against Postgres. Needs TEST_DATABASE_URL; see
// internal/repository/wallet_repository_test.go.
func TestGameSession_Leaderboard(t *testing.T) {
e := newEnakGameEnv(t)
ctx := e.ctx()
dave := uuid.New()
require.NoError(t, e.db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Dave Lee')`, dave, e.orgA).Error)
runner := e.playableGame(e.orgA, "runner", 1)
other := e.playableGame(e.orgA, "other", 1)
theirs := e.playableGame(e.orgB, "theirs", 1)
for _, c := range []uuid.UUID{e.alice, e.bob, e.carol, dave} {
e.coins(c, 100, nil)
}
n := 0
// play completes a session of game for customer and returns its id; every
// playableGame pays 10.
play := func(customer, game uuid.UUID) uuid.UUID {
t.Helper()
n++
s, err := e.sessions.Start(ctx, customer, game, fmt.Sprintf("k%d", n))
require.NoError(t, err)
_, err = e.sessions.Complete(ctx, customer, s.SessionID, models.GameSessionCompleteInput{})
require.NoError(t, err)
return s.SessionID
}
play(e.alice, runner.ID)
play(e.alice, other.ID)
bobs := play(e.bob, runner.ID)
play(dave, runner.ID)
play(e.carol, theirs.ID)
// Bob reached 10 before Dave did.
require.NoError(t, e.db.Exec(`UPDATE game_sessions SET ended_at = ended_at - INTERVAL '1 minute' WHERE id = ?`, bobs).Error)
flagged := play(e.bob, runner.ID)
require.NoError(t, e.db.Exec(`UPDATE game_sessions SET flagged = TRUE WHERE id = ?`, flagged).Error)
lastWeek := play(dave, runner.ID)
require.NoError(t, e.db.Exec(`UPDATE game_sessions SET started_at = started_at - INTERVAL '7 days' WHERE id = ?`, lastWeek).Error)
_, err := e.sessions.Start(ctx, e.alice, runner.ID, "open")
require.NoError(t, err, "a STARTED session counts for nothing")
board, err := e.sessions.Leaderboard(ctx, e.bob, models.EnakGameLeaderboardQuery{})
require.NoError(t, err)
week := leaderboardWeekStart(e.now)
assert.Equal(t, week.Format("2006-01-02"), board.WeekStart)
assert.Equal(t, week.AddDate(0, 0, 6).Format("2006-01-02"), board.WeekEnd)
assert.Equal(t, []models.EnakGameLeaderboardEntry{
{Rank: 1, Name: "Alice", Coins: 20},
{Rank: 2, Name: "Bob", Coins: 10, IsMe: true},
{Rank: 3, Name: "Dave L**", Coins: 10},
}, board.Entries, "the flagged play, last week's play and another organization's do not count")
require.NotNil(t, board.Me)
assert.Equal(t, 2, board.Me.Rank)
board, err = e.sessions.Leaderboard(ctx, dave, models.EnakGameLeaderboardQuery{Limit: 1})
require.NoError(t, err)
require.Len(t, board.Entries, 1)
assert.Equal(t, "Alice", board.Entries[0].Name)
require.NotNil(t, board.Me, "the customer asking is returned below the limit")
assert.Equal(t, models.EnakGameLeaderboardEntry{Rank: 3, Name: "Dave L**", Coins: 10, IsMe: true}, *board.Me)
board, err = e.sessions.Leaderboard(ctx, e.alice, models.EnakGameLeaderboardQuery{GameID: other.ID.String()})
require.NoError(t, err)
assert.Equal(t, []models.EnakGameLeaderboardEntry{{Rank: 1, Name: "Alice", Coins: 10, IsMe: true}}, board.Entries)
assert.Equal(t, &other.ID, board.GameID)
board, err = e.sessions.Leaderboard(ctx, dave, models.EnakGameLeaderboardQuery{Date: week.AddDate(0, 0, -3).Format("2006-01-02")})
require.NoError(t, err)
assert.Equal(t, []models.EnakGameLeaderboardEntry{{Rank: 1, Name: "Dave L**", Coins: 10, IsMe: true}}, board.Entries, "last week")
board, err = e.sessions.Leaderboard(ctx, e.carol, models.EnakGameLeaderboardQuery{})
require.NoError(t, err)
assert.Equal(t, []models.EnakGameLeaderboardEntry{{Rank: 1, Name: "Carol", Coins: 10, IsMe: true}}, board.Entries, "only the customer's organization")
board, err = e.sessions.Leaderboard(ctx, e.bob, models.EnakGameLeaderboardQuery{GameID: uuid.NewString(), Date: "2020-01-01"})
require.NoError(t, err)
assert.Empty(t, board.Entries)
assert.Nil(t, board.Me)
_, err = e.sessions.Leaderboard(ctx, e.bob, models.EnakGameLeaderboardQuery{Date: "12-10-2026"})
assert.ErrorIs(t, err, ErrGameSessionRejected)
_, err = e.sessions.Leaderboard(ctx, e.bob, models.EnakGameLeaderboardQuery{GameID: "runner"})
assert.ErrorIs(t, err, ErrGameSessionRejected)
}
@@ -0,0 +1,38 @@
package processor
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestMaskCustomerName(t *testing.T) {
for name, want := range map[string]string{
"Budi Santoso": "Budi S******",
"budi": "budi",
" Siti Nur Aisyah": "Siti N** A*****",
"Ádám Ő": "Ádám Ő",
"081234567890": "0***********",
"Budi2 Santoso": "B**** S******",
"": "Pemain",
" ": "Pemain",
} {
assert.Equal(t, want, maskCustomerName(name), "%q", name)
}
}
func TestLeaderboardWeekStart(t *testing.T) {
monday := time.Date(2026, 10, 12, 0, 0, 0, 0, walletDisplayLocation)
for _, day := range []time.Time{
monday,
time.Date(2026, 10, 14, 12, 0, 0, 0, walletDisplayLocation),
time.Date(2026, 10, 18, 23, 59, 59, 0, walletDisplayLocation),
// Sunday 17:00 UTC is already Monday 00:00 in Jakarta.
time.Date(2026, 10, 11, 17, 0, 0, 0, time.UTC),
} {
assert.True(t, monday.Equal(leaderboardWeekStart(day)), "%s", day)
}
// Sunday 16:59 UTC is still Sunday in Jakarta, the week before.
assert.True(t, monday.AddDate(0, 0, -7).Equal(leaderboardWeekStart(time.Date(2026, 10, 11, 16, 59, 0, 0, time.UTC))))
}
@@ -70,6 +70,32 @@ type GameSessionRepository interface {
CreateSessionRewards(ctx context.Context, rewards []entities.GameSessionReward) error
ListSessionRewards(ctx context.Context, sessionID uuid.UUID) ([]entities.GameSessionReward, error)
// Leaderboard ranks the active customers of an organization by the EnakCoin their
// completed, unflagged sessions started in [from, to) won, and returns the first
// limit ranks plus the row of filter.CustomerID wherever it ranks.
Leaderboard(ctx context.Context, filter LeaderboardFilter) ([]LeaderboardRow, error)
}
// LeaderboardFilter selects the sessions a leaderboard counts.
type LeaderboardFilter struct {
OrganizationID uuid.UUID
// Nil for every game.
GameID *uuid.UUID
From time.Time
To time.Time
Limit int
// The customer asking, returned even below Limit.
CustomerID uuid.UUID
}
// LeaderboardRow is one customer's total. Equal totals rank by who reached theirs
// first, so no two rows share a rank.
type LeaderboardRow struct {
Rank int
CustomerID uuid.UUID
Name string
Coins int64
}
type gameSessionRepository struct {
@@ -255,3 +281,45 @@ func (r *gameSessionRepository) ListDueSessions(ctx context.Context, now time.Ti
}
return due, nil
}
func (r *gameSessionRepository) Leaderboard(ctx context.Context, filter LeaderboardFilter) ([]LeaderboardRow, error) {
gameFilter, args := "", []any{filter.OrganizationID, filter.From, filter.To, constants.GameSessionStatusCompleted}
if filter.GameID != nil {
gameFilter = "AND s.game_id = ?"
args = append(args, *filter.GameID)
}
args = append(args, filter.Limit, filter.CustomerID)
var rows []struct {
Rank int
CustomerID string
Name string
Coins int64
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
WITH totals AS (
SELECT s.customer_id, SUM(s.reward_total) AS coins, MAX(s.ended_at) AS reached_at
FROM game_sessions s
WHERE s.organization_id = ? AND s.started_at >= ? AND s.started_at < ?
AND s.status = ? AND NOT s.flagged AND s.reward_total > 0 `+gameFilter+`
GROUP BY s.customer_id
), ranked AS (
SELECT t.customer_id, c.name, t.coins,
ROW_NUMBER() OVER (ORDER BY t.coins DESC, t.reached_at, t.customer_id) AS rank
FROM totals t
JOIN customers c ON c.id = t.customer_id AND c.is_active
)
SELECT rank, customer_id::text AS customer_id, name, coins
FROM ranked
WHERE rank <= ? OR customer_id = ?
ORDER BY rank`, args...).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to read game leaderboard: %w", err)
}
out := make([]LeaderboardRow, 0, len(rows))
for _, row := range rows {
l := LeaderboardRow{Rank: row.Rank, Name: row.Name, Coins: row.Coins}
l.CustomerID, _ = uuid.Parse(row.CustomerID)
out = append(out, l)
}
return out, nil
}
+1
View File
@@ -197,6 +197,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
enakGame.GET("/sessions", r.enakGameCustomerHandler.ListSessions)
enakGame.GET("/sessions/:id", r.enakGameCustomerHandler.GetSession)
enakGame.POST("/sessions/:id/complete", r.enakGameCustomerHandler.CompleteSession)
enakGame.GET("/leaderboard", r.enakGameCustomerHandler.Leaderboard)
}
// Vouchers: what EnakPoint is spent on, whatever it came from (docs/rfc-enakgame.md
+6
View File
@@ -74,6 +74,7 @@ type EnakGameCustomerService interface {
CompleteSession(ctx context.Context, customerID, sessionID uuid.UUID, in models.GameSessionCompleteInput) *contract.Response
ListSessions(ctx context.Context, customerID uuid.UUID, q models.GameSessionListQuery) *contract.Response
GetSession(ctx context.Context, customerID, sessionID uuid.UUID) *contract.Response
Leaderboard(ctx context.Context, customerID uuid.UUID, q models.EnakGameLeaderboardQuery) *contract.Response
ListVouchers(ctx context.Context, customerID uuid.UUID) *contract.Response
RedeemVoucher(ctx context.Context, customerID, voucherID uuid.UUID, req *contract.RedeemVoucherRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response
@@ -381,6 +382,11 @@ func (s *EnakGameCustomerServiceImpl) GetSession(ctx context.Context, customerID
return respond(ctx, session, err)
}
func (s *EnakGameCustomerServiceImpl) Leaderboard(ctx context.Context, customerID uuid.UUID, q models.EnakGameLeaderboardQuery) *contract.Response {
board, err := s.sessions.Leaderboard(ctx, customerID, q)
return respond(ctx, board, err)
}
// decodeStrict reads a JSON body into v, refusing unknown fields and trailing data.
// It returns the error response, or nil when the body fits.
func decodeStrict(body []byte, v any) *contract.Response {