refactor(repo): replace legacy openflare-server with Wavelet rename

Delete the old monolithic openflare-server implementation and rename
Wavelet/ to openflare-server/ to complete the migration consolidation.
Update CI workflows, agent Dockerfiles, and deployment docs for the new
layout (frontend/, docker/Dockerfile).
This commit is contained in:
ryan
2026-06-19 11:29:17 +08:00
parent 46123d62ae
commit 88360350a0
1305 changed files with 40652 additions and 157629 deletions
+5 -177
View File
@@ -1,178 +1,6 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package common 提供跨模块共享的常量、错误定义和通用工具函数。
package common
import (
"sync"
"time"
"github.com/google/uuid"
)
var StartTime = time.Now().Unix() // unit: second
var Version = "dev" // release builds inject the tag version via ldflags
var SystemName = "OpenFlare"
var ServerAddress = "http://localhost:3000"
var Footer = ""
var HomePageLink = ""
// Any options with "Secret", "Token" in its key won't be return by GetOptions
var SessionSecret = uuid.New().String()
var JWTSecret = "" // if empty, falls back to SessionSecret; set via JWT_SECRET env var
var SQLitePath = "openflare.db"
var SQLDSN = ""
var OptionMap map[string]string
var OptionMapRWMutex sync.RWMutex
var ItemsPerPage = 10
var PasswordLoginEnabled = true
var CapLoginEnabled = true
var PasswordRegisterEnabled = false
var EmailVerificationEnabled = false
var GitHubOAuthEnabled = false
var WeChatAuthEnabled = false
var RegisterEnabled = false
var SMTPServer = ""
var SMTPPort = 587
var SMTPAccount = ""
var SMTPToken = ""
var GitHubClientId = ""
var GitHubClientSecret = ""
var WeChatServerAddress = ""
var WeChatServerToken = ""
var WeChatAccountQRCodeImageURL = ""
var AccessToken = ""
var AgentDiscoveryToken = ""
var NodeOfflineThreshold = 2 * time.Minute
// V3 operational settings (hot-reloadable via Option table)
var AgentHeartbeatInterval = 10000 // milliseconds
var AgentWebsocketUpgradeEnabled = true
var AgentUpdateRepo = "Rain-kl/OpenFlare"
var GeoIPProvider = "ipinfo"
var DatabaseAutoCleanupEnabled = false
var DatabaseAutoCleanupRetentionDays = 30
// Uptime Kuma integration settings
var UptimeKumaEnabled = false
var UptimeKumaUrl = ""
var UptimeKumaUsername = ""
var UptimeKumaPassword = ""
var UptimeKumaMonitorScope = "all" // "all" or "selected"
var UptimeKumaSelectedSites = "" // Comma-separated list of site names
var UptimeKumaSyncInterval = 5 // minutes
var UptimeKumaInterval = 60 // seconds
var UptimeKumaRetry = 0
var UptimeKumaRetryInterval = 60 // seconds
var UptimeKumaTimeout = 48 // seconds
// V5 OpenResty performance settings (hot-reloadable via Option table)
var OpenRestyDefaultServerReturnStatus = 421
var OpenRestyWorkerProcesses = "auto"
var OpenRestyWorkerConnections = 4096
var OpenRestyWorkerRlimitNofile = 65535
var OpenRestyEventsUse = "epoll"
var OpenRestyEventsMultiAcceptEnabled = true
var OpenRestyKeepaliveTimeout = 20
var OpenRestyKeepaliveRequests = 1000
var OpenRestyClientHeaderTimeout = 15
var OpenRestyClientBodyTimeout = 15
var OpenRestyClientMaxBodySize = "64m"
var OpenRestyLargeClientHeaderBuffers = "4 16k"
var OpenRestySendTimeout = 30
var OpenRestyResolvers = ""
var OpenRestyProxyConnectTimeout = 3
var OpenRestyProxySendTimeout = 60
var OpenRestyProxyReadTimeout = 60
var OpenRestyWebsocketEnabled = true
var OpenRestyHTTP3Enabled = true
var OpenRestyProxyRequestBufferingEnabled = false
var OpenRestyProxyBufferingEnabled = true
var OpenRestyProxyBuffers = "16 16k"
var OpenRestyProxyBufferSize = "8k"
var OpenRestyProxyBusyBuffersSize = "64k"
var OpenRestyGzipEnabled = true
var OpenRestyGzipMinLength = 1024
var OpenRestyGzipCompLevel = 5
var OpenRestyCacheEnabled = false
var OpenRestyCachePath = ""
var OpenRestyCacheLevels = "1:2"
var OpenRestyCacheInactive = "30m"
var OpenRestyCacheMaxSize = "1g"
var OpenRestyCacheKeyTemplate = "$scheme$host$request_uri"
var OpenRestyCacheLockEnabled = true
var OpenRestyCacheLockTimeout = "5s"
var OpenRestyCacheUseStale = "error timeout updating http_500 http_502 http_503 http_504"
var OpenRestyMainConfigTemplate = `# This file is generated by OpenFlare. Do not edit manually.
worker_processes {{OpenRestyWorkerProcesses}};
worker_rlimit_nofile {{OpenRestyWorkerRlimitNofile}};
pid logs/nginx.pid;
error_log {{OpenRestyErrorLogPath}} warn;
events {
worker_connections {{OpenRestyWorkerConnections}};
{{OpenRestyEventsUseDirective}}{{OpenRestyEventsMultiAcceptDirective}}}
http {
include mime.types;
default_type application/octet-stream;
{{OpenRestyConnectionUpgradeMap}}{{OpenRestyDefaultServerBlock}} log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length}';
access_log {{OpenRestyAccessLogPath}} openflare_json;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout {{OpenRestyKeepaliveTimeout}};
keepalive_requests {{OpenRestyKeepaliveRequests}};
client_header_timeout {{OpenRestyClientHeaderTimeout}};
client_body_timeout {{OpenRestyClientBodyTimeout}};
client_max_body_size {{OpenRestyClientMaxBodySize}};
large_client_header_buffers {{OpenRestyLargeClientHeaderBuffers}};
send_timeout {{OpenRestySendTimeout}};
proxy_connect_timeout {{OpenRestyProxyConnectTimeout}};
proxy_send_timeout {{OpenRestyProxySendTimeout}};
proxy_read_timeout {{OpenRestyProxyReadTimeout}};
proxy_request_buffering {{OpenRestyProxyRequestBuffering}};
proxy_buffering {{OpenRestyProxyBuffering}};
proxy_buffers {{OpenRestyProxyBuffers}};
proxy_buffer_size {{OpenRestyProxyBufferSize}};
proxy_busy_buffers_size {{OpenRestyProxyBusyBuffersSize}};
gzip {{OpenRestyGzip}};
gzip_min_length {{OpenRestyGzipMinLength}};
gzip_comp_level {{OpenRestyGzipCompLevel}};
{{OpenRestyResolverDirective}}{{OpenRestyCacheBlock}} include {{OpenRestyRouteConfigInclude}};
}
`
const (
RoleGuestUser = 0
RoleCommonUser = 1
RoleAdminUser = 10
RoleRootUser = 100
)
// All duration's unit is seconds
// Shouldn't larger then RateLimitKeyExpirationDuration
var (
GlobalApiRateLimitNum = 300
GlobalApiRateLimitDuration int64 = 3 * 60
GlobalWebRateLimitNum = 300
GlobalWebRateLimitDuration int64 = 3 * 60
CriticalRateLimitNum = 100
CriticalRateLimitDuration int64 = 20 * 60
)
var RateLimitKeyExpirationDuration = 20 * time.Minute
const (
UserStatusEnabled = 1 // don't use 0, 0 is the default value!
UserStatusDisabled = 2 // also don't use 0
)
+11
View File
@@ -0,0 +1,11 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package common
// 通用业务错误消息常量
const (
BannedAccount = "账号已被封禁"
UnAuthorized = "未登录"
)
-84
View File
@@ -1,84 +0,0 @@
package common
import (
"flag"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
)
var (
Port = flag.Int("port", 3000, "the listening port")
PrintVersion = flag.Bool("version", false, "print version and exit")
PrintHelp = flag.Bool("help", false, "print help and exit")
LogDir = flag.String("log-dir", "", "specify the log directory")
)
func printHelp() {
fmt.Println("OpenFlare " + Version + " - Internal OpenResty Control Plane.")
fmt.Println("Copyright (C) 2023 JustSong. All rights reserved.")
fmt.Println("GitHub: https://github.com/Rain-kl/OpenFlare")
fmt.Println("Usage: openflare [--port <port>] [--log-dir <log directory>] [--version] [--help]")
}
// ParseFlags 在命令行参数被任何 import 链上的 init() 误解析之前,
// 由各 binary 的 main() 显式调用一次。openflare-server 与 openflare-relay
// 共用 flag.CommandLine,必须先注册各自的 flag 再调用本函数。
// 测试场景(go test)下不会执行本函数,单元测试可直接跳过命令行解析。
func ParseFlags() {
executableName := strings.ToLower(filepath.Base(os.Args[0]))
isTest := strings.Contains(executableName, ".test") || flag.Lookup("test.v") != nil
if isTest {
return
}
flag.Parse()
if *PrintVersion {
fmt.Println(Version)
os.Exit(0)
}
if *PrintHelp {
printHelp()
os.Exit(0)
}
if os.Getenv("SESSION_SECRET") != "" {
SessionSecret = os.Getenv("SESSION_SECRET")
}
if os.Getenv("JWT_SECRET") != "" {
JWTSecret = os.Getenv("JWT_SECRET")
}
if os.Getenv("SQLITE_PATH") != "" {
SQLitePath = os.Getenv("SQLITE_PATH")
}
if os.Getenv("SQL_DSN") != "" {
SQLDSN = os.Getenv("SQL_DSN")
}
if os.Getenv("DSN") != "" {
SQLDSN = os.Getenv("DSN")
}
if os.Getenv("AGENT_TOKEN") != "" {
AccessToken = os.Getenv("AGENT_TOKEN")
}
SetLogLevel(os.Getenv("LOG_LEVEL"))
if *LogDir != "" {
var err error
*LogDir, err = filepath.Abs(*LogDir)
if err != nil {
slog.Error("resolve log directory failed", "error", err)
os.Exit(1)
}
if _, err := os.Stat(*LogDir); os.IsNotExist(err) {
err = os.Mkdir(*LogDir, 0777)
if err != nil {
slog.Error("create log directory failed", "error", err)
os.Exit(1)
}
}
}
}
-242
View File
@@ -1,242 +0,0 @@
package common
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"github.com/gin-gonic/gin"
)
type logLevel int
const (
logLevelDebug logLevel = iota
logLevelInfo
logLevelWarn
logLevelError
)
var currentLogLevel = logLevelInfo
var currentLogLevelName = "info"
var commonLogWriter io.Writer = os.Stdout
var errorLogWriter io.Writer = os.Stderr
var defaultLogger *slog.Logger
type customTextHandler struct {
writer io.Writer
level slog.Level
attrs []slog.Attr
groups []string
}
type levelRouterHandler struct {
commonHandler slog.Handler
errorHandler slog.Handler
}
func (h *customTextHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level
}
func (h *customTextHandler) Handle(_ context.Context, record slog.Record) error {
var builder strings.Builder
builder.WriteString(record.Time.Format("2006-01-02 15:04:05.000"))
builder.WriteString(" | ")
builder.WriteString(fmt.Sprintf("%-8s", levelLabel(record.Level)))
builder.WriteString(" | ")
builder.WriteString(sourceLocation(record.PC))
builder.WriteString(" - ")
builder.WriteString(record.Message)
attrs := make([]slog.Attr, 0, len(h.attrs)+record.NumAttrs())
attrs = append(attrs, h.attrs...)
record.Attrs(func(attr slog.Attr) bool {
attrs = append(attrs, attr)
return true
})
if len(attrs) > 0 {
builder.WriteString(" | ")
builder.WriteString(formatAttrs(h.groups, attrs))
}
builder.WriteByte('\n')
_, err := io.WriteString(h.writer, builder.String())
return err
}
func (h *customTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
cloned := *h
cloned.attrs = append(slices.Clone(h.attrs), attrs...)
return &cloned
}
func (h *customTextHandler) WithGroup(name string) slog.Handler {
if strings.TrimSpace(name) == "" {
return h
}
cloned := *h
cloned.groups = append(slices.Clone(h.groups), name)
return &cloned
}
func (h *levelRouterHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.commonHandler.Enabled(ctx, level) || h.errorHandler.Enabled(ctx, level)
}
func (h *levelRouterHandler) Handle(ctx context.Context, record slog.Record) error {
if record.Level >= slog.LevelError {
return h.errorHandler.Handle(ctx, record)
}
return h.commonHandler.Handle(ctx, record)
}
func (h *levelRouterHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &levelRouterHandler{
commonHandler: h.commonHandler.WithAttrs(attrs),
errorHandler: h.errorHandler.WithAttrs(attrs),
}
}
func (h *levelRouterHandler) WithGroup(name string) slog.Handler {
return &levelRouterHandler{
commonHandler: h.commonHandler.WithGroup(name),
errorHandler: h.errorHandler.WithGroup(name),
}
}
func configureGinWriters() {
if shouldLog(logLevelDebug) {
gin.DefaultWriter = commonLogWriter
} else {
gin.DefaultWriter = io.Discard
}
gin.DefaultErrorWriter = errorLogWriter
}
func slogLevel() slog.Level {
switch currentLogLevel {
case logLevelDebug:
return slog.LevelDebug
case logLevelWarn:
return slog.LevelWarn
case logLevelError:
return slog.LevelError
default:
return slog.LevelInfo
}
}
func ensureLogger() *slog.Logger {
if defaultLogger != nil {
return defaultLogger
}
defaultLogger = slog.New(&levelRouterHandler{
commonHandler: &customTextHandler{writer: commonLogWriter, level: slogLevel()},
errorHandler: &customTextHandler{writer: errorLogWriter, level: slogLevel()},
})
slog.SetDefault(defaultLogger)
return defaultLogger
}
func SetLogLevel(level string) {
normalized := strings.TrimSpace(strings.ToLower(level))
switch normalized {
case "debug":
currentLogLevel = logLevelDebug
currentLogLevelName = "debug"
case "warn", "warning":
currentLogLevel = logLevelWarn
currentLogLevelName = "warn"
case "error":
currentLogLevel = logLevelError
currentLogLevelName = "error"
default:
currentLogLevel = logLevelInfo
currentLogLevelName = "info"
}
configureGinWriters()
}
func GetLogLevel() string {
return currentLogLevelName
}
func shouldLog(level logLevel) bool {
return level >= currentLogLevel
}
func SetupGinLog() {
if *LogDir != "" {
commonLogPath := filepath.Join(*LogDir, "common.log")
errorLogPath := filepath.Join(*LogDir, "error.log")
commonFd, err := os.OpenFile(commonLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
_, _ = io.WriteString(os.Stderr, "failed to open common log file\n")
os.Exit(1)
}
errorFd, err := os.OpenFile(errorLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
_, _ = io.WriteString(os.Stderr, "failed to open error log file\n")
os.Exit(1)
}
commonLogWriter = io.MultiWriter(os.Stdout, commonFd)
errorLogWriter = io.MultiWriter(os.Stderr, errorFd)
}
configureGinWriters()
defaultLogger = nil
ensureLogger()
}
func levelLabel(level slog.Level) string {
switch {
case level <= slog.LevelDebug:
return "DEBUG"
case level < slog.LevelWarn:
return "INFO"
case level < slog.LevelError:
return "WARNING"
default:
return "ERROR"
}
}
func sourceLocation(pc uintptr) string {
if pc == 0 {
return "unknown:unknown:0"
}
frame, _ := runtime.CallersFrames([]uintptr{pc}).Next()
fileName := strings.TrimSuffix(filepath.Base(frame.File), filepath.Ext(frame.File))
if fileName == "" {
fileName = "unknown"
}
functionName := "unknown"
if frame.Function != "" {
parts := strings.Split(frame.Function, "/")
functionName = parts[len(parts)-1]
if dot := strings.LastIndex(functionName, "."); dot >= 0 && dot < len(functionName)-1 {
functionName = functionName[dot+1:]
}
}
return fmt.Sprintf("%s:%s:%d", fileName, functionName, frame.Line)
}
func formatAttrs(groups []string, attrs []slog.Attr) string {
parts := make([]string, 0, len(attrs))
for _, attr := range attrs {
key := attr.Key
if key == "" {
continue
}
if len(groups) > 0 {
key = strings.Join(append(slices.Clone(groups), key), ".")
}
parts = append(parts, fmt.Sprintf("%s=%v", key, attr.Value.Any()))
}
return strings.Join(parts, " ")
}
-41
View File
@@ -1,41 +0,0 @@
package common
import (
"context"
"log/slog"
"os"
"time"
"github.com/go-redis/redis/v8"
)
var RDB *redis.Client
var RedisEnabled = true
// InitRedisClient This function is called after init()
func InitRedisClient() (err error) {
if os.Getenv("REDIS_CONN_STRING") == "" {
RedisEnabled = false
slog.Info("redis disabled because REDIS_CONN_STRING is not set")
return nil
}
opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
if err != nil {
panic(err)
}
RDB = redis.NewClient(opt)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = RDB.Ping(ctx).Result()
return err
}
func ParseRedisOption() *redis.Options {
opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
if err != nil {
panic(err)
}
return opt
}
@@ -0,0 +1,45 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package response
import (
"net/http"
"github.com/gin-gonic/gin"
)
// AbortBadRequest 以 400 中断请求并将错误挂载到 Gin Error 链,供全局中间件统一记录 Trace 并响应。
func AbortBadRequest(c *gin.Context, msg string) {
AbortWithError(c, http.StatusBadRequest, msg)
}
// AbortUnauthorized 以 401 中断请求并将错误挂载到 Gin Error 链。
func AbortUnauthorized(c *gin.Context, msg string) {
AbortWithError(c, http.StatusUnauthorized, msg)
}
// AbortForbidden 以 403 中断请求并将错误挂载到 Gin Error 链。
func AbortForbidden(c *gin.Context, msg string) {
AbortWithError(c, http.StatusForbidden, msg)
}
// AbortNotFound 以 404 中断请求并将错误挂载到 Gin Error 链。
func AbortNotFound(c *gin.Context, msg string) {
AbortWithError(c, http.StatusNotFound, msg)
}
// AbortInternal 以 500 中断请求并将错误挂载到 Gin Error 链。
func AbortInternal(c *gin.Context, msg string) {
AbortWithError(c, http.StatusInternalServerError, msg)
}
// AbortTooManyRequests 以 429 中断请求并将错误挂载到 Gin Error 链。
func AbortTooManyRequests(c *gin.Context, msg string) {
AbortWithError(c, http.StatusTooManyRequests, msg)
}
// AbortConflict 以 409 中断请求并将错误挂载到 Gin Error 链。
func AbortConflict(c *gin.Context, msg string) {
AbortWithError(c, http.StatusConflict, msg)
}
@@ -0,0 +1,40 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package response
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
// ErrorHandlerMiddleware 捕获 c.Errors 并统一格式化为 JSON 返回给客户端,同时将其记录到 Span 异常中。
// 与 AbortWithError / AbortBadRequest 等配合使用,是全局 OTel 友好错误响应的唯一出口。
func ErrorHandlerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
if len(c.Errors) == 0 || c.Writer.Written() {
return
}
err := c.Errors.Last().Err
span := trace.SpanFromContext(c.Request.Context())
if span.IsRecording() {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
var apiErr *APIError
if errors.As(err, &apiErr) {
c.JSON(apiErr.Code, Err(apiErr.Msg))
return
}
c.JSON(http.StatusInternalServerError, Err("内部系统错误"))
}
}
@@ -0,0 +1,168 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package response
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/codes"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"go.opentelemetry.io/otel/trace"
)
func init() {
gin.SetMode(gin.TestMode)
}
func TestAbortWithError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
AbortWithError(c, http.StatusBadRequest, "invalid input")
require.Len(t, c.Errors, 1)
var apiErr *APIError
require.True(t, errors.As(c.Errors.Last().Err, &apiErr))
assert.Equal(t, http.StatusBadRequest, apiErr.Code)
assert.Equal(t, "invalid input", apiErr.Msg)
assert.True(t, c.IsAborted())
}
func TestErrorHandlerMiddleware_APIErrorStatusCodes(t *testing.T) {
cases := []struct {
name string
statusCode int
message string
abort func(*gin.Context, string)
}{
{"400 Bad Request", http.StatusBadRequest, "bad request", AbortBadRequest},
{"401 Unauthorized", http.StatusUnauthorized, "unauthorized", AbortUnauthorized},
{"403 Forbidden", http.StatusForbidden, "forbidden", AbortForbidden},
{"404 Not Found", http.StatusNotFound, "not found", AbortNotFound},
{"409 Conflict", http.StatusConflict, "conflict", AbortConflict},
{"429 Too Many Requests", http.StatusTooManyRequests, "too many requests", AbortTooManyRequests},
{"500 Internal Server Error", http.StatusInternalServerError, "internal error", AbortInternal},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := gin.New()
r.Use(ErrorHandlerMiddleware())
r.GET("/test", func(c *gin.Context) {
tc.abort(c, tc.message)
})
req := httptest.NewRequest(http.MethodGet, "/test", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, tc.statusCode, w.Code)
assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type"))
var body Response[any]
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
assert.Equal(t, tc.message, body.ErrorMsg)
assert.Nil(t, body.Data)
})
}
}
func TestErrorHandlerMiddleware_SkipsWhenNoErrors(t *testing.T) {
r := gin.New()
r.Use(ErrorHandlerMiddleware())
r.GET("/ok", func(c *gin.Context) {
c.JSON(http.StatusOK, OK("success"))
})
req := httptest.NewRequest(http.MethodGet, "/ok", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var body Response[string]
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
assert.Equal(t, "success", body.Data)
assert.Empty(t, body.ErrorMsg)
}
func TestErrorHandlerMiddleware_SkipsWhenResponseAlreadyWritten(t *testing.T) {
r := gin.New()
r.Use(ErrorHandlerMiddleware())
r.GET("/written", func(c *gin.Context) {
c.JSON(http.StatusOK, OKNil())
_ = c.Error(NewError(http.StatusBadRequest, "should not overwrite"))
})
req := httptest.NewRequest(http.MethodGet, "/written", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var body Response[any]
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
assert.Empty(t, body.ErrorMsg)
assert.Nil(t, body.Data)
}
func TestErrorHandlerMiddleware_FallbackForNonAPIError(t *testing.T) {
r := gin.New()
r.Use(ErrorHandlerMiddleware())
r.GET("/plain", func(c *gin.Context) {
_ = c.Error(errors.New("plain error"))
})
req := httptest.NewRequest(http.MethodGet, "/plain", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
var body Response[any]
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
assert.Equal(t, "内部系统错误", body.ErrorMsg)
assert.Nil(t, body.Data)
}
func TestErrorHandlerMiddleware_RecordsSpanOnAPIError(t *testing.T) {
sr := tracetest.NewSpanRecorder()
tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr))
otel.SetTracerProvider(tp)
defer otel.SetTracerProvider(trace.NewNoopTracerProvider())
tracer := tp.Tracer("test")
ctx, span := tracer.Start(context.Background(), "request")
r := gin.New()
r.Use(ErrorHandlerMiddleware())
r.GET("/err", func(c *gin.Context) {
c.Request = c.Request.WithContext(ctx)
AbortBadRequest(c, "bad request")
})
req := httptest.NewRequest(http.MethodGet, "/err", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
span.End()
require.Equal(t, http.StatusBadRequest, w.Code)
spans := sr.Ended()
require.Len(t, spans, 1)
assert.Equal(t, codes.Error, spans[0].Status().Code)
assert.Equal(t, "bad request", spans[0].Status().Description)
require.NotEmpty(t, spans[0].Events())
}
@@ -1,82 +1,57 @@
// Copyright 2025 linux.do
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package response provides shared HTTP API response structures.
package response
import (
"net/http"
import "github.com/gin-gonic/gin"
"github.com/gin-gonic/gin"
)
const invalidParamsMessage = "参数错误"
// RespondSuccess sends a successful response with data
func RespondSuccess(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": data,
})
// Response 通用响应体
type Response[T any] struct {
ErrorMsg string `json:"error_msg"`
Data T `json:"data"`
}
// RespondSuccessWithExtras sends a successful response with data and extra fields
func RespondSuccessWithExtras(c *gin.Context, data any, extras gin.H) {
payload := gin.H{
"success": true,
"message": "",
"data": data,
}
for key, value := range extras {
payload[key] = value
}
c.JSON(http.StatusOK, payload)
// Any 用于 Swagger 文档的响应类型(非泛型)
// swag 不支持泛型,使用此类型替代 Response[T]
type Any struct {
ErrorMsg string `json:"error_msg" example:""`
Data interface{} `json:"data"`
}
// RespondSuccessMessage sends a successful response with a custom message
func RespondSuccessMessage(c *gin.Context, message string) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": message,
})
// APIError 统一的 API 业务错误类型,可被全局错误处理中间件捕获
type APIError struct {
Code int
Msg string
}
// RespondFailure sends a failed response with http.StatusOK and a failure message
func RespondFailure(c *gin.Context, message string) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": message,
})
func (e *APIError) Error() string {
return e.Msg
}
// RespondBadRequest sends a bad request response (400)
func RespondBadRequest(c *gin.Context, message string) {
if message == "" {
message = invalidParamsMessage
}
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": message,
})
// NewError 实例化一个 APIError
func NewError(code int, msg string) *APIError {
return &APIError{Code: code, Msg: msg}
}
// RespondUnauthorized sends an unauthorized response (401)
func RespondUnauthorized(c *gin.Context, message string) {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": message,
})
// AbortWithError 将 API 错误挂载到 Gin Context 并中断执行流
func AbortWithError(c *gin.Context, code int, msg string) {
_ = c.Error(NewError(code, msg))
c.Abort()
}
// RespondForbidden sends a forbidden response (403)
func RespondForbidden(c *gin.Context, message string) {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": message,
})
// OK 构造成功响应
func OK[T any](data T) Response[T] {
return Response[T]{Data: data}
}
// RespondErrorWithStatus sends a response with target HTTP status code and a message
func RespondErrorWithStatus(c *gin.Context, code int, message string) {
c.JSON(code, gin.H{
"success": false,
"message": message,
})
// OKNil 构造成功响应(data 为 null)
func OKNil() Response[any] {
return Response[any]{Data: nil}
}
// Err 构造错误响应
func Err(msg string) Response[any] {
return Response[any]{ErrorMsg: msg, Data: nil}
}