229 lines
7.8 KiB
Go
229 lines
7.8 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(左)。
|
||
// 全局密钥版(品牌等无类型实体用);类型化编码见 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++ {
|
||
idx := i
|
||
if !encrypt {
|
||
idx = rounds - 1 - i
|
||
}
|
||
// round function:乘法扩散 + 密钥混合 + 高地位混淆,输出取低 16 位
|
||
round := func(h uint16) uint16 {
|
||
f := uint32(h)*0x9E3779B1 + k[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)
|
||
}
|
||
|
||
// 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])
|
||
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
|
||
}
|
||
|
||
// base62Parse 是 numToBase62 的逆;含非法字符返回 error。
|
||
func base62Parse(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 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()
|
||
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))
|
||
}
|
||
|
||
// 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_runways 表)
|
||
TypeSnap = 's' // 街拍(street_snaps 表)
|
||
|
||
// 图片级前缀:单张图片也要有对外标识(图片收藏用)。
|
||
// 与图集前缀(r/s)互不冲突,因此同一数字主键在图集与图片下得到不同编码串,
|
||
// 收藏表可用 target_uid 同时容纳两种粒度而不撞车;后续也能凭前缀反查所属表。
|
||
TypeRunwayImage = 'i' // 走秀单图(brand_runway_images 表)
|
||
TypeSnapImage = 'j' // 街拍单图(street_snap_images 表)
|
||
)
|
||
|
||
// EncodeTyped 旧编码:在串前拼一个类型字符前缀(如 "r"+base62 串)。仅 /item 301 与一次性
|
||
// 迁移工具使用;新公开编码请用 EncodeWithType(无前缀、类型进密码)。
|
||
func EncodeTyped(t byte, id uint32) string {
|
||
ensure()
|
||
return string(t) + encodeNum(id)
|
||
}
|
||
|
||
// DecodeTyped 还原旧带前缀编码串,返回 (类型字符, 数字主键, error)。仅 /item 301 与迁移工具使用;
|
||
// 新公开解码请用 DecodeWithType(raw, typ)(需路由/存储提供的 typ)。
|
||
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
|
||
}
|