This commit is contained in:
toom1996
2026-09-16 00:56:15 +08:00
parent 5e4803b97f
commit 7dd3fda73c
10 changed files with 184 additions and 54 deletions

View File

@ -50,7 +50,13 @@ func ensure() {
// 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++ {
@ -60,7 +66,7 @@ func feistel(v uint32, encrypt bool) uint32 {
}
// round function:乘法扩散 + 密钥混合 + 高地位混淆,输出取低 16 位
round := func(h uint16) uint16 {
f := uint32(h)*0x9E3779B1 + keys[idx%4]
f := uint32(h)*0x9E3779B1 + k[idx%4]
return uint16((f ^ (f >> 16)) & 0xffff)
}
if encrypt {
@ -76,8 +82,20 @@ func feistel(v uint32, encrypt bool) uint32 {
return uint32(l)<<16 | uint32(r)
}
func encodeNum(n uint32) string {
x := feistel(n, true)
// 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])
@ -98,7 +116,8 @@ func encodeNum(n uint32) string {
return out
}
func decodeStr(s string) (uint32, error) {
// base62Parse 是 numToBase62 的逆;含非法字符返回 error。
func base62Parse(s string) (uint32, error) {
var x uint32
for _, c := range s {
idx := strings.IndexRune(alphabet, c)
@ -107,9 +126,38 @@ func decodeStr(s string) (uint32, error) {
}
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()
@ -125,8 +173,30 @@ func Decode(s string) (uint32, error) {
return decodeStr(strings.TrimSpace(s))
}
// 类型前缀:让同一数字主键在不同实体下得到不同的编码串,前端 /item/[id] 可凭前缀区分实体类型,
// 从而把 runway / street snap 等详情统一收口到同一路由而无需担心 id 撞车。
// 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 表)
@ -138,15 +208,15 @@ const (
TypeSnapImage = 'j' // 街拍单图(street_snap_images 表)
)
// EncodeTyped 在编码串前拼一个类型字符前缀(如 "r"+base62 串)。前缀字符取自 base62 字母表,
// 不参与 Feistel 还原、仅作类型标识。DecodeTyped 负责剥离前缀并还原数字主键。
// EncodeTyped 旧编码:在串前拼一个类型字符前缀(如 "r"+base62 串)。仅 /item 301 与一次性
// 迁移工具使用;新公开编码请用 EncodeWithType(无前缀、类型进密码)。
func EncodeTyped(t byte, id uint32) string {
ensure()
return string(t) + encodeNum(id)
}
// DecodeTyped 还原带类型前缀的编码串,返回 (类型字符, 数字主键, error)。
// 调用方通常忽略类型(路由已确定实体),只取数字主键查库;旧的无前缀串仍用 Decode。
// DecodeTyped 还原旧带前缀编码串,返回 (类型字符, 数字主键, error)。仅 /item 301 与迁移工具使用;
// 新公开解码请用 DecodeWithType(raw, typ)(需路由/存储提供的 typ)。
func DecodeTyped(s string) (byte, uint32, error) {
s = strings.TrimSpace(s)
if len(s) < 2 {

View File

@ -73,3 +73,39 @@ func TestTyped(t *testing.T) {
t.Fatal("长度不足 2 的 typed 串应返回错误")
}
}
// TestTypedWithType 验证新编码(类型进密码、无前缀):
// - 往返严格成立 DecodeWithType(EncodeWithType(n, typ), typ) == n;
// - 同数字不同类别得到完全不相关的串(runway#5 ≠ snap#5),且串本身不含类型字母;
// - 用错误 typ 解码得到错误主键(≠ n),从而查库自然 404。
func TestTypedWithType(t *testing.T) {
Init("test-salt")
// 往返
for _, typ := range []byte{TypeRunway, TypeSnap, TypeRunwayImage, TypeSnapImage} {
for _, n := range []uint32{0, 1, 5, 999, 1 << 20, (1 << 32) - 1} {
got, err := DecodeWithType(EncodeWithType(n, typ), typ)
if err != nil || got != n {
t.Fatalf("往返不一致: typ=%c n=%d got=%d err=%v", typ, n, got, err)
}
}
}
// 同数字不同类别:串不同且不相关,且都不含类型字母
r := EncodeWithType(5, TypeRunway)
s := EncodeWithType(5, TypeSnap)
if r == s {
t.Fatalf("同数字不同类别应得到不同串: runway=%q snap=%q", r, s)
}
if r[0] == TypeRunway || r[0] == TypeSnap || s[0] == TypeRunway || s[0] == TypeSnap {
t.Fatalf("新编码不应含类型字母前缀: runway=%q snap=%q", r, s)
}
// 用错误类别解码得不到原主键
if wrong, err := DecodeWithType(r, TypeSnap); err != nil || wrong == 5 {
t.Fatalf("用错误类别解码应得不到原主键: got=%d err=%v", wrong, err)
}
// 空串报错
if _, err := DecodeWithType("", TypeRunway); err == nil {
t.Fatal("空串应返回错误")
}
}