Skip to content

Commit 449a847

Browse files
committed
feat(editor): serve the sticker-picker payload in one response
Every editor in the ecosystem shipped its own copy of the same 498 URLs, generated from `KUNgal{set}/{n}.webp` plus a hardcoded array of pack sizes -- forum, moyu and this site each had one. All of them died the day this host stopped serving static files, and all of them were silently wrong long before that, because a pack that gained a sticker did not update anybody's array. GET /api/v1/editor-packs returns all 7 official packs and their 498 stickers at once, shaped so a consumer can hand it straight to @kungal/editor-core's `stickerSource` adapter. One request, not the face's list-then-fetch-each: a picker needs every sticker of every pack to draw one panel, and 1+N round trips is exactly what makes a consumer hardcode the list instead. The `src` values are content-addressed CDN URLs, and they are what the picker inserts into post content. That is the real fix for the class of bug rather than for this instance of it: the old path encoded a position in a mutable collection, so years of forum posts rotted when the collection moved; a content hash encodes the bytes and cannot. The corollary is a rule for this site -- official stickers get retired by status, never hard-deleted, or the image service's refcount reaches zero and those posts break anyway. Official packs only: a picker rendered inside somebody else's editor is not the place to surface unmoderated user uploads. Claude-Session: https://claude.ai/code/session_01WYeKk1xWGbYeENdb6Ez3xL
1 parent d76d2c7 commit 449a847

6 files changed

Lines changed: 161 additions & 0 deletions

File tree

‎apps/api/internal/app/app.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,12 @@ func New(cfg *config.Config) *App {
154154
// old copy instead of falling back.
155155
api.Get("/avatar-pool", readLimit, avatarPoolCache, etag.New(), h.AvatarPool)
156156

157+
// The sticker-picker payload other sites' editors render, in one response
158+
// rather than the face's list-then-fetch-each: a picker needs every
159+
// sticker of every pack at once, and 1+N round trips to build one panel is
160+
// what made every consumer hardcode the URLs in the first place.
161+
api.Get("/editor-packs", readLimit, avatarPoolCache, etag.New(), h.EditorPacks)
162+
157163
// Public, but an author also sees their own drafts here.
158164
api.Get("/packs/:packId", readLimit, optionalAuth, h.GetPack)
159165
api.Get("/packs/:packId/download", readLimit, optionalAuth, h.DownloadPack)

‎apps/api/internal/platform/sticker/dto/sticker.go‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,20 @@ type AvatarPool struct {
199199
Variant string `json:"variant"`
200200
URLs []string `json:"urls"`
201201
}
202+
203+
// EditorPacks is the sticker-picker payload other sites' editors render. The
204+
// field names match @kungal/editor-core's StickerPack/StickerItem so a
205+
// consumer can hand the response straight to its `stickerSource` adapter.
206+
type EditorPacks struct {
207+
Packs []EditorPack `json:"packs"`
208+
}
209+
210+
type EditorPack struct {
211+
Name string `json:"name"`
212+
Stickers []EditorSticker `json:"stickers"`
213+
}
214+
215+
type EditorSticker struct {
216+
Src string `json:"src"`
217+
Name string `json:"name"`
218+
}

‎apps/api/internal/platform/sticker/handler/pack.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,15 @@ func (h *Handler) AvatarPool(c fiber.Ctx) error {
161161
}
162162
return response.OK(c, pool)
163163
}
164+
165+
// EditorPacks serves the sticker-picker payload other sites' editors render.
166+
// Public and unauthenticated for the same reason as AvatarPool: it lists
167+
// images that are already public, and the consuming sites fetch it from their
168+
// own servers.
169+
func (h *Handler) EditorPacks(c fiber.Ctx) error {
170+
packs, appErr := h.svc.EditorPacks()
171+
if appErr != nil {
172+
return response.Error(c, appErr)
173+
}
174+
return response.OK(c, packs)
175+
}

‎apps/api/internal/platform/sticker/repository/pack_repo.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,13 @@ func escapeLike(s string) string {
140140
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
141141
return r.Replace(s)
142142
}
143+
144+
// OfficialPublished lists the official packs in publication order. It is the
145+
// picker's pack list, so it deliberately ignores the list filters: an editor
146+
// panel in someone else's app shows the same tabs to everyone.
147+
func (r *PackRepo) OfficialPublished() ([]model.Pack, error) {
148+
var rows []model.Pack
149+
err := r.db.Where("status = ? AND is_official", model.PackPublished).
150+
Order("created_at ASC, id ASC").Find(&rows).Error
151+
return rows, err
152+
}

