Skip to content

Commit 29fd124

Browse files
committed
feat(galgame): view resource edit history
1 parent 015068b commit 29fd124

10 files changed

Lines changed: 537 additions & 51 deletions

File tree

apps/api/internal/app/router.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,15 @@ func (a *App) RegisterRoutes() {
110110
a.PatchHandler.IncrementResourceDownload,
111111
)
112112
patchRoutes.Put("/resource/:resourceId/like", auth, a.PatchHandler.ToggleResourceLike)
113+
// Public per-field edit history (diff) for one resource (anyone, incl.
114+
// anonymous). Rate-limited like the other id-keyed resource reads. Changes
115+
// are secret-free (service strips download links / codes) — distinct from
116+
// the admin-only /admin/resource/:id/history file-replacement audit.
117+
patchRoutes.Get(
118+
"/resource/:resourceId/revisions",
119+
middleware.RateLimit(a.RDB, "resource-revisions", 60, time.Minute),
120+
a.PatchHandler.GetResourceRevisions,
121+
)
113122
patchRoutes.Put("/:id/favorite", auth, a.PatchHandler.ToggleFavorite)
114123

115124
// Galgame metadata edit (proxy to Galgame Wiki PUT /galgame/:gid).

apps/api/internal/patch/dto/dto.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package dto
22

3+
import "time"
4+
35
// PatchCreateRequest is the create-patch request body (D12, 2026-04-21).
46
//
57
// All game metadata (name / introduction / banner / released / content_limit / alias)
@@ -85,3 +87,23 @@ type PatchResourceUpdateRequest struct {
8587
type DuplicateCheckRequest struct {
8688
VndbID string `query:"vndb_id" validate:"required,max=20"`
8789
}
90+
91+
// ResourceFileHistoryRequest paginates the public resource file-history.
92+
type ResourceFileHistoryRequest struct {
93+
Page int `query:"page" validate:"required,min=1"`
94+
Limit int `query:"limit" validate:"required,min=1,max=30"`
95+
}
96+
97+
// PublicResourceFileHistoryItem is the privacy-safe view of one
98+
// patch_resource_file_history row for the PUBLIC history endpoint. It omits
99+
// old_s3_key (internal storage key) and old_content (the old download links) —
100+
// the public audit only needs when / who-role / why / old size + hash.
101+
type PublicResourceFileHistoryItem struct {
102+
ID int64 `json:"id"`
103+
OldStorage string `json:"old_storage"`
104+
OldBlake3 string `json:"old_blake3"`
105+
OldSize string `json:"old_size"`
106+
Reason string `json:"reason"`
107+
ActorRole int `json:"actor_role"`
108+
CreatedAt time.Time `json:"created_at"`
109+
}

apps/api/internal/patch/handler/handler.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,3 +1133,46 @@ func parseGalgameMultipart(c *fiber.Ctx) (galgameClient.SubmitGalgameRequest, st
11331133
}
11341134
return req, fh.Filename, raw, mime, nil
11351135
}
1136+
1137+
// GetResourceFileHistory GET /api/patch/resource/:resourceId/history
1138+
//
1139+
// Public, privacy-safe view of one resource's file-replacement audit
1140+
// (when / who-role / why / old size + hash). Deliberately omits the old
1141+
// download links + s3 key — those stay behind the rate-limited /link endpoint.
1142+
// Lets any visitor (incl. anonymous) see a resource's change history.
1143+
func (h *PatchHandler) GetResourceFileHistory(c *fiber.Ctx) error {
1144+
resourceID, err := getIDParam(c, "resourceId")
1145+
if err != nil {
1146+
return response.Error(c, err.(*errors.AppError))
1147+
}
1148+
var req dto.ResourceFileHistoryRequest
1149+
if err := utils.ParseQueryAndValidate(c, &req); err != nil {
1150+
return response.Error(c, errors.ErrBadRequest(err.Error()))
1151+
}
1152+
items, total, gErr := h.service.GetResourceFileHistory(resourceID, req.Page, req.Limit)
1153+
if gErr != nil {
1154+
return response.Error(c, errors.ErrInternal(""))
1155+
}
1156+
return response.Paginated(c, items, total)
1157+
}
1158+
1159+
// GetResourceRevisions GET /api/patch/resource/:resourceId/revisions
1160+
//
1161+
// Public per-field edit history (diff) for one resource: each row is one edit
1162+
// with a list of {field, before, after}. Secret-free (see service). Lets any
1163+
// visitor see "language changed from X to Y", etc.
1164+
func (h *PatchHandler) GetResourceRevisions(c *fiber.Ctx) error {
1165+
resourceID, err := getIDParam(c, "resourceId")
1166+
if err != nil {
1167+
return response.Error(c, err.(*errors.AppError))
1168+
}
1169+
var req dto.ResourceFileHistoryRequest
1170+
if err := utils.ParseQueryAndValidate(c, &req); err != nil {
1171+
return response.Error(c, errors.ErrBadRequest(err.Error()))
1172+
}
1173+
items, total, gErr := h.service.GetResourceRevisions(resourceID, req.Page, req.Limit)
1174+
if gErr != nil {
1175+
return response.Error(c, errors.ErrInternal(""))
1176+
}
1177+
return response.Paginated(c, items, total)
1178+
}

