Files
2026-09-20 01:06:13 +08:00

229 lines
7.8 KiB
Go
Raw Permalink 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(左)。
// 全局密钥版(品牌等无类型实体用);类型化编码见 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
}