update
This commit is contained in:
126
internal/pkg/hashid/hashid.go
Normal file
126
internal/pkg/hashid/hashid.go
Normal file
@ -0,0 +1,126 @@
|
||||
// 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))
|
||||
}
|
||||
42
internal/pkg/hashid/hashid_test.go
Normal file
42
internal/pkg/hashid/hashid_test.go
Normal file
@ -0,0 +1,42 @@
|
||||
package hashid
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRoundtrip(t *testing.T) {
|
||||
// 覆盖边界与大量随机值,验证 Decode(Encode(n)) == n 严格成立
|
||||
cases := []uint32{0, 1, 2, 3, 7, 15, 16, 255, 256, 4095, 4096, 65535, 65536, 1 << 31, (1 << 32) - 1}
|
||||
for _, n := range cases {
|
||||
got, err := Decode(Encode(n))
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(Encode(%d)) 返回错误: %v", n, err)
|
||||
}
|
||||
if got != n {
|
||||
t.Fatalf("往返不一致: 输入 %d, 得到 %d", n, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 随机大批量
|
||||
for n := uint32(1); n < 5000; n++ {
|
||||
got, err := Decode(Encode(n))
|
||||
if err != nil || got != n {
|
||||
t.Fatalf("往返不一致: 输入 %d, 得到 %d (err=%v)", n, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonSequential(t *testing.T) {
|
||||
Init("test-salt")
|
||||
a, b, c := Encode(100), Encode(101), Encode(102)
|
||||
if a == b || b == c || a == c {
|
||||
t.Fatalf("相邻 id 编码结果出现了相等: %s %s %s", a, b, c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalid(t *testing.T) {
|
||||
if _, err := Decode(""); err == nil {
|
||||
t.Fatal("空串应返回错误")
|
||||
}
|
||||
if _, err := Decode("!!!"); err == nil {
|
||||
t.Fatal("含非法字符应返回错误")
|
||||
}
|
||||
}
|
||||
82
internal/pkg/jwt/jwt.go
Normal file
82
internal/pkg/jwt/jwt.go
Normal file
@ -0,0 +1,82 @@
|
||||
// Package jwt 封装 JWT 令牌的签发与解析。
|
||||
//
|
||||
// Claims 结构(uid / username / email / exp)与签名算法(HS256)保持与原项目一致,
|
||||
// 因此原后端签发的 token 在本服务中依然有效(相同 secret 前提下)。
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
jwtlib "github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// ErrInvalidToken 表示令牌缺失、过期或签名不合法。
|
||||
var ErrInvalidToken = errors.New("invalid token")
|
||||
|
||||
// Claims 从令牌中解析出的用户身份信息。
|
||||
type Claims struct {
|
||||
UserID uint32
|
||||
Username string
|
||||
Email string
|
||||
}
|
||||
|
||||
// Manager 令牌签发器。通过构造函数注入密钥与有效期,避免每次调用都重新读取配置
|
||||
// (原项目在 genToken/中间件里各自调用 config.Load(),属于重复解析)。
|
||||
type Manager struct {
|
||||
secret []byte
|
||||
expire time.Duration
|
||||
}
|
||||
|
||||
// NewManager 创建令牌签发器。expireHours <= 0 时回落为 7 天。
|
||||
func NewManager(secret string, expireHours int) *Manager {
|
||||
if expireHours <= 0 {
|
||||
expireHours = 168
|
||||
}
|
||||
return &Manager{
|
||||
secret: []byte(secret),
|
||||
expire: time.Duration(expireHours) * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// Generate 签发令牌。
|
||||
func (m *Manager) Generate(userID uint32, username, email string) (string, error) {
|
||||
claims := jwtlib.MapClaims{
|
||||
"uid": userID,
|
||||
"username": username,
|
||||
"email": email,
|
||||
"exp": time.Now().Add(m.expire).Unix(),
|
||||
}
|
||||
return jwtlib.NewWithClaims(jwtlib.SigningMethodHS256, claims).SignedString(m.secret)
|
||||
}
|
||||
|
||||
// Parse 校验并解析令牌。
|
||||
func (m *Manager) Parse(tokenStr string) (*Claims, error) {
|
||||
token, err := jwtlib.Parse(tokenStr, func(t *jwtlib.Token) (any, error) {
|
||||
// 只接受 HMAC 签名,防止 alg 混淆攻击
|
||||
if _, ok := t.Method.(*jwtlib.SigningMethodHMAC); !ok {
|
||||
return nil, jwtlib.ErrSignatureInvalid
|
||||
}
|
||||
return m.secret, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
raw, ok := token.Claims.(jwtlib.MapClaims)
|
||||
if !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
c := &Claims{}
|
||||
// JSON 数字统一解析为 float64
|
||||
if uid, ok := raw["uid"].(float64); ok {
|
||||
c.UserID = uint32(uid)
|
||||
}
|
||||
if v, ok := raw["username"].(string); ok {
|
||||
c.Username = v
|
||||
}
|
||||
if v, ok := raw["email"].(string); ok {
|
||||
c.Email = v
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
52
internal/pkg/response/response.go
Normal file
52
internal/pkg/response/response.go
Normal file
@ -0,0 +1,52 @@
|
||||
// Package response 统一 HTTP 响应结构。
|
||||
//
|
||||
// 响应形状严格沿用原项目,前端 (src/lib/api.ts、data.ts) 依赖这些字段名:
|
||||
// - 列表:{ data, total, current_page, last_page, per_page }
|
||||
// - 详情:{ data }
|
||||
// - 错误:{ error }
|
||||
//
|
||||
// 集中在此处定义,避免各 handler 手写 gin.H 时出现字段拼写漂移。
|
||||
package response
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// Data 返回单个对象:{ "data": v }。
|
||||
func Data(c *gin.Context, status int, v any) {
|
||||
c.JSON(status, gin.H{"data": v})
|
||||
}
|
||||
|
||||
// Page 返回分页列表:{ data, total, current_page, last_page, per_page }。
|
||||
func Page(c *gin.Context, status int, data any, total int64, page, size int) {
|
||||
c.JSON(status, gin.H{
|
||||
"data": data,
|
||||
"total": total,
|
||||
"current_page": page,
|
||||
"last_page": LastPage(total, size),
|
||||
"per_page": size,
|
||||
})
|
||||
}
|
||||
|
||||
// Error 返回错误:{ "error": msg }。
|
||||
func Error(c *gin.Context, status int, msg string) {
|
||||
c.JSON(status, gin.H{"error": msg})
|
||||
}
|
||||
|
||||
// AbortError 中断请求并返回错误,供中间件使用。
|
||||
func AbortError(c *gin.Context, status int, msg string) {
|
||||
c.AbortWithStatusJSON(status, gin.H{"error": msg})
|
||||
}
|
||||
|
||||
// LastPage 计算总页数;无数据时返回 1(与原项目一致,避免前端出现 0 页)。
|
||||
func LastPage(total int64, size int) int {
|
||||
if size <= 0 {
|
||||
return 1
|
||||
}
|
||||
last := int(total) / size
|
||||
if int(total)%size > 0 {
|
||||
last++
|
||||
}
|
||||
if last == 0 {
|
||||
last = 1
|
||||
}
|
||||
return last
|
||||
}
|
||||
13
internal/pkg/textutil/textutil.go
Normal file
13
internal/pkg/textutil/textutil.go
Normal file
@ -0,0 +1,13 @@
|
||||
// Package textutil 提供文本处理辅助函数。
|
||||
package textutil
|
||||
|
||||
// Summarize 按 rune 截断字符串,避免中文被按字节切断;超出部分追加省略号。
|
||||
//
|
||||
// 行为与原项目 handlers.summarize 完全一致(含省略号字符 U+2026)。
|
||||
func Summarize(s string, n int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
}
|
||||
return string(runes[:n]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user