update
This commit is contained in:
@ -83,7 +83,7 @@ func (h *ArticleHandler) Detail(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusBadRequest, "缺少文章 id")
|
response.Error(c, http.StatusBadRequest, "缺少文章 id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
numeric, err := hashid.Decode(raw)
|
_, numeric, err := hashid.DecodeTyped(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, http.StatusNotFound, "文章不存在")
|
response.Error(c, http.StatusNotFound, "文章不存在")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -70,7 +70,7 @@ func (h *StreetSnapHandler) Detail(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusBadRequest, "缺少街拍 id")
|
response.Error(c, http.StatusBadRequest, "缺少街拍 id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
numeric, err := hashid.Decode(raw)
|
_, numeric, err := hashid.DecodeTyped(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, http.StatusNotFound, "街拍不存在")
|
response.Error(c, http.StatusNotFound, "街拍不存在")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -124,3 +124,29 @@ func Decode(s string) (uint32, error) {
|
|||||||
}
|
}
|
||||||
return decodeStr(strings.TrimSpace(s))
|
return decodeStr(strings.TrimSpace(s))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 类型前缀:让同一数字主键在不同实体下得到不同的编码串,前端 /item/[id] 可凭前缀区分实体类型,
|
||||||
|
// 从而把 runway / street snap 等详情统一收口到同一路由而无需担心 id 撞车。
|
||||||
|
const (
|
||||||
|
TypeRunway = 'r' // 走秀 / 文章 lookbook(brand_runway 表)
|
||||||
|
TypeSnap = 's' // 街拍(street_snap 表)
|
||||||
|
)
|
||||||
|
|
||||||
|
// EncodeTyped 在编码串前拼一个类型字符前缀(如 "r"+base62 串)。前缀字符取自 base62 字母表,
|
||||||
|
// 不参与 Feistel 还原、仅作类型标识。DecodeTyped 负责剥离前缀并还原数字主键。
|
||||||
|
func EncodeTyped(t byte, id uint32) string {
|
||||||
|
ensure()
|
||||||
|
return string(t) + encodeNum(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeTyped 还原带类型前缀的编码串,返回 (类型字符, 数字主键, error)。
|
||||||
|
// 调用方通常忽略类型(路由已确定实体),只取数字主键查库;旧的无前缀串仍用 Decode。
|
||||||
|
func DecodeTyped(s string) (byte, uint32, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) < 2 {
|
||||||
|
return 0, 0, errors.New("invalid typed hashid")
|
||||||
|
}
|
||||||
|
t := s[0]
|
||||||
|
n, err := decodeStr(s[1:])
|
||||||
|
return t, n, err
|
||||||
|
}
|
||||||
|
|||||||
@ -40,3 +40,36 @@ func TestInvalid(t *testing.T) {
|
|||||||
t.Fatal("含非法字符应返回错误")
|
t.Fatal("含非法字符应返回错误")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTyped 验证带类型前缀的编解码:同数字不同前缀得到不同串,且能正确还原类型与数字主键。
|
||||||
|
func TestTyped(t *testing.T) {
|
||||||
|
Init("test-salt")
|
||||||
|
|
||||||
|
r := EncodeTyped(TypeRunway, 5)
|
||||||
|
s := EncodeTyped(TypeSnap, 5)
|
||||||
|
if r == s {
|
||||||
|
t.Fatalf("同数字不同前缀应得到不同串: runway=%q snap=%q", r, s)
|
||||||
|
}
|
||||||
|
if r[0] != TypeRunway || s[0] != TypeSnap {
|
||||||
|
t.Fatalf("前缀字符不正确: runway=%q snap=%q", r, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
tr, nr, err := DecodeTyped(r)
|
||||||
|
if err != nil || tr != TypeRunway || nr != 5 {
|
||||||
|
t.Fatalf("DecodeTyped(runway) 失败: type=%c num=%d err=%v", tr, nr, err)
|
||||||
|
}
|
||||||
|
ts, ns, err := DecodeTyped(s)
|
||||||
|
if err != nil || ts != TypeSnap || ns != 5 {
|
||||||
|
t.Fatalf("DecodeTyped(snap) 失败: type=%c num=%d err=%v", ts, ns, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无前缀旧串仍可用 Decode 还原(兼容历史链接)
|
||||||
|
old := Encode(5)
|
||||||
|
if no, err := Decode(old); err != nil || no != 5 {
|
||||||
|
t.Fatalf("旧无前缀串解码失败: num=%d err=%v", no, err)
|
||||||
|
}
|
||||||
|
// 长度不足 2 视为非法
|
||||||
|
if _, _, err := DecodeTyped("r"); err == nil {
|
||||||
|
t.Fatal("长度不足 2 的 typed 串应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -40,7 +40,7 @@ func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.Pu
|
|||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
ids = append(ids, r.ID)
|
ids = append(ids, r.ID)
|
||||||
list = append(list, dto.PublicArticle{
|
list = append(list, dto.PublicArticle{
|
||||||
UID: hashid.Encode(r.ID),
|
UID: hashid.EncodeTyped(hashid.TypeRunway, r.ID),
|
||||||
BrandUID: hashid.Encode(r.BrandID),
|
BrandUID: hashid.Encode(r.BrandID),
|
||||||
Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale),
|
Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale),
|
||||||
Cover: r.Cover,
|
Cover: r.Cover,
|
||||||
@ -69,6 +69,8 @@ func (s *articleService) attachImages(ctx context.Context, list []dto.PublicArti
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// 缩略图条带剔除主图(cover),避免与卡片主图重复。cover 恒等于图片列表首张。
|
||||||
|
imgs = excludeCover(imgs, list[i].Cover)
|
||||||
if len(imgs) > limit {
|
if len(imgs) > limit {
|
||||||
imgs = imgs[:limit]
|
imgs = imgs[:limit]
|
||||||
}
|
}
|
||||||
@ -77,6 +79,21 @@ func (s *articleService) attachImages(ctx context.Context, list []dto.PublicArti
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// excludeCover 从图片列表中去掉与主图(cover)同 URL 的元素,供列表缩略图条带使用。
|
||||||
|
// 详情接口(完整图集)不走此函数,封面作为其中一张 look 需保留。
|
||||||
|
func excludeCover(imgs []model.BrandRunwayImage, cover string) []model.BrandRunwayImage {
|
||||||
|
if cover == "" {
|
||||||
|
return imgs
|
||||||
|
}
|
||||||
|
kept := imgs[:0]
|
||||||
|
for _, im := range imgs {
|
||||||
|
if im.Image != cover {
|
||||||
|
kept = append(kept, im)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言。
|
// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言。
|
||||||
func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
|
func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) {
|
||||||
row, err := s.articles.FindByID(ctx, id)
|
row, err := s.articles.FindByID(ctx, id)
|
||||||
@ -93,7 +110,7 @@ func (s *articleService) Detail(ctx context.Context, id string, locale string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &dto.PublicArticleDetail{
|
return &dto.PublicArticleDetail{
|
||||||
UID: hashid.Encode(row.ID),
|
UID: hashid.EncodeTyped(hashid.TypeRunway, row.ID),
|
||||||
BrandUID: hashid.Encode(row.BrandID),
|
BrandUID: hashid.Encode(row.BrandID),
|
||||||
Title: pickLocale(row.TitleEn, row.TitleCn, locale),
|
Title: pickLocale(row.TitleEn, row.TitleCn, locale),
|
||||||
Description: pickLocale(row.DescriptionEn, row.DescriptionCn, locale),
|
Description: pickLocale(row.DescriptionEn, row.DescriptionCn, locale),
|
||||||
|
|||||||
@ -45,7 +45,7 @@ func (s *streetSnapService) List(ctx context.Context, q dto.StreetSnapQuery) ([]
|
|||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
ids = append(ids, r.ID)
|
ids = append(ids, r.ID)
|
||||||
list = append(list, dto.PublicStreetSnap{
|
list = append(list, dto.PublicStreetSnap{
|
||||||
UID: hashid.Encode(r.ID),
|
UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID),
|
||||||
Title: r.Title,
|
Title: r.Title,
|
||||||
Cover: r.Cover,
|
Cover: r.Cover,
|
||||||
ImageCount: r.ImageCount,
|
ImageCount: r.ImageCount,
|
||||||
@ -71,6 +71,8 @@ func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicS
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// 缩略图条带剔除主图(cover),避免与卡片主图重复。cover 恒等于图片列表首张。
|
||||||
|
imgs = excludeCoverSnap(imgs, list[i].Cover)
|
||||||
if len(imgs) > limit {
|
if len(imgs) > limit {
|
||||||
imgs = imgs[:limit]
|
imgs = imgs[:limit]
|
||||||
}
|
}
|
||||||
@ -79,6 +81,20 @@ func (s *streetSnapService) attachImages(ctx context.Context, list []dto.PublicS
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// excludeCoverSnap 同 excludeCover,作用于街拍图片列表。
|
||||||
|
func excludeCoverSnap(imgs []model.StreetSnapImage, cover string) []model.StreetSnapImage {
|
||||||
|
if cover == "" {
|
||||||
|
return imgs
|
||||||
|
}
|
||||||
|
kept := imgs[:0]
|
||||||
|
for _, im := range imgs {
|
||||||
|
if im.Image != cover {
|
||||||
|
kept = append(kept, im)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
// Detail 查询街拍详情,含完整图片集。
|
// Detail 查询街拍详情,含完整图片集。
|
||||||
func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicStreetSnapDetail, error) {
|
||||||
snap, err := s.repo.FindByID(ctx, id)
|
snap, err := s.repo.FindByID(ctx, id)
|
||||||
@ -95,7 +111,7 @@ func (s *streetSnapService) Detail(ctx context.Context, id string) (*dto.PublicS
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &dto.PublicStreetSnapDetail{
|
return &dto.PublicStreetSnapDetail{
|
||||||
UID: hashid.Encode(snap.ID),
|
UID: hashid.EncodeTyped(hashid.TypeSnap, snap.ID),
|
||||||
Title: snap.Title,
|
Title: snap.Title,
|
||||||
Cover: snap.Cover,
|
Cover: snap.Cover,
|
||||||
Images: toPublicSnapImages(imgs),
|
Images: toPublicSnapImages(imgs),
|
||||||
@ -112,7 +128,7 @@ func (s *streetSnapService) Popular(ctx context.Context, limit int) ([]dto.Publi
|
|||||||
list := make([]dto.PublicStreetSnap, 0, len(rows))
|
list := make([]dto.PublicStreetSnap, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
list = append(list, dto.PublicStreetSnap{
|
list = append(list, dto.PublicStreetSnap{
|
||||||
UID: hashid.Encode(r.ID),
|
UID: hashid.EncodeTyped(hashid.TypeSnap, r.ID),
|
||||||
Title: r.Title,
|
Title: r.Title,
|
||||||
Cover: r.Cover,
|
Cover: r.Cover,
|
||||||
ImageCount: r.ImageCount,
|
ImageCount: r.ImageCount,
|
||||||
|
|||||||
Reference in New Issue
Block a user