diff --git a/internal/handler/article_handler.go b/internal/handler/article_handler.go index 39b3eff..8bbfd14 100644 --- a/internal/handler/article_handler.go +++ b/internal/handler/article_handler.go @@ -89,7 +89,9 @@ func (h *ArticleHandler) Detail(c *gin.Context) { response.Error(c, http.StatusBadRequest, "缺少文章 id") return } - _, numeric, err := hashid.DecodeTyped(raw) + // 类型由路由提供(/runway-looks 即 runway)。类型已揉进密码:非 runway 编码的串 + // 在此解出的是错误主键,查库自然 404,从而强制前端按类型化路由分流(无需靠前缀嗅探)。 + numeric, err := hashid.DecodeWithType(raw, hashid.TypeRunway) if err != nil { response.Error(c, http.StatusNotFound, "文章不存在") return @@ -97,8 +99,17 @@ func (h *ArticleHandler) Detail(c *gin.Context) { detail, err := h.articles.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), parseLocale(c)) if err != nil { - fail(c, err) - return + // 向后兼容:迁移前收藏/历史/旧外链可能仍带 r= 前缀(旧 EncodeTyped)。 + // 主线按类型化密码解码不到实体时,按旧编码再解一次回退(仅当旧类型确为 runway)。 + if ltyp, lnum, lerr := hashid.DecodeTyped(raw); lerr == nil && ltyp == hashid.TypeRunway { + if d2, e2 := h.articles.Detail(c.Request.Context(), strconv.FormatUint(uint64(lnum), 10), parseLocale(c)); e2 == nil { + detail, err = d2, nil + } + } + if err != nil { + fail(c, err) + return + } } // 未登录:主图只返回预览上限(PreviewLimit)张并打 preview=true,前端据此展示 @@ -112,9 +123,9 @@ func (h *ArticleHandler) Detail(c *gin.Context) { preview = true } - // 已登录:把当前用户对该图集(gallery 级)与每张图片(image 级,含细节图)的收藏态 - // 随详情返回,前端无需再单独调 /me/favorites/checks。gallery uid(r=)与图片 id(i=) - // 前缀不同,混传 FilterExisting 不影响查询;匿名请求不查(保持公开接口无用户态、可缓存)。 + // 已登录:把当前用户对该图集(gallery 级 TypeRunway)与每张图片(image 级 TypeRunwayImage,含细节图)的收藏态 + // 随详情返回,前端无需再单独调 /me/favorites/checks。gallery uid 与图片 id 因类型进密码、串值不同, + // 混传 FilterExisting 不影响查询;匿名请求不查(保持公开接口无用户态、可缓存)。 if uid, ok := middleware.UserIDFrom(c); ok { ids := make([]string, 0, len(detail.Images)*2+1) ids = append(ids, detail.UID) diff --git a/internal/handler/backstage_handler.go b/internal/handler/backstage_handler.go index 530d08b..a6e9f64 100644 --- a/internal/handler/backstage_handler.go +++ b/internal/handler/backstage_handler.go @@ -258,10 +258,10 @@ func (h *BackstageHandler) Runways(c *gin.Context) { } // RunwayDetail 走秀详情:展示完整图集,复用 ArticleService.Detail。 -// 路由 :id 是带 "r" 前缀的 hashid,需先解码成数字主键再查库(与公开接口 article_handler 保持一致)。 +// 路由 :id 是 runway 类型化编码(无前缀,类型进密码),按 TypeRunway 解码(与公开接口 article_handler 一致)。 func (h *BackstageHandler) RunwayDetail(c *gin.Context) { raw := c.Param("id") - _, numeric, err := hashid.DecodeTyped(raw) + numeric, err := hashid.DecodeWithType(raw, hashid.TypeRunway) if err != nil { renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw}) return @@ -311,10 +311,10 @@ func (h *BackstageHandler) StreetSnaps(c *gin.Context) { } // StreetSnapDetail 街拍详情:展示完整图集,复用 StreetSnapService.Detail。 -// 路由 :id 是带 "s" 前缀的 hashid,需先解码成数字主键再查库(与公开接口 street_snap_handler 保持一致)。 +// 路由 :id 是 snap 类型化编码(无前缀,类型进密码),按 TypeSnap 解码(与公开接口 street_snap_handler 一致)。 func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) { raw := c.Param("id") - _, numeric, err := hashid.DecodeTyped(raw) + numeric, err := hashid.DecodeWithType(raw, hashid.TypeSnap) if err != nil { renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw}) return @@ -590,7 +590,7 @@ func usernameString(v any) string { // RunwayEdit 渲染走秀编辑表单(预填现有字段 + 图片管理)。 func (h *BackstageHandler) RunwayEdit(c *gin.Context) { raw := c.Param("id") - _, numeric, err := hashid.DecodeTyped(raw) + numeric, err := hashid.DecodeWithType(raw, hashid.TypeRunway) if err != nil { renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw}) return @@ -619,7 +619,7 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) { // RunwayUpdate 应用走秀字段编辑。 func (h *BackstageHandler) RunwayUpdate(c *gin.Context) { raw := c.Param("id") - _, numeric, err := hashid.DecodeTyped(raw) + numeric, err := hashid.DecodeWithType(raw, hashid.TypeRunway) if err != nil { renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw}) return @@ -650,7 +650,7 @@ func (h *BackstageHandler) RunwayUpdate(c *gin.Context) { // RunwayImageDelete 软删单张走秀图片。 func (h *BackstageHandler) RunwayImageDelete(c *gin.Context) { raw := c.Param("id") - if _, _, err := hashid.DecodeTyped(raw); err != nil { + if _, err := hashid.DecodeWithType(raw, hashid.TypeRunway); err != nil { renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw}) return } @@ -669,7 +669,7 @@ func (h *BackstageHandler) RunwayImageDelete(c *gin.Context) { // RunwaySetCover 把某张图片设为封面。 func (h *BackstageHandler) RunwaySetCover(c *gin.Context) { raw := c.Param("id") - _, numeric, err := hashid.DecodeTyped(raw) + numeric, err := hashid.DecodeWithType(raw, hashid.TypeRunway) if err != nil { renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw}) return @@ -689,7 +689,7 @@ func (h *BackstageHandler) RunwaySetCover(c *gin.Context) { // RunwayToggleDeleted 上下架(下架=is_deleted=1 对外不可见)。 func (h *BackstageHandler) RunwayToggleDeleted(c *gin.Context) { raw := c.Param("id") - _, numeric, err := hashid.DecodeTyped(raw) + numeric, err := hashid.DecodeWithType(raw, hashid.TypeRunway) if err != nil { renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "走秀不存在或已删除: " + raw}) return @@ -710,7 +710,7 @@ func (h *BackstageHandler) RunwayToggleDeleted(c *gin.Context) { // StreetSnapEdit 渲染街拍编辑表单。 func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) { raw := c.Param("id") - _, numeric, err := hashid.DecodeTyped(raw) + numeric, err := hashid.DecodeWithType(raw, hashid.TypeSnap) if err != nil { renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw}) return @@ -731,7 +731,7 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) { // StreetSnapUpdate 应用街拍字段编辑。 func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) { raw := c.Param("id") - _, numeric, err := hashid.DecodeTyped(raw) + numeric, err := hashid.DecodeWithType(raw, hashid.TypeSnap) if err != nil { renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw}) return @@ -757,7 +757,7 @@ func (h *BackstageHandler) StreetSnapUpdate(c *gin.Context) { // StreetSnapImageDelete 软删单张街拍图片。 func (h *BackstageHandler) StreetSnapImageDelete(c *gin.Context) { raw := c.Param("id") - if _, _, err := hashid.DecodeTyped(raw); err != nil { + if _, err := hashid.DecodeWithType(raw, hashid.TypeSnap); err != nil { renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw}) return } @@ -776,7 +776,7 @@ func (h *BackstageHandler) StreetSnapImageDelete(c *gin.Context) { // StreetSnapSetCover 把某张图片设为封面。 func (h *BackstageHandler) StreetSnapSetCover(c *gin.Context) { raw := c.Param("id") - _, numeric, err := hashid.DecodeTyped(raw) + numeric, err := hashid.DecodeWithType(raw, hashid.TypeSnap) if err != nil { renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw}) return @@ -796,7 +796,7 @@ func (h *BackstageHandler) StreetSnapSetCover(c *gin.Context) { // StreetSnapToggleDeleted 上下架。 func (h *BackstageHandler) StreetSnapToggleDeleted(c *gin.Context) { raw := c.Param("id") - _, numeric, err := hashid.DecodeTyped(raw) + numeric, err := hashid.DecodeWithType(raw, hashid.TypeSnap) if err != nil { renderHTML(c, http.StatusNotFound, tplError, gin.H{"Error": "街拍不存在或已删除: " + raw}) return diff --git a/internal/handler/street_snap_handler.go b/internal/handler/street_snap_handler.go index 7aa9bf7..f76425a 100644 --- a/internal/handler/street_snap_handler.go +++ b/internal/handler/street_snap_handler.go @@ -74,7 +74,9 @@ func (h *StreetSnapHandler) Detail(c *gin.Context) { response.Error(c, http.StatusBadRequest, "缺少街拍 id") return } - _, numeric, err := hashid.DecodeTyped(raw) + // 类型由路由提供(/street-snaps 即 snap)。类型已揉进密码:非 snap 编码的串 + // 在此解出的是错误主键,查库自然 404,从而强制前端按类型化路由分流(无需靠前缀嗅探)。 + numeric, err := hashid.DecodeWithType(raw, hashid.TypeSnap) if err != nil { response.Error(c, http.StatusNotFound, "街拍不存在") return @@ -82,8 +84,17 @@ func (h *StreetSnapHandler) Detail(c *gin.Context) { detail, err := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10)) if err != nil { - fail(c, err) - return + // 向后兼容:迁移前收藏/历史/旧外链可能仍带 s= 前缀(旧 EncodeTyped)。 + // 主线按类型化密码解码不到实体时,按旧编码再解一次回退(仅当旧类型确为 snap)。 + if ltyp, lnum, lerr := hashid.DecodeTyped(raw); lerr == nil && ltyp == hashid.TypeSnap { + if d2, e2 := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(lnum), 10)); e2 == nil { + detail, err = d2, nil + } + } + if err != nil { + fail(c, err) + return + } } // 未登录:图片集仅返回预览上限(PreviewLimit)张,并打 preview=true, @@ -96,7 +107,7 @@ func (h *StreetSnapHandler) Detail(c *gin.Context) { preview = true } - // 已登录:图集(gallery 级 s=)与每张图片(image 级 j=)收藏态随详情返回, + // 已登录:图集(gallery 级 TypeSnap)与每张图片(image 级 TypeSnapImage)收藏态随详情返回, // 前端无需再单独调 /me/favorites/checks。 if uid, ok := middleware.UserIDFrom(c); ok { ids := make([]string, 0, len(detail.Images)+1) diff --git a/internal/model/favorite.go b/internal/model/favorite.go index fdb5d24..8507974 100644 --- a/internal/model/favorite.go +++ b/internal/model/favorite.go @@ -4,13 +4,15 @@ package model // // 存储「对外编码 uid」而非数字主键,这样无需 decode 即可与前端卡片的 id 直接对齐, // 取消收藏时也只靠该串匹配。两种粒度共用一张表,由 kind 区分: -// - kind=gallery(默认):收藏整篇图集,target_uid 是图集 uid(r/s 前缀), +// - kind=gallery(默认):收藏整篇图集,target_uid 是图集 uid(类型由 TargetType 决定), // title / cover / brand 为收藏时的快照,供个人中心直接渲染,不必回查原表; -// - kind=image:收藏单张图片,target_uid 是图片自身的编码 id(i/j 前缀), -// image_url 存这张图,parent_uid 指向所属图集(用于从收藏回跳原图集)。 +// - kind=image:收藏单张图片,target_uid 是图片自身的编码 id,parent_uid 指向所属图集 +// (用于从收藏回跳原图集)。 // -// 两种粒度的 uid 前缀互不冲突(图集 r/s,图片 i/j),因此唯一键 (user_id, target_uid) -// 无需调整即可同时约束两种收藏。 +// 编码:新写入用 hashid.EncodeWithType(类型进密码、无前缀);历史行可能仍是带 r/s/i/j 前缀的 +// 旧 EncodeTyped 串,后端详情 handler 有向后兼容回退,故旧 uid 仍可正常解析。类型以 TargetType +// 列为准(runway | street | runway_image | snap_image),不再靠串前缀区分。 +// 两种粒度的 uid 因类型进密码、串值不同互不冲突,唯一键 (user_id, target_uid) 无需调整。 type Favorite struct { ID uint32 `gorm:"primaryKey;column:id" json:"id"` CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` diff --git a/internal/model/history.go b/internal/model/history.go index 88e33ed..4bbbc99 100644 --- a/internal/model/history.go +++ b/internal/model/history.go @@ -4,8 +4,8 @@ package model // // 2026-09-02 收敛为「文章级」记录:点开某篇走秀/街拍详情(/item/xxx)时记一条, // 进入列表页不再记。仅存三要素:user_id + target_uid + viewed_at。 -// target_uid 为带类型前缀的对外编码串(runway r=xxx / street s=xxx,见 hashid.EncodeTyped), -// 前缀本身携带类型,无需 kind / target_type 列;title/cover/brand 快照也已移除, +// target_uid 为对外编码串(类型进密码、无前缀,见 hashid.EncodeWithType);类型由账户页 +// 存储的 kind / target_type 或回查详情接口决定,串本身不含类型字母。title/cover/brand 快照已移除, // 账户页展示时按 target_uid 回查公开详情接口(保持表最简、数据新鲜)。 // 唯一键 (user_id, target_uid) 保证同一篇文章反复打开只刷新 viewed_at、不新增行。 type History struct { diff --git a/internal/pkg/hashid/hashid.go b/internal/pkg/hashid/hashid.go index aced7c7..3685ca5 100644 --- a/internal/pkg/hashid/hashid.go +++ b/internal/pkg/hashid/hashid.go @@ -50,7 +50,13 @@ func ensure() { // Feistel 网络的逆只需逆序执行轮函数,因此无论 round function 是否可逆都能精确还原。 // 加密轮:f 作用于右半块,左下一 = 右、右下一 = 左 ^ f(右)。 // 解密轮:f 作用于左半块(解密时左半块即上一轮的右半块),右下一 = 左、左下一 = 右 ^ f(左)。 +// 全局密钥版(品牌等无类型实体用);类型化编码见 feistelK。 func feistel(v uint32, encrypt bool) uint32 { + return feistelK(v, encrypt, keys) +} + +// feistelK 与 feistel 同构,但使用调用方提供的密钥(用于把实体类型揉进编码)。 +func feistelK(v uint32, encrypt bool, k [4]uint32) uint32 { const rounds = 8 l, r := uint16(v>>16), uint16(v&0xffff) for i := 0; i < rounds; i++ { @@ -60,7 +66,7 @@ func feistel(v uint32, encrypt bool) uint32 { } // round function:乘法扩散 + 密钥混合 + 高地位混淆,输出取低 16 位 round := func(h uint16) uint16 { - f := uint32(h)*0x9E3779B1 + keys[idx%4] + f := uint32(h)*0x9E3779B1 + k[idx%4] return uint16((f ^ (f >> 16)) & 0xffff) } if encrypt { @@ -76,8 +82,20 @@ func feistel(v uint32, encrypt bool) uint32 { return uint32(l)<<16 | uint32(r) } -func encodeNum(n uint32) string { - x := feistel(n, true) +// typeKeys 由全局混淆密钥与实体类型派生出 per-type 子密钥。 +// 因此同一数字主键在不同类型下得到完全不同的编码串,且不依赖首字母前缀来区分类型。 +// 仅作确定性混合(Feistel 自身用同密钥逆序还原,typeKeys 无需可逆)。 +func typeKeys(typ byte) [4]uint32 { + var k [4]uint32 + t := uint32(typ) * 0x9E3779B1 + for i := 0; i < 4; i++ { + k[i] = keys[i] ^ t ^ (uint32(typ) << (8*uint(i) + 1)) + } + return k +} + +// numToBase62 把 32-bit 值序列化为定长(minLen)base62 串,高位在前。 +func numToBase62(x uint32) string { var sb strings.Builder for x > 0 { sb.WriteByte(alphabet[x%base]) @@ -98,7 +116,8 @@ func encodeNum(n uint32) string { return out } -func decodeStr(s string) (uint32, error) { +// base62Parse 是 numToBase62 的逆;含非法字符返回 error。 +func base62Parse(s string) (uint32, error) { var x uint32 for _, c := range s { idx := strings.IndexRune(alphabet, c) @@ -107,9 +126,38 @@ func decodeStr(s string) (uint32, error) { } x = x*base + uint32(idx) } + return x, nil +} + +// encodeNum 用全局密钥编码(品牌等无类型实体)。 +func encodeNum(n uint32) string { + return numToBase62(feistel(n, true)) +} + +// decodeStr 用全局密钥解码(品牌等无类型实体)。 +func decodeStr(s string) (uint32, error) { + x, err := base62Parse(s) + if err != nil { + return 0, err + } return feistel(x, false), nil } +// encodeNumWithType 把类型揉进 Feistel 子密钥后编码,公开串不含类型字母, +// 且同类数字 id 在不同类型下完全不相关(runway#1 ≠ streetsnap#1)。 +func encodeNumWithType(n uint32, typ byte) string { + return numToBase62(feistelK(n, true, typeKeys(typ))) +} + +// decodeStrWithType 还原 encodeNumWithType,需传入与编码一致的 typ。 +func decodeStrWithType(s string, typ byte) (uint32, error) { + x, err := base62Parse(s) + if err != nil { + return 0, err + } + return feistelK(x, false, typeKeys(typ)), nil +} + // Encode 把数字主键编码为对外暴露的无序串。 func Encode(id uint32) string { ensure() @@ -125,8 +173,30 @@ func Decode(s string) (uint32, error) { return decodeStr(strings.TrimSpace(s)) } -// 类型前缀:让同一数字主键在不同实体下得到不同的编码串,前端 /item/[id] 可凭前缀区分实体类型, -// 从而把 runway / street snap 等详情统一收口到同一路由而无需担心 id 撞车。 +// EncodeWithType 把数字主键按实体类型编码为对外串:类型被揉进 Feistel 子密钥, +// 因此公开串既不含类型字母,同类数字 id 在不同类型下也完全不相关(runway#1 ≠ streetsnap#1), +// 外人无部署盐无法反解。前端按类型化路由(/runway-looks、/street-snaps)或存储 type 分流类型。 +// 这是新的公开编码方式,取代带前缀的 EncodeTyped。 +func EncodeWithType(id uint32, typ byte) string { + ensure() + return encodeNumWithType(id, typ) +} + +// DecodeWithType 还原 EncodeWithType 的串,需传入与编码一致的 typ(由路由/存储提供)。 +// 类型不符会解出错误主键,查库自然 404,从而强制按类型分流而非靠前缀嗅探。 +func DecodeWithType(s string, typ byte) (uint32, error) { + ensure() + if strings.TrimSpace(s) == "" { + return 0, errors.New("empty hashid") + } + return decodeStrWithType(strings.TrimSpace(s), typ) +} + +// 类型标签:r/s/i/j。两个用途: +// - 新编码 EncodeWithType(id, typ):作为 per-type 子密钥的种子揉进 Feistel(公开串无前缀)。 +// - 旧编码 EncodeTyped(typ, id):直接作为可见前缀拼在串前(仅 /item 301 与一次性迁移仍用)。 +// +// 前端按类型化路由(/runway-looks、/street-snaps)或存储 type 分流,后端按路由 typ 调 DecodeWithType。 const ( TypeRunway = 'r' // 走秀 / 文章 lookbook(brand_runway 表) TypeSnap = 's' // 街拍(street_snap 表) @@ -138,15 +208,15 @@ const ( TypeSnapImage = 'j' // 街拍单图(street_snap_images 表) ) -// EncodeTyped 在编码串前拼一个类型字符前缀(如 "r"+base62 串)。前缀字符取自 base62 字母表, -// 不参与 Feistel 还原、仅作类型标识。DecodeTyped 负责剥离前缀并还原数字主键。 +// EncodeTyped 旧编码:在串前拼一个类型字符前缀(如 "r"+base62 串)。仅 /item 301 与一次性 +// 迁移工具使用;新公开编码请用 EncodeWithType(无前缀、类型进密码)。 func EncodeTyped(t byte, id uint32) string { ensure() return string(t) + encodeNum(id) } -// DecodeTyped 还原带类型前缀的编码串,返回 (类型字符, 数字主键, error)。 -// 调用方通常忽略类型(路由已确定实体),只取数字主键查库;旧的无前缀串仍用 Decode。 +// DecodeTyped 还原旧带前缀编码串,返回 (类型字符, 数字主键, error)。仅 /item 301 与迁移工具使用; +// 新公开解码请用 DecodeWithType(raw, typ)(需路由/存储提供的 typ)。 func DecodeTyped(s string) (byte, uint32, error) { s = strings.TrimSpace(s) if len(s) < 2 { diff --git a/internal/pkg/hashid/hashid_test.go b/internal/pkg/hashid/hashid_test.go index 4c9d5f3..e14153f 100644 --- a/internal/pkg/hashid/hashid_test.go +++ b/internal/pkg/hashid/hashid_test.go @@ -73,3 +73,39 @@ func TestTyped(t *testing.T) { t.Fatal("长度不足 2 的 typed 串应返回错误") } } + +// TestTypedWithType 验证新编码(类型进密码、无前缀): +// - 往返严格成立 DecodeWithType(EncodeWithType(n, typ), typ) == n; +// - 同数字不同类别得到完全不相关的串(runway#5 ≠ snap#5),且串本身不含类型字母; +// - 用错误 typ 解码得到错误主键(≠ n),从而查库自然 404。 +func TestTypedWithType(t *testing.T) { + Init("test-salt") + + // 往返 + for _, typ := range []byte{TypeRunway, TypeSnap, TypeRunwayImage, TypeSnapImage} { + for _, n := range []uint32{0, 1, 5, 999, 1 << 20, (1 << 32) - 1} { + got, err := DecodeWithType(EncodeWithType(n, typ), typ) + if err != nil || got != n { + t.Fatalf("往返不一致: typ=%c n=%d got=%d err=%v", typ, n, got, err) + } + } + } + + // 同数字不同类别:串不同且不相关,且都不含类型字母 + r := EncodeWithType(5, TypeRunway) + s := EncodeWithType(5, TypeSnap) + if r == s { + t.Fatalf("同数字不同类别应得到不同串: runway=%q snap=%q", r, s) + } + if r[0] == TypeRunway || r[0] == TypeSnap || s[0] == TypeRunway || s[0] == TypeSnap { + t.Fatalf("新编码不应含类型字母前缀: runway=%q snap=%q", r, s) + } + // 用错误类别解码得不到原主键 + if wrong, err := DecodeWithType(r, TypeSnap); err != nil || wrong == 5 { + t.Fatalf("用错误类别解码应得不到原主键: got=%d err=%v", wrong, err) + } + // 空串报错 + if _, err := DecodeWithType("", TypeRunway); err == nil { + t.Fatal("空串应返回错误") + } +} diff --git a/internal/service/article_service.go b/internal/service/article_service.go index 85b5716..947f159 100644 --- a/internal/service/article_service.go +++ b/internal/service/article_service.go @@ -70,7 +70,7 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu for _, r := range rows { ids = append(ids, r.ID) list = append(list, dto.PublicArticle{ - UID: hashid.EncodeTyped(hashid.TypeRunway, r.ID), + UID: hashid.EncodeWithType(r.ID, hashid.TypeRunway), BrandUID: hashid.Encode(r.BrandID), Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale), Cover: s.img.ComposeThumb(r.Cover), // 列表封面走缩略图样式(小尺寸省流量) @@ -98,7 +98,7 @@ func (s *articleService) AdminList(ctx context.Context, q dto.ArticleQuery) ([]d list := make([]dto.AdminRunway, 0, len(rows)) for _, r := range rows { list = append(list, dto.AdminRunway{ - UID: hashid.EncodeTyped(hashid.TypeRunway, r.ID), + UID: hashid.EncodeWithType(r.ID, hashid.TypeRunway), BrandUID: hashid.Encode(r.BrandID), BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, "en"), Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale), @@ -190,7 +190,7 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string) ( } return &dto.PublicArticleDetail{ - UID: hashid.EncodeTyped(hashid.TypeRunway, row.ID), + UID: hashid.EncodeWithType(row.ID, hashid.TypeRunway), BrandUID: hashid.Encode(row.BrandID), Title: pickLocale(row.TitleEn, row.TitleCn, locale), Description: pickLocale(row.DescriptionEn, row.DescriptionCn, locale), @@ -274,7 +274,7 @@ func (s *articleService) toPublicImages(imgs []model.BrandRunwayImage) []dto.Pub out := make([]dto.PublicArticleImage, 0, len(imgs)) for _, im := range imgs { out = append(out, dto.PublicArticleImage{ - ID: hashid.EncodeTyped(hashid.TypeRunwayImage, im.ID), + ID: hashid.EncodeWithType(im.ID, hashid.TypeRunwayImage), Image: s.img.Compose(im.Image), Thumb: s.img.ComposeThumb(im.Image), Name: im.Name, @@ -294,7 +294,7 @@ func (s *articleService) toThumbImages(imgs []model.BrandRunwayImage) []dto.Publ out := make([]dto.PublicArticleImage, 0, len(imgs)) for _, im := range imgs { out = append(out, dto.PublicArticleImage{ - ID: hashid.EncodeTyped(hashid.TypeRunwayImage, im.ID), + ID: hashid.EncodeWithType(im.ID, hashid.TypeRunwayImage), Image: s.img.ComposeThumb(im.Image), Name: im.Name, IsDetail: im.IsDetail, diff --git a/internal/service/index_service.go b/internal/service/index_service.go index 50e2ea2..2ef0c11 100644 --- a/internal/service/index_service.go +++ b/internal/service/index_service.go @@ -51,8 +51,8 @@ func (s *indexService) Runway(ctx context.Context, locale string) (IndexRunwayDa brands := make([]dto.PopularBrand, 0, len(rows)) for _, b := range rows { brands = append(brands, dto.PopularBrand{ - UID: hashid.Encode(b.ID), - ArticleUID: hashid.Encode(b.ArticleID), + UID: hashid.Encode(b.ID), // 品牌无类型,继续用全局编码(brand handler 用 Decode 解码) + ArticleUID: hashid.EncodeWithType(b.ArticleID, hashid.TypeRunway), // 走秀详情链接用类型化编码 // 品牌名强制优先英文("en"),不随 locale 切换,保持英文观感。 Brand: pickLocale(b.BrandEn, b.BrandCn, "en"), Cover: s.img.Compose(b.Cover), // 首页热门卡片为大图展示,用高清样式(high) diff --git a/internal/service/street_snap_service.go b/internal/service/street_snap_service.go index ea94c37..0a3d9ce 100644 --- a/internal/service/street_snap_service.go +++ b/internal/service/street_snap_service.go @@ -67,7 +67,7 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([] for _, r := range rows { ids = append(ids, r.ID) list = append(list, dto.PublicStreetSnap{ - UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID), + UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap), Title: r.Title, Cover: s.img.ComposeThumb(r.Cover), // 列表封面走缩略图样式(小尺寸省流量) ImageCount: r.ImageCount, @@ -135,7 +135,7 @@ func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicS } return &dto.PublicStreetSnapDetail{ - UID: hashid.EncodeTyped(hashid.TypeSnap, snap.ID), + UID: hashid.EncodeWithType(snap.ID, hashid.TypeSnap), Title: snap.Title, Cover: s.img.Compose(snap.Cover), Images: s.toPublicSnapImages(imgs), @@ -152,7 +152,7 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi list := make([]dto.PublicStreetSnap, 0, len(rows)) for _, r := range rows { list = append(list, dto.PublicStreetSnap{ - UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID), + UID: hashid.EncodeWithType(r.ID, hashid.TypeSnap), Title: r.Title, Cover: s.img.Compose(r.Cover), // 首页热门街拍卡片为大图展示,用高清样式(high) ImageCount: r.ImageCount, @@ -162,7 +162,7 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi } // toPublicSnapImages 把图片实体映射为对外结构(url + 名称 + 图片自身的编码 id)。 -// ID 用 TypeSnapImage 前缀编码,与街拍 uid(s 前缀)区分开,供单图收藏定位。 +// ID 用 EncodeWithType(TypeSnapImage) 编码(类型进密码、无前缀),与街拍图集 uid 区分开,供单图收藏定位。 // Image 走展示样式(high);额外给出 Thumb(缩略图样式),供大图灯箱左栏缩略图条等 // 小尺寸场景使用(列表用的 toThumbSnapImages 不填 Thumb,其 Image 本身就是缩略图)。 func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage) []dto.PublicArticleImage { @@ -172,7 +172,7 @@ func (s *streetSnapService) toPublicSnapImages(imgs []model.StreetSnapImage) []d out := make([]dto.PublicArticleImage, 0, len(imgs)) for _, im := range imgs { out = append(out, dto.PublicArticleImage{ - ID: hashid.EncodeTyped(hashid.TypeSnapImage, im.ID), + ID: hashid.EncodeWithType(im.ID, hashid.TypeSnapImage), Image: s.img.Compose(im.Image), Thumb: s.img.ComposeThumb(im.Image), Name: im.Name, @@ -190,7 +190,7 @@ func (s *streetSnapService) toThumbSnapImages(imgs []model.StreetSnapImage) []dt out := make([]dto.PublicArticleImage, 0, len(imgs)) for _, im := range imgs { out = append(out, dto.PublicArticleImage{ - ID: hashid.EncodeTyped(hashid.TypeSnapImage, im.ID), + ID: hashid.EncodeWithType(im.ID, hashid.TypeSnapImage), Image: s.img.ComposeThumb(im.Image), Name: im.Name, })