update
This commit is contained in:
@ -89,17 +89,28 @@ 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
|
||||
}
|
||||
|
||||
detail, err := h.articles.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), parseLocale(c))
|
||||
if err != nil {
|
||||
// 向后兼容:迁移前收藏/历史/旧外链可能仍带 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,前端据此展示
|
||||
// 「登录查看全部图片」门禁。细节图(Detail 子数组)不额外剥离 —— 游客也能看前几张主图的细节,
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -74,17 +74,28 @@ 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
|
||||
}
|
||||
|
||||
detail, err := h.svc.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10))
|
||||
if err != nil {
|
||||
// 向后兼容:迁移前收藏/历史/旧外链可能仍带 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,
|
||||
// 前端据此在详情页展示「登录查看全部图片」门禁;已登录(带有效 Bearer)返回完整图片集。
|
||||
@ -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)
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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("空串应返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user