apps/api/internal/patch/model/model.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,3 +342,53 @@ func (PatchResourceFileHistory) TableName() string { return "patch_resource_file
342342

343343
// NOTE: PatchTag / PatchTagRel are deprecated per D11 (2026-04-21).
344344
// Tag metadata is owned by the Galgame Wiki; fetch it via patch.vndb_id -> Wiki /galgame/batch.
345+
346+
// ─── Resource edit revision (per-field diff history, migration 013) ──────────
347+
348+
// ResourceFieldChange is one field's before/after in a resource edit. Public-
349+
// safe: secrets (download link / s3 key / extract code / unzip password) are
350+
// never stored as raw values — only marked "已更新" via a synthetic entry.
351+
type ResourceFieldChange struct {
352+
Field string `json:"field"`
353+
Label string `json:"label"`
354+
Before string `json:"before"`
355+
After string `json:"after"`
356+
}
357+
358+
// ResourceChangeList is the jsonb column type for PatchResourceRevision.Changes.
359+
type ResourceChangeList []ResourceFieldChange
360+
361+
func (c *ResourceChangeList) Scan(value any) error {
362+
if value == nil {
363+
*c = ResourceChangeList{}
364+
return nil
365+
}
366+
bytes, ok := value.([]byte)
367+
if !ok {
368+
return fmt.Errorf("failed to unmarshal ResourceChangeList: %v", value)
369+
}
370+
return json.Unmarshal(bytes, c)
371+
}
372+
373+
func (c ResourceChangeList) Value() (driver.Value, error) {
374+
if c == nil {
375+
return "[]", nil
376+
}
377+
return json.Marshal(c)
378+
}
379+
380+
// PatchResourceRevision is one edit of a patch_resource, stored as a computed
381+
// field diff. Written by UpdateResource on every metadata/file change. Changes
382+
// carries no secret values (see service.diffResourceFields).
383+
type PatchResourceRevision struct {
384+
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
385+
ResourceID int `gorm:"not null;index:idx_prr_resource,priority:1" json:"resource_id"`
386+
Action string `gorm:"type:varchar(16);not null;default:'updated'" json:"action"`
387+
Changes ResourceChangeList `gorm:"type:jsonb;default:'[]'" json:"changes"`
388+
Reason string `gorm:"type:varchar(500);not null;default:''" json:"reason"`
389+
ActorID int `gorm:"not null;default:0" json:"actor_id"`
390+
ActorRole int `gorm:"not null;default:0" json:"actor_role"`
391+
CreatedAt time.Time `gorm:"autoCreateTime;index:idx_prr_resource,priority:2,sort:desc" json:"created_at"`
392+
}
393+
394+
func (PatchResourceRevision) TableName() string { return "patch_resource_revision" }

apps/api/internal/patch/repository/repository.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,3 +421,38 @@ func setToSlice(s map[string]bool) []string {
421421
}
422422
return result
423423
}
424+
425+
// GetResourceFileHistory returns the file-replacement audit rows for one
426+
// resource, newest first. Public surface (the privacy-safe projection that
427+
// drops old_s3_key / old_content happens in the service). Mirrors
428+
// AdminRepository.GetResourceFileHistory so the public history endpoint does
429+
// not reach across modules into the admin repo.
430+
func (r *PatchRepository) GetResourceFileHistory(resourceID, offset, limit int) ([]model.PatchResourceFileHistory, int64, error) {
431+
var rows []model.PatchResourceFileHistory
432+
var total int64
433+
base := r.db.Model(&model.PatchResourceFileHistory{}).Where("resource_id = ?", resourceID)
434+
if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil {
435+
return nil, 0, err
436+
}
437+
err := base.Session(&gorm.Session{}).
438+
Order("created_at DESC, id DESC").
439+
Offset(offset).Limit(limit).
440+
Find(&rows).Error
441+
return rows, total, err
442+
}
443+
444+
// GetResourceRevisions returns the per-field edit-diff history for one resource,
445+
// newest first. Public (stored Changes are secret-free). Paginated.
446+
func (r *PatchRepository) GetResourceRevisions(resourceID, offset, limit int) ([]model.PatchResourceRevision, int64, error) {
447+
var rows []model.PatchResourceRevision
448+
var total int64
449+
base := r.db.Model(&model.PatchResourceRevision{}).Where("resource_id = ?", resourceID)
450+
if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil {
451+
return nil, 0, err
452+
}
453+
err := base.Session(&gorm.Session{}).
454+
Order("created_at DESC, id DESC").
455+
Offset(offset).Limit(limit).
456+
Find(&rows).Error
457+
return rows, total, err
458+
}

