Skip to content

Commit d2f9824

Browse files
committed
fix(pack): official stickers are retired by unpublishing, never deleted
ON DELETE CASCADE plus the image service's reference-ping TTL means a hard delete here retroactively breaks every post on every consuming site that used the image. Nothing enforced that; the rule existed only as a comment in editor_packs.go. Moving status back to PackDraft takes a pack out of the picker while keeping its rows, and therefore its ping, alive.
1 parent 94ae03c commit d2f9824

4 files changed

Lines changed: 73 additions & 0 deletions

File tree

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,20 @@ func (s *Service) visiblePack(id uuid.UUID, v Viewer) (*model.Pack, *errors.AppE
211211
return pack, nil
212212
}
213213

214+
// An official sticker's hash is embedded in post content on other sites -- the forum
215+
// alone had 1620 such embeds. A hard delete drops the hash out of this site's daily
216+
// reference ping, and the image service then collects the bytes on its own TTL, so
217+
// every one of those posts breaks retroactively. Unpublishing (status -> PackDraft)
218+
// takes a pack out of the picker while keeping its rows, and therefore its ping.
219+
func refuseOfficialDelete(pack *model.Pack) *errors.AppError {
220+
if !pack.IsOfficial {
221+
return nil
222+
}
223+
return errors.ErrForbidden(
224+
"official stickers are retired by unpublishing the pack, never deleted: " +
225+
"other sites' posts reference these images by hash")
226+
}
227+
214228
func (s *Service) editablePack(id uuid.UUID, v Viewer) (*model.Pack, *errors.AppError) {
215229
pack, err := s.packs.Get(id)
216230
if err != nil {
@@ -360,6 +374,9 @@ func (s *Service) DeletePack(id uuid.UUID, v Viewer) *errors.AppError {
360374
if !(pack.OwnerUID == v.UID || perm.Can(v.Roles, perm.PackDeleteAny)) {
361375
return errors.ErrNotOwner()
362376
}
377+
if appErr := refuseOfficialDelete(pack); appErr != nil {
378+
return appErr
379+
}
363380

364381
var tagIDs []uuid.UUID
365382
if err := s.packs.DB().Table("pack_tag").Where("pack_id = ?", id).

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,9 @@ func (s *Service) DeleteSticker(packID, stickerID uuid.UUID, v Viewer) *errors.A
197197
if appErr != nil {
198198
return appErr
199199
}
200+
if appErr := refuseOfficialDelete(pack); appErr != nil {
201+
return appErr
202+
}
200203
if err := s.stickers.Delete(packID, stickerID); err != nil {
201204
if stderrors.Is(err, gorm.ErrRecordNotFound) {
202205
return errors.ErrStickerNotFound()

‎docs/open-api/04-editor-packs.md‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# `GET /api/v1/editor-packs` — 编辑器贴纸选择器的载荷
2+
3+
不需要密钥、可缓存、只含官方已发布包。一次响应给出全部官方包的全部贴纸,形状对齐
4+
`@kungal/editor-core` 的 `StickerPack` / `StickerItem`,消费方可以直接丢给自己的
5+
`stickerSource` 适配器。
6+
7+
```json
8+
{
9+
"code": 0,
10+
"message": "ok",
11+
"data": {
12+
"variant": "320",
13+
"packs": [
14+
{
15+
"name": "鲲 Galgame 表情包 [1]",
16+
"stickers": [
17+
{
18+
"src": "https://image.kungal.iloveren.link/d5/2a/d52ac5fb…e8_320.webp",
19+
"name": "鲲 Galgame 表情包 [1] - 1",
20+
"hash": "d52ac5fb…e8"
21+
}
22+
]
23+
}
24+
]
25+
}
26+
}
27+
```
28+
29+
## 存 `hash`,不要存 `src`
30+
31+
`src` 是本次部署的图床当前域名下的一个 URL。它存在的唯一目的是让选择器把缩略图画出来。
32+
33+
**消费方一旦把 `src` 写进帖子正文,就把这个域名焊死进了它以后写的每一篇帖子。** 这不是假设:
34+
论坛就是这么干的,等上一代贴纸域名停止发静态文件的那天,**215 张贴纸、1620 处引用同时变成裂图**,
35+
而且唯一的回滚材料是从一张改过名的 legacy 表里手工还原出来的「位置 → hash」对照表。
36+
37+
所以:**持久化 `hash`,把 `variant` 一起存着,渲染时再用当时配置的 base 拼 URL。** URL 形状是图床的,
38+
见 `docs/image_service/`,两级 hex 分片:
39+
40+
```
41+
{cdn_base}/{hash[0:2]}/{hash[2:4]}/{hash}_{variant}.webp
42+
```
43+
44+
论坛现在把它存成不含域名的 `/image/{hash}_{variant}` token,在自己的 markdown 渲染器里展开 ——
45+
这就是可以照抄的形状。
46+
47+
## 官方贴纸只下架,永不删除
48+
49+
`ON DELETE CASCADE` 叠上图床的 reference-ping TTL,意味着这边一次硬删会**追溯性地**弄坏所有消费方
50+
用过这张图的帖子。`apps/api/internal/platform/sticker/service/pack.go` 的 `refuseOfficialDelete`
51+
在 `is_official` 为真时同时挡住删贴纸和删包两条路。要让一个包退出流通,把 `status` 改回
52+
`PackDraft`:选择器不再提供它,而行和 reference ping 都还在。

‎docs/open-api/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
| 01 | [data-inventory.md](./01-data-inventory.md) | 库里有什么、字段含义、哪些能对外哪些不能、与其他 infra 服务的现状 |
88
| 02 | [opening-the-api.md](./02-opening-the-api.md) | 三条已写进 infra 文档的接入路径的**实际状态核对**、建议的面形状、需要 infra 拍板的四件事、上线清单 |
99
| 03 | [wiring.md](./03-wiring.md) | **接线现状**:infra 已交付什么、本站已交付什么、还差什么,含容器别名与端口 |
10+
| 04 | [editor-packs.md](./04-editor-packs.md) | 编辑器贴纸选择器载荷的消费方契约:**存 hash 不要存 src**,以及官方贴纸只下架不删除 |
1011
| — | [sticker-openapi.yaml](./sticker-openapi.yaml) | 面的 OpenAPI 3.1 契约(9 op,`redocly lint` 通过) |
1112

1213
## 三十秒版本

0 commit comments

Comments
 (0)