This commit is contained in:
toom1996
2026-08-26 10:45:21 +08:00
parent 3cf1368e68
commit 30c9f21da4
48 changed files with 3442 additions and 0 deletions

View 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))
}

View 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
View 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
}

View 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
}

View 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]) + "…"
}