// 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_runway 表) TypeSnap = 's' // 街拍(street_snap 表) // 图片级前缀:单张图片也要有对外标识(图片收藏用)。 // 与图集前缀(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 }