Files
backend_v2/internal/pkg/hashid/hashid.go
toom1996 ffe84efc4c update
2026-09-03 00:32:52 +08:00

159 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package hashid 把自增主键(uint32)编码为无序、URL 安全的短串,
// 用于公开接口对外暴露,避免爬虫按 1,2,3... 顺序枚举全部文章/品牌。
//
// 设计要点:
// - 内部仍用数字主键,仅对外序列化时编码、入参时解码,DB 与内部逻辑完全不变。
// - 编码基于 32-bit 平衡 Feistel 网络(密钥由部署盐值派生)+ base62,
// 是真实双射:Decode(Encode(n)) == n 严格成立,解码即可还原主键。
// - 非顺序:相邻 id 的编码结果无规律,无法 +1 遍历;盐值不同编码结果不同。
// - 零外部依赖;字母表 0-9a-zA-Z 全部 URL 安全。
package hashid
import (
"crypto/sha256"
"encoding/binary"
"errors"
"strings"
)
const (
alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
base = 62
minLen = 8
)
var (
keys [4]uint32
inited bool
)
// Init 用部署级盐值初始化混淆密钥。盐值为空时使用内置默认(仅防顺序枚举,不算安全)。
// 必须在服务启动时调用一次(main.go 加载配置后)。
func Init(secret string) {
if secret == "" {
secret = "fashion-archive-default-salt-change-me"
}
h := sha256.Sum256([]byte(secret))
for i := 0; i < 4; i++ {
keys[i] = binary.BigEndian.Uint32(h[i*4 : i*4+4])
}
inited = true
}
func ensure() {
if !inited {
Init("")
}
}
// feistel 32-bit 平衡 Feistel 网络。encrypt=true 加密,false 解密。
// Feistel 网络的逆只需逆序执行轮函数,因此无论 round function 是否可逆都能精确还原。
// 加密轮:f 作用于右半块,左下一 = 右、右下一 = 左 ^ f(右)。
// 解密轮:f 作用于左半块(解密时左半块即上一轮的右半块),右下一 = 左、左下一 = 右 ^ f(左)。
func feistel(v uint32, encrypt bool) uint32 {
const rounds = 8
l, r := uint16(v>>16), uint16(v&0xffff)
for i := 0; i < rounds; i++ {
idx := i
if !encrypt {
idx = rounds - 1 - i
}
// round function:乘法扩散 + 密钥混合 + 高地位混淆,输出取低 16 位
round := func(h uint16) uint16 {
f := uint32(h)*0x9E3779B1 + keys[idx%4]
return uint16((f ^ (f >> 16)) & 0xffff)
}
if encrypt {
nl := r
nr := l ^ round(r)
l, r = nl, nr
} else {
nl := r ^ round(l)
nr := l
l, r = nl, nr
}
}
return uint32(l)<<16 | uint32(r)
}
func encodeNum(n uint32) string {
x := feistel(n, true)
var sb strings.Builder
for x > 0 {
sb.WriteByte(alphabet[x%base])
x /= base
}
if sb.Len() == 0 {
sb.WriteByte(alphabet[0])
}
// base62 低位在前,反转成高位在前
runes := []rune(sb.String())
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
out := string(runes)
if len(out) < minLen {
out = strings.Repeat("0", minLen-len(out)) + out
}
return out
}
func decodeStr(s string) (uint32, error) {
var x uint32
for _, c := range s {
idx := strings.IndexRune(alphabet, c)
if idx < 0 {
return 0, errors.New("invalid hashid: 含非法字符")
}
x = x*base + uint32(idx)
}
return feistel(x, false), nil
}
// Encode 把数字主键编码为对外暴露的无序串。
func Encode(id uint32) string {
ensure()
return encodeNum(id)
}
// Decode 把对外串还原为数字主键;非法串返回 error(调用方应视为 404/未找到)。
func Decode(s string) (uint32, error) {
ensure()
if strings.TrimSpace(s) == "" {
return 0, errors.New("empty hashid")
}
return decodeStr(strings.TrimSpace(s))
}
// 类型前缀:让同一数字主键在不同实体下得到不同的编码串,前端 /item/[id] 可凭前缀区分实体类型,
// 从而把 runway / street snap 等详情统一收口到同一路由而无需担心 id 撞车。
const (
TypeRunway = 'r' // 走秀 / 文章 lookbook(brand_runway 表)
TypeSnap = 's' // 街拍(street_snap 表)
// 图片级前缀:单张图片也要有对外标识(图片收藏用)。
// 与图集前缀(r/s)互不冲突,因此同一数字主键在图集与图片下得到不同编码串,
// 收藏表可用 target_uid 同时容纳两种粒度而不撞车;后续也能凭前缀反查所属表。
TypeRunwayImage = 'i' // 走秀单图(brand_runway_images 表)
TypeSnapImage = 'j' // 街拍单图(street_snap_images 表)
)
// 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
}