159 lines
5.0 KiB
Go
159 lines
5.0 KiB
Go
// 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
|
||
}
|