apps/api/internal/patch/service/service.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
galgameClient "kun-galgame-patch-api/internal/galgame/client"
1313
"kun-galgame-patch-api/internal/infrastructure/markdown"
1414
"kun-galgame-patch-api/internal/infrastructure/storage"
15+
"kun-galgame-patch-api/internal/patch/dto"
1516
"kun-galgame-patch-api/internal/patch/model"
1617
"kun-galgame-patch-api/internal/patch/repository"
1718
settingService "kun-galgame-patch-api/internal/setting/service"
@@ -860,6 +861,11 @@ func (s *PatchService) UpdateResource(ctx context.Context, resourceID, userID in
860861
}
861862

862863
galgameID := existing.GalgameID
864+
// Per-field edit diff (public-safe) — computed from existing(before) vs
865+
// update(after) BEFORE the txn overwrites `existing` below. Stored as a
866+
// PatchResourceRevision so the resource page can show 改动前 → 改动后 for
867+
// language / platform / type / note / name / size / file. Empty = no-op save.
868+
changes := diffResourceFields(existing, update)
863869
if err := s.db.Transaction(func(tx *gorm.DB) error {
864870
if fileChanged {
865871
hist := &model.PatchResourceFileHistory{
@@ -878,6 +884,20 @@ func (s *PatchService) UpdateResource(ctx context.Context, resourceID, userID in
878884
}
879885
}
880886

887+
if len(changes) > 0 {
888+
rev := &model.PatchResourceRevision{
889+
ResourceID: existing.ID,
890+
Action: "updated",
891+
Changes: changes,
892+
Reason: reason,
893+
ActorID: userID,
894+
ActorRole: actorRole,
895+
}
896+
if err := tx.Create(rev).Error; err != nil {
897+
return fmt.Errorf("write resource revision: %w", err)
898+
}
899+
}
900+
881901
existing.Storage = update.Storage
882902
existing.Name = update.Name
883903
existing.ModelName = update.ModelName
@@ -1203,3 +1223,76 @@ func (s *PatchService) IsCommentVerifyEnabled() bool {
12031223
func (s *PatchService) IsCreatorOnlyEnabled() bool {
12041224
return s.setting.GetBool(settingService.KeyCreatorOnly)
12051225
}
1226+
1227+
// GetResourceFileHistory returns the privacy-safe, paginated file-replacement
1228+
// audit for one resource. Public (any visitor, incl. anonymous): deliberately
1229+
// omits old_s3_key (internal storage key) and old_content (the old download
1230+
// links) — those stay behind the rate-limited /link endpoint. Callers see only
1231+
// when / who-role / why / old size + hash.
1232+
func (s *PatchService) GetResourceFileHistory(resourceID, page, limit int) ([]dto.PublicResourceFileHistoryItem, int64, error) {
1233+
rows, total, err := s.repo.GetResourceFileHistory(resourceID, (page-1)*limit, limit)
1234+
if err != nil {
1235+
return nil, 0, err
1236+
}
1237+
items := make([]dto.PublicResourceFileHistoryItem, 0, len(rows))
1238+
for _, h := range rows {
1239+
items = append(items, dto.PublicResourceFileHistoryItem{
1240+
ID: h.ID,
1241+
OldStorage: h.OldStorage,
1242+
OldBlake3: h.OldBlake3,
1243+
OldSize: h.OldSize,
1244+
Reason: h.Reason,
1245+
ActorRole: h.ActorRole,
1246+
CreatedAt: h.CreatedAt,
1247+
})
1248+
}
1249+
return items, total, nil
1250+
}
1251+
1252+
// diffResourceFields computes the public-safe per-field diff between the
1253+
// pre-edit (before) and post-edit (after) resource. Secrets (download link /
1254+
// s3 key / extract code / unzip password) are never emitted as raw values —
1255+
// only a single "已更新" marker. Used by UpdateResource to record a revision.
1256+
func diffResourceFields(before, after *model.PatchResource) model.ResourceChangeList {
1257+
var ch model.ResourceChangeList
1258+
addStr := func(field, label, b, a string) {
1259+
if b != a {
1260+
ch = append(ch, model.ResourceFieldChange{Field: field, Label: label, Before: b, After: a})
1261+
}
1262+
}
1263+
addArr := func(field, label string, b, a model.JSONArray) {
1264+
bs, as := strings.Join(b, "、"), strings.Join(a, "、")
1265+
if bs != as {
1266+
ch = append(ch, model.ResourceFieldChange{Field: field, Label: label, Before: bs, After: as})
1267+
}
1268+
}
1269+
addStr("name", "资源名称", before.Name, after.Name)
1270+
addStr("size", "文件大小", before.Size, after.Size)
1271+
addStr("model_name", "AI 模型", before.ModelName, after.ModelName)
1272+
addStr("storage", "存储方式", before.Storage, after.Storage)
1273+
// blake3 故意不在此 diff:它由文件自动计算、UpdateResource 不写入它(编辑表单
1274+
// 也不回传),直接比较会让每次元数据编辑都误报 "hash → (空)"。文件替换通过
1275+
// size/storage 变化 + 下面的「已更新」标记体现,blake3 本身不参与字段 diff。
1276+
addStr("note", "备注", before.Note, after.Note)
1277+
addArr("language", "语言", before.Language, after.Language)
1278+
addArr("platform", "平台", before.Platform, after.Platform)
1279+
addArr("type", "类型", before.Type, after.Type)
1280+
if before.Code != after.Code ||
1281+
before.Password != after.Password ||
1282+
before.Content != after.Content ||
1283+
before.S3Key != after.S3Key {
1284+
ch = append(ch, model.ResourceFieldChange{
1285+
Field: "download",
1286+
Label: "下载文件 / 链接 / 提取码 / 密码",
1287+
Before: "",
1288+
After: "已更新",
1289+
})
1290+
}
1291+
return ch
1292+
}
1293+
1294+
// GetResourceRevisions returns the paginated per-field edit history for one
1295+
// resource (public; Changes are secret-free).
1296+
func (s *PatchService) GetResourceRevisions(resourceID, page, limit int) ([]model.PatchResourceRevision, int64, error) {
1297+
return s.repo.GetResourceRevisions(resourceID, (page-1)*limit, limit)
1298+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE IF EXISTS patch_resource_revision;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-- 013_patch_resource_revision: 资源「按字段」编辑历史(diff)。
2+
--
3+
-- patch_resource 原地修改时,patch_resource_file_history 只记录「文件替换」,
4+
-- 不记录 语言/平台/类型/备注/名称/大小 等元数据改动。本表为每次 UpdateResource
5+
-- 存一条「改动 diff」——公开安全:只存字段标签 + 改动前/后的值;敏感的下载链接 /
6+
-- 提取码 / 解压密码只以「已更新」标记,绝不存原文。供前端展示「改动前 → 改动后」。
7+
--
8+
-- CASCADE on delete: 资源删除即连带删除其修订(与 patch_resource_file_history 一致)。
9+
CREATE TABLE IF NOT EXISTS patch_resource_revision (
10+
id BIGSERIAL PRIMARY KEY,
11+
resource_id INT NOT NULL REFERENCES patch_resource(id) ON DELETE CASCADE,
12+
action VARCHAR(16) NOT NULL DEFAULT 'updated', -- 'created' / 'updated'
13+
changes JSONB NOT NULL DEFAULT '[]', -- [{field,label,before,after}]
14+
reason VARCHAR(500) NOT NULL DEFAULT '',
15+
actor_id INT NOT NULL DEFAULT 0,
16+
actor_role INT NOT NULL DEFAULT 0, -- 3=admin / 2=mod / 1=user / 0=unknown
17+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
18+
);
19+
20+
CREATE INDEX IF NOT EXISTS idx_prr_resource ON patch_resource_revision(resource_id, created_at DESC);

apps/web/app/pages/patch/[id].vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,12 +217,15 @@ const tabs = computed(() => [
217217
</div>
218218

219219
<!-- ── Tabs ───────────────────────────────────────── -->
220+
<!-- scrollable: 5 个 Tab 在手机端会超出视口宽度;KunTab 的 scrollable
221+
让 tablist 横向滚动(overflow-x-auto scrollbar-hide)而非撑破布局。 -->
220222
<KunTab
221223
v-model="currentTab"
222224
:items="tabs.map((t) => ({ value: t.key, textValue: t.title, href: t.href }))"
223225
variant="light"
224226
color="primary"
225227
size="md"
228+
scrollable
226229
/>
227230

228231
<div>

0 commit comments

Comments
 (0)