refactor(layout): consolidate backend codebase into backend/ package and clean root directory

- Moved cmd/, core/, plugins/, pkg/, downstream/, and main.go into backend/ directory
- Batch updated all Go source files to import github.com/Rain-kl/Wavelet/backend/...
- Updated Makefile, scripts/swagger.sh, architecture guards, and platform skills
- Passed all quality gates (100% tests, 0 lint issues, clean build)
This commit is contained in:
ryan
2026-08-28 12:56:02 +08:00
parent 33b38f8687
commit 43dc97e48c
319 changed files with 912 additions and 1031 deletions
+10
View File
@@ -0,0 +1,10 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
const (
errCreateHTTPRequestFailed = "创建HTTP请求失败: %w"
errHTTPRequestFailed = "请求%s接口失败: %w"
errInvalidCustomValue = "invalid value: %v"
)
+160
View File
@@ -0,0 +1,160 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package util provides generic utility functions.
package util
import (
"crypto/aes"
"crypto/cipher"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
)
const (
aesKeyLength = 32
errInvalidSignKey = "invalid sign key: %w"
errSignKeyLengthInvalid = "sign key must be 32 bytes (64 hex characters)"
errCreateCipherFailed = "failed to create cipher: %w"
errCreateGCMFailed = "failed to create GCM: %w"
errGenerateNonceFailed = "failed to generate nonce: %w"
errDecodeCiphertextFailed = "failed to decode ciphertext: %w"
errCiphertextTooShort = "ciphertext too short"
errDecryptFailed = "failed to decrypt: %w"
)
// Encrypt 使用 SignKey 加密字符串数据
// signKey: 64 字符 hex 编码的密钥(对应 32 字节,用于 AES-256)
// plaintext: 要加密的明文字符串
// return: base64 编码的密文
func Encrypt(signKey string, plaintext string) (string, error) {
return encryptBytes(signKey, []byte(plaintext))
}
// Decrypt 使用 SignKey 解密字符串数据
// signKey: 64 字符 hex 编码的密钥(对应 32 字节,用于 AES-256)
// ciphertext: base64 编码的密文
// return: 解密后的明文字符串
func Decrypt(signKey string, ciphertext string) (string, error) {
plaintext, err := decryptBytes(signKey, ciphertext)
if err != nil {
return "", err
}
return string(plaintext), nil
}
// encryptBytes 加密函数,处理字节数据
func encryptBytes(signKey string, plaintext []byte) (string, error) {
// 将 hex 编码的密钥转换为字节
key, err := hex.DecodeString(signKey)
if err != nil {
return "", fmt.Errorf(errInvalidSignKey, err)
}
if len(key) != aesKeyLength {
return "", errors.New(errSignKeyLengthInvalid)
}
// 创建 AES cipher
block, err := aes.NewCipher(key)
if err != nil {
return "", fmt.Errorf(errCreateCipherFailed, err)
}
// 使用 GCM 模式(Galois/Counter Mode)
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf(errCreateGCMFailed, err)
}
// 生成随机 nonce
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf(errGenerateNonceFailed, err)
}
// 加密数据
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
// 返回 base64 编码的密文
return Base64Encode(ciphertext), nil
}
// decryptBytes 解密函数,处理字节数据
func decryptBytes(signKey string, ciphertext string) ([]byte, error) {
// 将 hex 编码的密钥转换为字节
key, err := hex.DecodeString(signKey)
if err != nil {
return nil, fmt.Errorf(errInvalidSignKey, err)
}
if len(key) != aesKeyLength {
return nil, errors.New(errSignKeyLengthInvalid)
}
// 解码 base64 密文
data, err := Base64Decode(ciphertext)
if err != nil {
return nil, fmt.Errorf(errDecodeCiphertextFailed, err)
}
// 创建 AES cipher
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf(errCreateCipherFailed, err)
}
// 使用 GCM 模式
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf(errCreateGCMFailed, err)
}
// 提取 nonce
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return nil, errors.New(errCiphertextTooShort)
}
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
// 解密数据
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
if err != nil {
return nil, fmt.Errorf(errDecryptFailed, err)
}
return plaintext, nil
}
// Base64Encode Base64编码
func Base64Encode(data []byte) string {
return base64.StdEncoding.EncodeToString(data)
}
// Base64Decode Base64解码
func Base64Decode(encoded string) ([]byte, error) {
return base64.StdEncoding.DecodeString(encoded)
}
// Ed25519Verify 验证 Ed25519 签名
// publicKey: 32 字节的公钥(已解码的二进制格式)
// message: 待验证的原始消息
// signature: 64 字节的签名(已解码的二进制格式)
// return: 签名是否有效
func Ed25519Verify(publicKey, message, signature []byte) bool {
if len(publicKey) != ed25519.PublicKeySize {
return false
}
if len(signature) != ed25519.SignatureSize {
return false
}
return ed25519.Verify(publicKey, message, signature)
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package util provides framework-agnostic helper types and HTTP utilities.
package util
import (
"database/sql/driver"
"encoding/json"
"fmt"
)
// StringArray custom type for handling JSON arrays
type StringArray []string
// Scan 实现 sql.Scanner 接口,从数据库读取 JSON 数组
func (sa *StringArray) Scan(value interface{}) error {
bytesValue, ok := value.([]byte)
if !ok {
return fmt.Errorf(errInvalidCustomValue, value)
}
return json.Unmarshal(bytesValue, sa)
}
// Value 实现 driver.Valuer 接口,将 JSON 数组序列化为数据库存储值
func (sa StringArray) Value() (driver.Value, error) {
return json.Marshal(sa)
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import "github.com/gin-gonic/gin"
// GetFromContext retrieves a typed value from Gin context.
func GetFromContext[T any](c *gin.Context, key string) (T, bool) {
value, exists := c.Get(key)
if !exists {
var zero T
return zero, false
}
typed, ok := value.(T)
return typed, ok
}
// SetToContext sets a typed value into Gin context.
func SetToContext[T any](c *gin.Context, key string, value T) {
c.Set(key, value)
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import (
"log/slog"
"runtime"
"runtime/debug"
)
// Go runs fn in a new goroutine and recovers panics, so a background task
// cannot crash the whole process. The panic is logged together with the
// util.Go call site. Use it for every fire-and-forget / long-lived
// background goroutine; HTTP handlers are already covered by gin.Recovery.
func Go(fn func()) {
pc, file, line, _ := runtime.Caller(1)
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("panic recovered in background goroutine",
"caller", runtime.FuncForPC(pc).Name(),
"file", file,
"line", line,
"panic", r,
"stack", string(debug.Stack()))
}
}()
fn()
}()
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import (
"sync"
"testing"
)
func TestGoRecoversPanic(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)
// Go should swallow the panic without crashing the test process
Go(func() {
defer wg.Done()
panic("boom")
})
wg.Wait()
}
func TestGoRunsNormally(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)
ran := false
Go(func() {
defer wg.Done()
ran = true
})
wg.Wait()
if !ran {
t.Fatal("expected fn to run")
}
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/Rain-kl/Wavelet/backend/pkg/httppool"
)
// IsLocalhost 检查 URL 是否为 localhost
func IsLocalhost(urlStr string) bool {
u, err := url.Parse(urlStr)
if err != nil {
return false
}
hostname := u.Hostname()
return hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1"
}
// HTTP 客户端配置常量
const (
httpClientTimeout = 10 // HTTP 客户端超时时间(秒)
httpMaxIdleConns = 100
httpMaxIdleConnsPerHost = 20
httpIdleConnTimeout = 60 // 空闲连接超时(秒)
)
// 配置HTTP客户端 使用 otelhttp 自动注入 trace span
var httpClient = &http.Client{
Timeout: httpClientTimeout * time.Second,
Transport: httppool.DefaultTransport(),
}
// SetHTTPClient 替换全局 HTTP 客户端实例
func SetHTTPClient(c *http.Client) {
httpClient = c
}
// Request 发送 HTTP 请求,支持自定义 Headers 和 Cookies
func Request(ctx context.Context, method, url string, body io.Reader, headers, cookies map[string]string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, fmt.Errorf(errCreateHTTPRequestFailed, err)
}
for key, value := range cookies {
req.AddCookie(&http.Cookie{Name: key, Value: value}) //nolint:gosec // client-side cookies do not require server attributes (Secure/HttpOnly)
}
for key, value := range headers {
req.Header.Set(key, value)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf(errHTTPRequestFailed, url, err)
}
return resp, nil
}
+16
View File
@@ -0,0 +1,16 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import "strings"
var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
// EscapeLike escapes SQL LIKE metacharacters (\, %, _) so a user-supplied
// value matches literally in LIKE patterns. Pair it with an explicit
// `ESCAPE '\'` clause where the dialect has no backslash default (SQLite);
// PostgreSQL and ClickHouse treat backslash as the default LIKE escape.
func EscapeLike(value string) string {
return likeEscaper.Replace(value)
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import "testing"
func TestEscapeLike(t *testing.T) {
cases := map[string]string{
"": "",
"/my_page": `/my\_page`,
"100%": `100\%`,
`a\b`: `a\\b`,
`%_\`: `\%\_\\`,
"normal/path": "normal/path",
}
for input, want := range cases {
if got := EscapeLike(input); got != want {
t.Errorf("EscapeLike(%q) = %q, want %q", input, got, want)
}
}
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import (
"sync"
"golang.org/x/crypto/bcrypt"
)
// HashPassword 使用 bcrypt 对密码进行哈希处理
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
var dummyPasswordHashOnce sync.Once
var dummyPasswordHash string
func dummyHash() string {
dummyPasswordHashOnce.Do(func() {
hash, err := bcrypt.GenerateFromPassword([]byte("x"), bcrypt.DefaultCost)
if err != nil {
return
}
dummyPasswordHash = string(hash)
})
return dummyPasswordHash
}
// CheckPasswordHash 比较 bcrypt 哈希值与明文密码是否匹配
func CheckPasswordHash(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// DummyCheckPassword runs a bcrypt compare against a dummy hash so missing-user
// login failures take a similar amount of time as a real password miss.
func DummyCheckPassword(password string) {
hash := dummyHash()
if hash == "" {
return
}
_ = CheckPasswordHash(hash, password)
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import "testing"
func TestDummyCheckPasswordDoesNotPanic(t *testing.T) {
DummyCheckPassword("any-password")
}
func TestCheckPasswordHashRoundTrip(t *testing.T) {
hash, err := HashPassword("secret-pass")
if err != nil {
t.Fatal(err)
}
if !CheckPasswordHash(hash, "secret-pass") {
t.Fatal("expected matching password to succeed")
}
if CheckPasswordHash(hash, "other-pass") {
t.Fatal("expected mismatched password to fail")
}
}
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import "strings"
// emailPartsCount 邮箱地址由 @ 分割为两部分
const (
emailPartsCount = 2
emailLocalMinChars = 2 // 邮箱 local 部分掩码显示的最小字符数
)
// DerefString 安全地解引用字符串指针,nil 返回空字符串
func DerefString(s *string) string {
if s == nil {
return ""
}
return *s
}
// MaskEmail 安全脱敏用户的邮箱地址(例如 us***@example.com)
func MaskEmail(email string) string {
parts := strings.Split(email, "@")
if len(parts) != emailPartsCount {
return email
}
local := parts[0]
domain := parts[1]
if len(local) <= emailLocalMinChars {
return "**@" + domain
}
return local[:2] + "***" + local[len(local)-1:] + "@" + domain
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package util
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"io"
"github.com/google/uuid"
)
// uniqueIDBytes 生成唯一 ID 所需的随机字节长度
const uniqueIDBytes = 32
// GenerateUniqueIDSimple 生成 64 位唯一标识符
func GenerateUniqueIDSimple() string {
randomBytes := make([]byte, uniqueIDBytes)
if _, err := io.ReadFull(rand.Reader, randomBytes); err != nil {
// 如果随机数生成失败,使用 UUID 作为后备
uuidBytes := []byte(uuid.NewString())
hash := sha256.Sum256(uuidBytes)
copy(randomBytes, hash[:])
}
return hex.EncodeToString(randomBytes)
}