‎apps/api/internal/platform/sticker/repository/sticker_repo.go‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,20 @@ func (r *StickerRepo) AvatarPoolHashes() ([]string, error) {
240240
Pluck("sticker.image_hash", &hashes).Error
241241
return hashes, err
242242
}
243+
244+
// ForPacks loads the stickers of several packs in one query, grouped by pack.
245+
func (r *StickerRepo) ForPacks(packIDs []uuid.UUID) (map[uuid.UUID][]model.Sticker, error) {
246+
out := make(map[uuid.UUID][]model.Sticker, len(packIDs))
247+
if len(packIDs) == 0 {
248+
return out, nil
249+
}
250+
var rows []model.Sticker
251+
if err := r.db.Where("pack_id IN ?", packIDs).
252+
Order("pack_id ASC, position ASC").Find(&rows).Error; err != nil {
253+
return nil, err
254+
}
255+
for _, row := range rows {
256+
out[row.PackID] = append(out[row.PackID], row)
257+
}
258+
return out, nil
259+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package service
2+
3+
import (
4+
"log/slog"
5+
"strconv"
6+
7+
"kun-galgame-sticker-api/internal/platform/sticker/dto"
8+
"kun-galgame-sticker-api/pkg/errors"
9+
10+
"github.com/google/uuid"
11+
"gorm.io/datatypes"
12+
)
13+
14+
// editorPackVariant is what a picker grid renders. 320 is the same variant the
15+
// site's own grid uses; an editor tile is the same size as a pack-page tile.
16+
const editorPackVariant = "320"
17+
18+
// EditorPacks is the whole official sticker catalogue in one response, shaped
19+
// for a sticker picker.
20+
//
21+
// It exists because every editor in the ecosystem shipped its own copy of the
22+
// same 498 URLs, generated from `KUNgal{set}/{n}.webp` with a hardcoded set of
23+
// pack sizes -- forum, moyu and this site each had one, all of them dead the
24+
// day this host stopped serving static files, and all of them silently wrong
25+
// long before that (a pack gained stickers; the arrays did not).
26+
//
27+
// Two shape decisions:
28+
//
29+
// - ONE request, not the face's list-then-fetch-each. A picker needs every
30+
// sticker of every pack at once, and 1+N round trips to build one panel is
31+
// the sort of thing consumers work around by hardcoding again.
32+
// - `src` is the CDN URL, and it is what the picker INSERTS INTO POST
33+
// CONTENT. That is the actual fix for the class of bug: the old path
34+
// encoded a position in a mutable collection, so years of posts rotted
35+
// when the collection moved. A content hash encodes the bytes and cannot.
36+
// The corollary is a rule for this site, not for consumers: official
37+
// stickers are retired by status, never hard-deleted, or the image
38+
// service's refcount drops to zero and the old posts break anyway.
39+
//
40+
// Official packs only. A picker offered in someone else's editor is not the
41+
// place to surface unmoderated user uploads.
42+
func (s *Service) EditorPacks() (*dto.EditorPacks, *errors.AppError) {
43+
rows, err := s.packs.OfficialPublished()
44+
if err != nil {
45+
slog.Error("editor packs query failed", "error", err)
46+
return nil, errors.ErrInternal("failed to load the sticker packs")
47+
}
48+
ids := make([]uuid.UUID, 0, len(rows))
49+
for _, row := range rows {
50+
ids = append(ids, row.ID)
51+
}
52+
byPack, err := s.stickers.ForPacks(ids)
53+
if err != nil {
54+
slog.Error("editor packs sticker query failed", "error", err)
55+
return nil, errors.ErrInternal("failed to load the sticker packs")
56+
}
57+
58+
packs := make([]dto.EditorPack, 0, len(rows))
59+
for _, row := range rows {
60+
label := displayTitle(row.Title)
61+
stickers := make([]dto.EditorSticker, 0, len(byPack[row.ID]))
62+
for _, st := range byPack[row.ID] {
63+
if st.ImageHash == "" || s.images == nil {
64+
continue
65+
}
66+
stickers = append(stickers, dto.EditorSticker{
67+
Src: s.images.VariantURL(st.ImageHash, editorPackVariant),
68+
Name: label + " - " + strconv.Itoa(st.Position),
69+
})
70+
}
71+
if len(stickers) == 0 {
72+
continue
73+
}
74+
packs = append(packs, dto.EditorPack{Name: label, Stickers: stickers})
75+
}
76+
return &dto.EditorPacks{Packs: packs}, nil
77+
}
78+
79+
// displayTitle flattens a multilingual title to the one string a picker tab
80+
// can show. The picker has no locale to negotiate with -- it is rendered
81+
// inside somebody else's app -- so this is a fixed preference order rather
82+
// than content negotiation, and zh-cn leads because that is what the official
83+
// packs are authored in.
84+
var titlePreference = []string{"zh-cn", "zh-tw", "ja-jp", "en-us", "und"}
85+
86+
func displayTitle(raw datatypes.JSON) string {
87+
title := decodeML(raw)
88+
for _, key := range titlePreference {
89+
if value := title[key]; value != "" {
90+
return value
91+
}
92+
}
93+
for _, value := range title {
94+
if value != "" {
95+
return value
96+
}
97+
}
98+
return "Stickers"
99+
}

0 commit comments

Comments
 (0)