mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-11-12 21:44:22 +08:00
feat(i18n): default to English, localize runtime text and add Russian (#52)
This commit is contained in:
@@ -209,7 +209,7 @@ func getPostgresOverview(gormDB *gorm.DB) DBOverviewResponse {
|
||||
func GetDBOverview(c *gin.Context) {
|
||||
gormDB := db.DB(c.Request.Context())
|
||||
if gormDB == nil {
|
||||
response.AbortInternal(c, "数据库未初始化")
|
||||
response.AbortInternal(c, "Database is not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ func GetDBOverview(c *gin.Context) {
|
||||
func ListDBTables(c *gin.Context) {
|
||||
gormDB := db.DB(c.Request.Context())
|
||||
if gormDB == nil {
|
||||
response.AbortInternal(c, "数据库未初始化")
|
||||
response.AbortInternal(c, "Database is not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func GetDBTableData(c *gin.Context) {
|
||||
|
||||
gormDB := db.DB(c.Request.Context())
|
||||
if gormDB == nil {
|
||||
response.AbortInternal(c, "数据库未初始化")
|
||||
response.AbortInternal(c, "Database is not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -442,13 +442,13 @@ func ExecuteSQL(c *gin.Context) {
|
||||
|
||||
gormDB := db.DB(c.Request.Context())
|
||||
if gormDB == nil {
|
||||
response.AbortInternal(c, "数据库未初始化")
|
||||
response.AbortInternal(c, "Database is not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
trimmedSQL := strings.TrimSpace(req.SQL)
|
||||
if trimmedSQL == "" {
|
||||
response.AbortBadRequest(c, "SQL 语句不能为空")
|
||||
response.AbortBadRequest(c, "SQL statement must not be empty")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ package admin
|
||||
|
||||
// 管理后台错误消息常量
|
||||
const (
|
||||
AdminRequired = "未经授权访问"
|
||||
TokenAdminRequired = "该访问令牌没有管理员权限,无法访问管理端点" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
InvalidAuthSourceID = "认证源 ID 无效"
|
||||
InvalidCursorParam = "无效的 cursor 参数"
|
||||
InvalidTaskExecutionID = "无效的任务执行记录 ID"
|
||||
AdminRequired = "Unauthorized access"
|
||||
TokenAdminRequired = "This access token has no administrator privileges and cannot access admin endpoints" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
InvalidAuthSourceID = "invalid authentication source ID"
|
||||
InvalidCursorParam = "Invalid cursor parameter"
|
||||
InvalidTaskExecutionID = "Invalid task execution ID"
|
||||
)
|
||||
|
||||
@@ -165,7 +165,7 @@ func buildAccessLogFilter(ctx context.Context, c *gin.Context) (analyticsmodel.A
|
||||
if username != "" {
|
||||
userIDs, err := repository.ListUserIDsByUsernameContains(ctx, username)
|
||||
if err != nil {
|
||||
return filter, fmt.Errorf("查询用户信息失败: %w", err)
|
||||
return filter, fmt.Errorf("failed to query user information: %w", err)
|
||||
}
|
||||
filter.UserIDs = userIDs
|
||||
}
|
||||
@@ -246,8 +246,8 @@ func GetAccessLogs(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
store, err := logstore.Active(ctx)
|
||||
if err != nil {
|
||||
logger.ErrorF(ctx, "获取日志存储实例失败: %v", err)
|
||||
response.AbortWithError(c, http.StatusBadRequest, "日志存储未启用,无法检索访问日志")
|
||||
logger.ErrorF(ctx, "Failed to get log storage instance: %v", err)
|
||||
response.AbortWithError(c, http.StatusBadRequest, "Log storage is disabled; access logs cannot be retrieved")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -348,8 +348,8 @@ func GetLogsAnalytics(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
store, err := logstore.Active(ctx)
|
||||
if err != nil {
|
||||
logger.ErrorF(ctx, "获取日志存储实例失败: %v", err)
|
||||
response.AbortWithError(c, http.StatusBadRequest, "日志存储未启用,无法获取分析数据")
|
||||
logger.ErrorF(ctx, "Failed to get log storage instance: %v", err)
|
||||
response.AbortWithError(c, http.StatusBadRequest, "Log storage is disabled; analytics are unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -357,7 +357,7 @@ func GetLogsAnalytics(c *gin.Context) {
|
||||
|
||||
trendPoints, err := store.UserAccessLogs.GetDailyTrend(ctx, analyticsDays)
|
||||
if err != nil {
|
||||
response.AbortWithError(c, http.StatusInternalServerError, "查询访问趋势失败: "+err.Error())
|
||||
response.AbortWithError(c, http.StatusInternalServerError, "Failed to query access trends: "+err.Error())
|
||||
return
|
||||
}
|
||||
trendList := make([]trendItem, len(trendPoints))
|
||||
@@ -370,7 +370,7 @@ func GetLogsAnalytics(c *gin.Context) {
|
||||
|
||||
browserPoints, err := store.UserAccessLogs.GetBrowserDistribution(ctx, startTime)
|
||||
if err != nil {
|
||||
response.AbortWithError(c, http.StatusInternalServerError, "查询浏览器分布失败: "+err.Error())
|
||||
response.AbortWithError(c, http.StatusInternalServerError, "Failed to query browser distribution: "+err.Error())
|
||||
return
|
||||
}
|
||||
browserList := make([]browserItem, len(browserPoints))
|
||||
@@ -383,7 +383,7 @@ func GetLogsAnalytics(c *gin.Context) {
|
||||
|
||||
topUserPoints, err := store.UserAccessLogs.GetTopActiveUsers(ctx, startTime, topActiveLimit)
|
||||
if err != nil {
|
||||
response.AbortWithError(c, http.StatusInternalServerError, "查询活跃用户失败: "+err.Error())
|
||||
response.AbortWithError(c, http.StatusInternalServerError, "Failed to query active users: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -221,9 +221,9 @@ func TestChannel(c *gin.Context) {
|
||||
default:
|
||||
config = pkgpush.Config{Channel: channelCustom, URL: url}
|
||||
customPushReq := CustomPushRequest{
|
||||
Title: "通道测试通知",
|
||||
Content: "这是一条来自系统的消息通道连通性测试消息。",
|
||||
Description: "系统通道测试",
|
||||
Title: "Channel test notification",
|
||||
Content: "This is a system notification channel connectivity test.",
|
||||
Description: "System channel test",
|
||||
URL: "https://example.com",
|
||||
To: req.Target,
|
||||
}
|
||||
@@ -235,8 +235,8 @@ func TestChannel(c *gin.Context) {
|
||||
Config: config,
|
||||
Target: req.Target,
|
||||
Body: NotificationMessage{
|
||||
Title: "通道测试通知",
|
||||
Content: "这是一条来自系统的消息通道连通性测试消息。",
|
||||
Title: "Channel test notification",
|
||||
Content: "This is a system notification channel connectivity test.",
|
||||
Level: defaultLevelInfo,
|
||||
},
|
||||
Template: renderedJSON,
|
||||
|
||||
@@ -81,24 +81,24 @@ func init() {
|
||||
// Register custom webhook channel
|
||||
RegisterChannelDefinition(Definition{
|
||||
Type: channelCustom,
|
||||
Name: "自定义消息通道",
|
||||
Description: "使用自定义 HTTP POST 请求向外部 Webhook 发送数据。",
|
||||
Name: "Custom notification channel",
|
||||
Description: "Send data to an external webhook using a custom HTTP POST request.",
|
||||
Fields: []Field{
|
||||
{
|
||||
Key: KeyURL,
|
||||
Label: "请求地址",
|
||||
Label: "Request URL",
|
||||
Type: TypeText,
|
||||
Required: true,
|
||||
Placeholder: "在此填写完整的请求地址,必须使用 HTTPS 协议",
|
||||
Description: "接口请求的完整 HTTPS URL,例如 https://api.example.com/webhook",
|
||||
Placeholder: "Enter the complete request URL using HTTPS",
|
||||
Description: "Complete HTTPS endpoint URL, for example https://api.example.com/webhook",
|
||||
},
|
||||
{
|
||||
Key: KeyOther,
|
||||
Label: "请求体 (JSON)",
|
||||
Label: "Request body (JSON)",
|
||||
Type: TypeTextarea,
|
||||
Required: true,
|
||||
Placeholder: "在此输入请求体,支持模板变量,必须为合法的 JSON 格式",
|
||||
Description: "可使用的变量:$title, $description, $content, $url, $to。例如 {\"text\": \"$content\"}",
|
||||
Placeholder: "Enter a valid JSON request body; template variables are supported",
|
||||
Description: `Available variables: $title, $description, $content, $url, $to. Example: {"text": "$content"}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -106,33 +106,33 @@ func init() {
|
||||
// Register Lark robot channel
|
||||
RegisterChannelDefinition(Definition{
|
||||
Type: channelLark,
|
||||
Name: "飞书群机器人",
|
||||
Description: "配置飞书群自定义机器人的 Webhook 接口投递。",
|
||||
Name: "Feishu group bot",
|
||||
Description: "Deliver notifications through a Feishu group bot webhook.",
|
||||
Fields: []Field{
|
||||
{
|
||||
Key: KeyURL,
|
||||
Label: "Webhook 地址",
|
||||
Label: "Webhook URL",
|
||||
Type: TypeText,
|
||||
Required: true,
|
||||
Placeholder: "https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN",
|
||||
Description: "从飞书群机器人设置中复制 of Webhook URL",
|
||||
Description: "Copy the webhook URL from the Feishu group bot settings",
|
||||
// Note: using 'of' was in feishu.go, let's keep original wording or fix it
|
||||
},
|
||||
{
|
||||
Key: KeyToken,
|
||||
Label: "签名校验密钥 (Secret) (可选)",
|
||||
Label: "Signing secret (optional)",
|
||||
Type: TypeText,
|
||||
Required: false,
|
||||
Placeholder: "可选,若机器人启用了安全设置中的签名校验,请在此输入",
|
||||
Description: "飞书群机器人安全设置中的签名校验 Key",
|
||||
Placeholder: "Enter the secret if signature verification is enabled for the bot",
|
||||
Description: "Signature verification key from the Feishu group bot security settings",
|
||||
},
|
||||
{
|
||||
Key: KeyOther,
|
||||
Label: "自定义卡片 JSON 模版 (可选)",
|
||||
Label: "Custom card JSON template (optional)",
|
||||
Type: TypeTextarea,
|
||||
Required: false,
|
||||
Placeholder: "可选,留空则默认使用系统内置的精美互动卡片",
|
||||
Description: "若填写,必须是合法的飞书卡片 JSON 格式",
|
||||
Placeholder: "Leave empty to use the built-in interactive card",
|
||||
Description: "If specified, this must be valid Feishu card JSON",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -140,32 +140,32 @@ func init() {
|
||||
// Register Telegram channel
|
||||
RegisterChannelDefinition(Definition{
|
||||
Type: channelTelegram,
|
||||
Name: "Telegram 机器人",
|
||||
Description: "配置 Telegram 机器人推送消息。",
|
||||
Name: "Telegram bot",
|
||||
Description: "Configure Telegram bot notifications.",
|
||||
Fields: []Field{
|
||||
{
|
||||
Key: KeyURL,
|
||||
Label: "API 基础地址 (可选)",
|
||||
Label: "API base URL (optional)",
|
||||
Type: TypeText,
|
||||
Required: false,
|
||||
Placeholder: "https://api.telegram.org",
|
||||
Description: "接口请求的 HTTPS 基础地址,留空默认为 https://api.telegram.org",
|
||||
Description: "HTTPS API base URL; defaults to https://api.telegram.org",
|
||||
},
|
||||
{
|
||||
Key: KeyToken,
|
||||
Label: "机器人 Token (Bot Token)",
|
||||
Label: "Bot token",
|
||||
Type: TypePassword,
|
||||
Required: true,
|
||||
Placeholder: "在此输入 Telegram 机器人的 Bot Token",
|
||||
Description: "通过 BotFather 申请到的机器人 Access Token",
|
||||
Placeholder: "Enter the Telegram bot token",
|
||||
Description: "Bot access token issued by BotFather",
|
||||
},
|
||||
{
|
||||
Key: KeyOther,
|
||||
Label: "默认会话 ID (Chat ID) (可选)",
|
||||
Label: "Default chat ID (optional)",
|
||||
Type: TypeText,
|
||||
Required: false,
|
||||
Placeholder: "例如 -100123456789 或 @channel_name",
|
||||
Description: "默认的消息接收 Chat ID。如果通知事件中未配置 targets,将推送到此 ID",
|
||||
Placeholder: "For example -100123456789 or @channel_name",
|
||||
Description: "Default recipient chat ID, used when the notification event has no targets",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -173,8 +173,8 @@ func init() {
|
||||
// Register Email channel
|
||||
RegisterChannelDefinition(Definition{
|
||||
Type: channelEmail,
|
||||
Name: "邮件推送通道",
|
||||
Description: "邮件推送通道直接使用系统全局 SMTP 设置进行发送,无需在此填写服务器配置。",
|
||||
Name: "Email notification channel",
|
||||
Description: "Email notifications use the global SMTP settings; no server configuration is required here.",
|
||||
Fields: []Field{},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,13 +15,13 @@ import (
|
||||
// AdminLogin is the metadata definition for the admin login event.
|
||||
var AdminLogin = push.EventMetadata{
|
||||
Key: "admin_login",
|
||||
Name: "管理员登录",
|
||||
Name: "Administrator login",
|
||||
DefaultTemplate: push.NotificationMessage{
|
||||
Title: "管理员登录提醒",
|
||||
Content: "管理员 {{user.username}} 于 {{time}} 从 IP {{ip}} 登录系统。",
|
||||
Title: "Administrator login notification",
|
||||
Content: "Administrator {{user.username}} signed in at {{time}} from IP {{ip}}.",
|
||||
Level: "INFO",
|
||||
},
|
||||
Description: "当管理员成功登录系统时触发此通知",
|
||||
Description: "Triggered when an administrator signs in successfully",
|
||||
}
|
||||
|
||||
func handleAdminLogin(ctx context.Context, event listener.AdminLoggedIn) {
|
||||
|
||||
@@ -138,7 +138,7 @@ func TestAdminLoginPushIntegration(t *testing.T) {
|
||||
|
||||
assert.Equal(t, AdminLogin.Key, payload.EventKey)
|
||||
assert.Equal(t, "ops_team", payload.Target)
|
||||
assert.Equal(t, "管理员登录提醒", payload.Body.Title)
|
||||
assert.Equal(t, "Administrator login notification", payload.Body.Title)
|
||||
assert.Contains(t, payload.Body.Content, "super_admin")
|
||||
assert.Contains(t, payload.Body.Content, "203.0.113.42")
|
||||
})
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/pkg/i18n"
|
||||
)
|
||||
|
||||
func localizeEventMetadata(locale string, meta EventMetadata) EventMetadata {
|
||||
meta.Name = i18n.Translate(locale, meta.Name)
|
||||
meta.Description = i18n.Translate(locale, meta.Description)
|
||||
meta.DefaultTemplate.Title = i18n.Translate(locale, meta.DefaultTemplate.Title)
|
||||
meta.DefaultTemplate.Content = i18n.Translate(locale, meta.DefaultTemplate.Content)
|
||||
return meta
|
||||
}
|
||||
|
||||
func localizePushEvent(locale string, event model.PushEvent) model.PushEvent {
|
||||
if meta, found := findBuiltInEvent(event.EventKey); found {
|
||||
if event.Name == meta.Name || i18n.Translate("en", event.Name) == meta.Name {
|
||||
event.Name = i18n.Translate(locale, meta.Name)
|
||||
}
|
||||
} else if meta := task.GetTaskMetaByAsynqTask(event.TaskType); meta != nil {
|
||||
prefix := "Task completed: "
|
||||
english := prefix + meta.Name
|
||||
chinese := i18n.Translate("zh-CN", prefix) + i18n.Translate("zh-CN", meta.Name)
|
||||
if event.Name == english || event.Name == chinese {
|
||||
event.Name = i18n.Translate(locale, prefix) + i18n.Translate(locale, meta.Name)
|
||||
}
|
||||
}
|
||||
// Templates and targets are administrator-owned data, even when they happen
|
||||
// to match a built-in message. Never rewrite them during presentation.
|
||||
return event
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package push
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLocalizeEventMetadataPreservesTemplatesAndRegistry(t *testing.T) {
|
||||
source := EventMetadata{Name: "Administrator login", DefaultTemplate: NotificationMessage{Title: "Administrator login notification"}}
|
||||
localized := localizeEventMetadata("zh-CN", source)
|
||||
assert.Equal(t, "管理员登录", localized.Name)
|
||||
assert.Equal(t, "管理员登录提醒", localized.DefaultTemplate.Title)
|
||||
assert.Equal(t, "Administrator login", source.Name)
|
||||
assert.Equal(t, "Administrator login notification", source.DefaultTemplate.Title)
|
||||
|
||||
event := model.PushEvent{Name: "自定义事件", Template: `{"title":"管理员登录提醒"}`, Targets: []string{"系统"}}
|
||||
assert.Equal(t, event, localizePushEvent("en", event))
|
||||
}
|
||||
@@ -24,6 +24,10 @@ type smtpConfig struct {
|
||||
Password string
|
||||
}
|
||||
|
||||
// This persisted recipient alias predates localization. Display translations
|
||||
// must never expand the set of reserved routing identifiers.
|
||||
const legacySystemTarget = "\u7cfb\u7edf"
|
||||
|
||||
func loadSMTPConfig(ctx context.Context) smtpConfig {
|
||||
host, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPHost)
|
||||
port, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPPort)
|
||||
@@ -278,7 +282,7 @@ func recordPushHistory(ctx context.Context, req SendPayload, status, errMsg stri
|
||||
content := req.Body.Content
|
||||
level := req.Body.Level
|
||||
if title == "" {
|
||||
title = "系统通知"
|
||||
title = "System notification"
|
||||
}
|
||||
if level == "" {
|
||||
level = defaultLevelInfo
|
||||
@@ -331,7 +335,7 @@ func resolveTargetUser(ctx context.Context, resolved string, _ string) (model.Us
|
||||
}
|
||||
|
||||
func resolveSystemTarget(ctx context.Context, resolved string, channel string) (string, bool) {
|
||||
if resolved != "系统" && resolved != "system" && resolved != "0" {
|
||||
if resolved != legacySystemTarget && resolved != "system" && resolved != "0" {
|
||||
return "", false
|
||||
}
|
||||
adminUser, err := repository.GetFirstAdminUser(ctx)
|
||||
@@ -383,10 +387,10 @@ func getEventInfo(req CreateEventRequest) (string, string, []byte, error) {
|
||||
return "", "", nil, errors.New("unsupported task type")
|
||||
}
|
||||
eventKey := "task_completed:" + req.TaskType
|
||||
eventName := "任务完成: " + meta.Name
|
||||
eventName := "Task completed: " + meta.Name
|
||||
defaultTemplate := NotificationMessage{
|
||||
Title: "任务完成: " + meta.Name,
|
||||
Content: "异步任务 {{task_name}} (ID: {{task_id}}) 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。",
|
||||
Title: "Task completed: " + meta.Name,
|
||||
Content: "Asynchronous task {{task_name}} (ID: {{task_id}}) completed. Status: {{task_status}}; duration: {{task_duration}} ms.",
|
||||
Level: defaultLevelInfo,
|
||||
}
|
||||
defaultTemplateBytes, err := json.Marshal(defaultTemplate)
|
||||
|
||||
@@ -312,7 +312,7 @@ func TestPushHandler(t *testing.T) {
|
||||
t.Run("execute task successfully", func(t *testing.T) {
|
||||
res, execErr := handler.Execute(context.Background(), payloadBytes)
|
||||
require.NoError(t, execErr)
|
||||
assert.Contains(t, res.Message, "推送成功")
|
||||
assert.Contains(t, res.Message, "Notification sent")
|
||||
|
||||
// Verify mock pusher received flattened variables
|
||||
mPusher.mu.Lock()
|
||||
@@ -553,6 +553,24 @@ func TestResolveTarget(t *testing.T) {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
for index, username := range []string{"System", "Система"} {
|
||||
t.Run("translated system label is an ordinary username/"+username, func(t *testing.T) {
|
||||
user := model.User{
|
||||
ID: uint64(20000 + index), Username: username,
|
||||
Email: "ordinary_" + strconv.Itoa(index) + "@example.invalid",
|
||||
}
|
||||
if err := dbConn.Create(&user).Error; err != nil {
|
||||
t.Fatalf("create user %q: %v", username, err)
|
||||
}
|
||||
for _, target := range []string{username, "user.username"} {
|
||||
body := map[string]any{"user.username": username}
|
||||
if got := resolveTarget(ctx, target, body, channelEmail); got != user.Email {
|
||||
t.Errorf("resolveTarget(%q, username=%q, email) = %q, want %q", target, username, got, user.Email)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("dynamic user.id resolved and converted for email channel", func(t *testing.T) {
|
||||
res := resolveTarget(ctx, "user.id", flatBody, "email")
|
||||
assert.Equal(t, "target@test.com", res)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
@@ -54,6 +55,9 @@ func ListEvents(c *gin.Context) {
|
||||
response.AbortInternal(c, err.Error())
|
||||
return
|
||||
}
|
||||
for index := range events {
|
||||
events[index] = localizePushEvent(response.Locale(c), events[index])
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(events))
|
||||
}
|
||||
|
||||
@@ -85,7 +89,11 @@ func findBuiltInEvent(key string) (EventMetadata, bool) {
|
||||
// @Success 200 {object} response.Any{data=[]EventMetadata} "内置通知事件列表"
|
||||
// @Router /api/v1/admin/push/events/builtin [get]
|
||||
func ListBuiltInEvents(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response.OK(BuiltInEvents))
|
||||
metas := slices.Clone(BuiltInEvents)
|
||||
for index := range metas {
|
||||
metas[index] = localizeEventMetadata(response.Locale(c), metas[index])
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(metas))
|
||||
}
|
||||
|
||||
// CreateEvent 创建通知事件
|
||||
@@ -110,7 +118,7 @@ func CreateEvent(c *gin.Context) {
|
||||
response.AbortBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(event))
|
||||
c.JSON(http.StatusOK, response.OK(localizePushEvent(response.Locale(c), event)))
|
||||
}
|
||||
|
||||
// DeleteEvent 删除通知事件配置
|
||||
@@ -280,8 +288,8 @@ func TestPush(c *gin.Context) {
|
||||
applySMTPFallbackToPushConfig(c.Request.Context(), &req.Config)
|
||||
|
||||
testBody := map[string]any{
|
||||
keyTitle: "测试通道推送",
|
||||
keyContent: "当您收到这条消息,说明当前渠道连通性测试通过。",
|
||||
keyTitle: "Notification channel test",
|
||||
keyContent: "Receiving this message confirms that the notification channel connectivity test succeeded.",
|
||||
keyLevel: defaultLevelInfo,
|
||||
}
|
||||
if _, err := pusher.Send(c.Request.Context(), req.Config, req.Target, testBody, "", nil); err != nil {
|
||||
|
||||
@@ -67,7 +67,7 @@ func handleTaskCompleted(ctx context.Context, execution *model.TaskExecution, re
|
||||
meta := EventMetadata{
|
||||
Key: event.EventKey,
|
||||
Name: event.Name,
|
||||
Description: "异步任务执行完毕触发的自动通知",
|
||||
Description: "Automatic notification triggered when an asynchronous task finishes",
|
||||
}
|
||||
DefaultTrigger.Trigger(ctx, meta, body)
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ const (
|
||||
var SendNotificationMeta = task.TaskMeta{
|
||||
Type: TaskTypeSendNotification,
|
||||
AsynqTask: SendNotificationTask,
|
||||
Name: "推送通知",
|
||||
Description: "异步执行系统通知的多渠道派发与推送",
|
||||
Name: "Send notification",
|
||||
Description: "Dispatch system notifications asynchronously across multiple channels",
|
||||
SupportsTime: false,
|
||||
MaxRetry: task.DefaultMaxRetry,
|
||||
Queue: task.QueueDefault,
|
||||
@@ -34,14 +34,14 @@ var SendNotificationMeta = task.TaskMeta{
|
||||
Params: []task.TaskParam{
|
||||
{
|
||||
Name: "event_key",
|
||||
Label: "事件标识",
|
||||
Label: "Event key",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Placeholder: "admin_login",
|
||||
},
|
||||
{
|
||||
Name: "target",
|
||||
Label: "目标接收者",
|
||||
Label: "Recipient",
|
||||
Type: "string",
|
||||
Required: false,
|
||||
},
|
||||
@@ -75,16 +75,16 @@ func (h *PushHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||
func (h *PushHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
||||
var req SendPayload
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
task.AppendLog(ctx, "解析推送参数失败: %v", err)
|
||||
task.AppendLog(ctx, "Failed to parse notification parameters: %v", err)
|
||||
return nil, fmt.Errorf("parse payload failed: %w", err)
|
||||
}
|
||||
|
||||
task.AppendLog(ctx, "开始推送通知: 事件 = %s, 渠道 = %s, 接收目标 = %s", req.EventKey, req.Config.Channel, req.Target)
|
||||
task.AppendLog(ctx, "Starting notification: event = %s, channel = %s, recipient = %s", req.EventKey, req.Config.Channel, req.Target)
|
||||
|
||||
pusher, err := push.GetPusher(req.Config.Channel)
|
||||
if err != nil {
|
||||
errWrap := fmt.Errorf("get pusher failed: %w", err)
|
||||
task.AppendLog(ctx, "推送失败: %v", errWrap)
|
||||
task.AppendLog(ctx, "Notification failed: %v", errWrap)
|
||||
if task.IsFinalAttempt(ctx) {
|
||||
h.recordHistory(ctx, req, "failed", errWrap.Error())
|
||||
}
|
||||
@@ -99,9 +99,9 @@ func (h *PushHandler) Execute(ctx context.Context, payload []byte) (*task.TaskRe
|
||||
content := req.Body.Content
|
||||
|
||||
if err != nil {
|
||||
task.AppendLog(ctx, "消息推送失败 (标题: %s): %v", title, err)
|
||||
task.AppendLog(ctx, "Failed to send notification (title: %s): %v", title, err)
|
||||
if upstreamResp != "" {
|
||||
task.AppendLog(ctx, "上游返回: %s", upstreamResp)
|
||||
task.AppendLog(ctx, "Upstream response: %s", upstreamResp)
|
||||
}
|
||||
if task.IsFinalAttempt(ctx) {
|
||||
h.recordHistory(ctx, req, "failed", err.Error())
|
||||
@@ -109,19 +109,19 @@ func (h *PushHandler) Execute(ctx context.Context, payload []byte) (*task.TaskRe
|
||||
return nil, fmt.Errorf("pusher.Send failed: %w", err)
|
||||
}
|
||||
|
||||
task.AppendLog(ctx, "消息推送成功 (标题: %s, 内容摘要: %s)", title, content)
|
||||
task.AppendLog(ctx, "Notification sent (title: %s, content summary: %s)", title, content)
|
||||
if upstreamResp != "" {
|
||||
task.AppendLog(ctx, "上游返回: %s", upstreamResp)
|
||||
task.AppendLog(ctx, "Upstream response: %s", upstreamResp)
|
||||
}
|
||||
h.recordHistory(ctx, req, "success", "")
|
||||
|
||||
return &task.TaskResult{
|
||||
Message: fmt.Sprintf("推送成功: [%s] -> %s", req.Config.Channel, req.Target),
|
||||
Message: fmt.Sprintf("Notification sent: [%s] -> %s", req.Config.Channel, req.Target),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *PushHandler) recordHistory(ctx context.Context, req SendPayload, status string, errMsg string) {
|
||||
if dbErr := recordPushHistory(ctx, req, status, errMsg); dbErr != nil {
|
||||
task.AppendLog(ctx, "写入推送历史审计记录失败: %v", dbErr)
|
||||
task.AppendLog(ctx, "Failed to write notification audit history: %v", dbErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,16 +55,16 @@ func GetLogDatabaseStatus(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
store, err := logstore.Active(ctx)
|
||||
if err != nil {
|
||||
logger.ErrorF(ctx, "获取日志存储实例失败: %v", err)
|
||||
response.AbortInternal(c, "日志存储初始化失败")
|
||||
logger.ErrorF(ctx, "Failed to get log storage instance: %v", err)
|
||||
response.AbortInternal(c, "Failed to initialize log storage")
|
||||
return
|
||||
}
|
||||
|
||||
// 分支判定复用同一 store 实例的 ActiveDatabase,避免与 Active 解析之间出现 TOCTOU。
|
||||
activeDB, err := store.Status.ActiveDatabase(ctx)
|
||||
if err != nil {
|
||||
logger.ErrorF(ctx, "获取日志库状态失败: %v", err)
|
||||
response.AbortInternal(c, "获取日志库状态失败")
|
||||
logger.ErrorF(ctx, "Failed to get log database status: %v", err)
|
||||
response.AbortInternal(c, "Failed to get log database status")
|
||||
return
|
||||
}
|
||||
migration := "idle"
|
||||
@@ -86,7 +86,7 @@ func GetLogDatabaseStatus(c *gin.Context) {
|
||||
if activeDB == logDBNameClickHouse {
|
||||
stats, err := store.Status.ClickHouseOperationalStats(ctx)
|
||||
if err != nil {
|
||||
logger.ErrorF(ctx, "获取 ClickHouse 运行指标失败: %v", err)
|
||||
logger.ErrorF(ctx, "Failed to get ClickHouse runtime metrics: %v", err)
|
||||
} else {
|
||||
stats.BatchWriters = collectBatchWriterStats()
|
||||
out.ClickHouse = stats
|
||||
@@ -101,7 +101,7 @@ func retentionOr(ctx context.Context, key string) int {
|
||||
v, err := repository.GetIntByKey(ctx, key)
|
||||
if err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
logger.ErrorF(ctx, "读取日志保留天数配置失败 key=%s: %v", key, err)
|
||||
logger.ErrorF(ctx, "Failed to read log retention configuration key=%s: %v", key, err)
|
||||
}
|
||||
return defaultLogRetentionDays
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package status
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFormatDuration(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
duration time.Duration
|
||||
want string
|
||||
}{
|
||||
{duration: 0, want: "0 seconds"},
|
||||
{duration: time.Second, want: "1 second"},
|
||||
{duration: 61 * time.Second, want: "1 minute 1 second"},
|
||||
{duration: 26 * time.Hour, want: "1 day 2 hours"},
|
||||
{duration: 48*time.Hour + 2*time.Minute + 5*time.Second, want: "2 days 2 minutes 5 seconds"},
|
||||
} {
|
||||
if got := formatDuration(tc.duration); got != tc.want {
|
||||
t.Errorf("formatDuration(%v) = %q, want %q", tc.duration, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/config"
|
||||
@@ -118,20 +119,20 @@ func formatDuration(d time.Duration) string {
|
||||
minutes := int(d.Minutes()) % minutesInHour
|
||||
seconds := int(d.Seconds()) % secondsInMinute
|
||||
|
||||
var res string
|
||||
if days > 0 {
|
||||
res += fmt.Sprintf("%d天", days)
|
||||
var parts []string
|
||||
for _, part := range []struct {
|
||||
value int
|
||||
unit string
|
||||
}{{days, "day"}, {hours, "hour"}, {minutes, "minute"}, {seconds, "second"}} {
|
||||
if part.value > 0 || (part.unit == "second" && len(parts) == 0) {
|
||||
unit := part.unit
|
||||
if part.value != 1 {
|
||||
unit += "s"
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d %s", part.value, unit))
|
||||
}
|
||||
}
|
||||
if hours > 0 {
|
||||
res += fmt.Sprintf("%d小时", hours)
|
||||
}
|
||||
if minutes > 0 {
|
||||
res += fmt.Sprintf("%d分钟", minutes)
|
||||
}
|
||||
if seconds > 0 || res == "" {
|
||||
res += fmt.Sprintf("%d秒钟", seconds)
|
||||
}
|
||||
return res
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// GetSystemStatus 获取系统状态信息
|
||||
@@ -156,9 +157,9 @@ func GetSystemStatus(c *gin.Context) {
|
||||
case m.LastGC > 0 && m.LastGC <= math.MaxInt64:
|
||||
lastGCTime = formatDuration(time.Since(time.Unix(0, int64(m.LastGC))))
|
||||
case m.LastGC > 0:
|
||||
lastGCTime = "未知"
|
||||
lastGCTime = "Unknown"
|
||||
default:
|
||||
lastGCTime = "无"
|
||||
lastGCTime = "None"
|
||||
}
|
||||
|
||||
var lastPause string
|
||||
@@ -292,7 +293,7 @@ func exportSQLite(c *gin.Context) {
|
||||
dir, err := os.MkdirTemp("", "openflare-db-export-*")
|
||||
if err != nil {
|
||||
logger.ErrorF(c.Request.Context(), "[db-export] create temporary directory failed: %v", err)
|
||||
response.AbortInternal(c, "无法创建数据库导出文件")
|
||||
response.AbortInternal(c, "Unable to create database export file")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
@@ -303,13 +304,13 @@ func exportSQLite(c *gin.Context) {
|
||||
path := filepath.Join(dir, "openflare.db")
|
||||
if err := repository.ExportSQLiteSnapshot(c.Request.Context(), path); err != nil {
|
||||
logger.ErrorF(c.Request.Context(), "[db-export] create SQLite snapshot failed: %v", err)
|
||||
response.AbortInternal(c, "无法导出数据库")
|
||||
response.AbortInternal(c, "Unable to export database")
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(path) //nolint:gosec // path is generated in a private temporary directory, not from user input
|
||||
if err != nil {
|
||||
response.AbortInternal(c, "无法打开数据库文件: "+err.Error())
|
||||
response.AbortInternal(c, "Unable to open database file: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
@@ -320,7 +321,7 @@ func exportSQLite(c *gin.Context) {
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
response.AbortInternal(c, "无法读取数据库文件信息: "+err.Error())
|
||||
response.AbortInternal(c, "Unable to read database file information: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -338,7 +339,7 @@ func exportPostgres(c *gin.Context) {
|
||||
// 检查 pg_dump 是否可用
|
||||
pgDumpPath, err := exec.LookPath("pg_dump")
|
||||
if err != nil {
|
||||
response.AbortInternal(c, "pg_dump 不可用,请确保服务器已安装 PostgreSQL 客户端工具")
|
||||
response.AbortInternal(c, "pg_dump is unavailable; install the PostgreSQL client tools on the server")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ package system_config
|
||||
|
||||
// 系统配置错误消息常量
|
||||
const (
|
||||
SystemConfigNotFound = "系统配置不存在"
|
||||
ConfigKeyRequired = "配置键不能为空"
|
||||
ConfigValueRequired = "配置值不能为空"
|
||||
ConfigKeyExists = "配置键已存在"
|
||||
StorageDriverSwitchRequiresMigration = "存在存量文件,请通过存储迁移任务切换存储引擎"
|
||||
SystemConfigNotFound = "System configuration was not found"
|
||||
ConfigKeyRequired = "Configuration key must not be empty"
|
||||
ConfigValueRequired = "Configuration value must not be empty"
|
||||
ConfigKeyExists = "configuration key already exists"
|
||||
StorageDriverSwitchRequiresMigration = "existing files were found; use the storage migration task to switch storage backends"
|
||||
)
|
||||
|
||||
@@ -43,7 +43,7 @@ func createSystemConfig(ctx context.Context, req CreateSystemConfigRequest) erro
|
||||
|
||||
invalidateSystemConfigCaches(ctx, req.Key)
|
||||
if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil {
|
||||
logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err)
|
||||
logger.WarnF(ctx, "Failed to clear public configuration list cache: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func updateSystemConfig(ctx context.Context, key string, req UpdateSystemConfigR
|
||||
|
||||
validatedVal, err := validateAndMergeStorageConfig(ctx, req.Value, config.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
return &storageConfigValidationError{err}
|
||||
}
|
||||
req.Value = validatedVal
|
||||
}
|
||||
@@ -123,9 +123,9 @@ func resolveStorageMigrationTasksOnDirectDriverUpdate(
|
||||
if err := repository.MarkFailedTaskExecutionsSucceededTx(
|
||||
tx,
|
||||
"storage:migrate",
|
||||
"存储配置直接更新,故障迁移任务自动标记为已解决",
|
||||
"Storage configuration updated directly; failed migration task marked as resolved",
|
||||
time.Now(),
|
||||
); err != nil {
|
||||
logger.ErrorF(ctx, "自动更新迁移任务状态失败: %v", err)
|
||||
logger.ErrorF(ctx, "Failed to automatically update migration task status: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -28,7 +27,7 @@ import (
|
||||
const maskedConfigValue = "******"
|
||||
|
||||
// protectedConfigKeyMessage 命中受保护 key 时返回给管理员的业务错误文案。
|
||||
const protectedConfigKeyMessage = "该配置项由系统任务管理,禁止手动修改"
|
||||
const protectedConfigKeyMessage = "this configuration is managed by system tasks and cannot be changed manually"
|
||||
|
||||
// protectedConfigKeys 仅允许内部(迁移任务/bootstrap)写入的 key。
|
||||
var protectedConfigKeys = map[string]bool{
|
||||
@@ -198,7 +197,7 @@ func UpdateSystemConfig(c *gin.Context) {
|
||||
|
||||
func invalidateSystemConfigCaches(ctx context.Context, key string) {
|
||||
if err := repository.InvalidateSystemConfigCache(ctx, key); err != nil {
|
||||
logger.WarnF(ctx, "清理系统配置缓存失败: %v", err)
|
||||
logger.WarnF(ctx, "Failed to clear system configuration cache: %v", err)
|
||||
}
|
||||
if cap.IsRuntimeConfigKey(key) {
|
||||
cap.InvalidateRuntimeSettings()
|
||||
@@ -220,7 +219,7 @@ func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) {
|
||||
}
|
||||
|
||||
if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil {
|
||||
logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err)
|
||||
logger.WarnF(ctx, "Failed to clear public configuration list cache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,15 +288,13 @@ func TestSMTP(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response.OK(resp))
|
||||
}
|
||||
|
||||
type storageConfigValidationError struct{ error }
|
||||
|
||||
func (e *storageConfigValidationError) Unwrap() error { return e.error }
|
||||
|
||||
func isStorageConfigValidationError(err error) bool {
|
||||
msg := err.Error()
|
||||
return msg == StorageDriverSwitchRequiresMigration ||
|
||||
strings.HasPrefix(msg, "解析") ||
|
||||
strings.HasPrefix(msg, "验证") ||
|
||||
strings.HasPrefix(msg, "初始化测试") ||
|
||||
strings.HasPrefix(msg, "存储连通性") ||
|
||||
strings.HasPrefix(msg, "序列化") ||
|
||||
strings.HasPrefix(msg, "检查存量文件")
|
||||
var validationErr *storageConfigValidationError
|
||||
return errors.As(err, &validationErr)
|
||||
}
|
||||
|
||||
func maskSensitiveConfig(key, value string) string {
|
||||
@@ -324,12 +321,12 @@ func maskSensitiveConfig(key, value string) string {
|
||||
func validateAndMergeStorageConfig(ctx context.Context, value string, currentConfig string) (string, error) {
|
||||
var currentCfg objectstore.Config
|
||||
if err := json.Unmarshal([]byte(currentConfig), ¤tCfg); err != nil {
|
||||
return "", fmt.Errorf("解析当前存储配置失败: %w", err)
|
||||
return "", fmt.Errorf("failed to parse current storage configuration: %w", err)
|
||||
}
|
||||
|
||||
var newCfg objectstore.Config
|
||||
if err := json.Unmarshal([]byte(value), &newCfg); err != nil {
|
||||
return "", fmt.Errorf("解析目标存储配置失败: %w", err)
|
||||
return "", fmt.Errorf("failed to parse target storage configuration: %w", err)
|
||||
}
|
||||
|
||||
// 合并被掩码屏蔽的敏感信息,获取完整的真实配置
|
||||
@@ -341,7 +338,7 @@ func validateAndMergeStorageConfig(ctx context.Context, value string, currentCon
|
||||
// 序列化为最终保存的真实明文配置,防止保存屏蔽的 ****** 字符
|
||||
unmaskedVal, err := json.Marshal(targetCfg)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化存储配置失败: %w", err)
|
||||
return "", fmt.Errorf("failed to serialize storage configuration: %w", err)
|
||||
}
|
||||
|
||||
return string(unmaskedVal), nil
|
||||
@@ -351,13 +348,13 @@ func validateMergedStorageConfig(ctx context.Context, currentCfg, newCfg, target
|
||||
if newCfg.Driver != "" && newCfg.Driver != currentCfg.Driver {
|
||||
uploadCount, err := repository.CountActiveUploads(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查存量文件失败: %w", err)
|
||||
return fmt.Errorf("failed to check existing files: %w", err)
|
||||
}
|
||||
if uploadCount > 0 {
|
||||
return errors.New(StorageDriverSwitchRequiresMigration)
|
||||
}
|
||||
if err := validateDriverConfig(targetCfg, newCfg.Driver); err != nil {
|
||||
return fmt.Errorf("验证目标存储配置参数失败: %w", err)
|
||||
return fmt.Errorf("failed to validate target storage parameters: %w", err)
|
||||
}
|
||||
pendingCfg := targetCfg
|
||||
pendingCfg.Driver = newCfg.Driver
|
||||
@@ -365,7 +362,7 @@ func validateMergedStorageConfig(ctx context.Context, currentCfg, newCfg, target
|
||||
}
|
||||
|
||||
if err := objectstore.ValidateConfig(targetCfg); err != nil {
|
||||
return fmt.Errorf("验证存储配置参数失败: %w", err)
|
||||
return fmt.Errorf("failed to validate storage parameters: %w", err)
|
||||
}
|
||||
return testStorageBackend(ctx, targetCfg, targetCfg.Driver)
|
||||
}
|
||||
@@ -378,10 +375,10 @@ func validateDriverConfig(cfg objectstore.Config, driver objectstore.Driver) err
|
||||
func testStorageBackend(ctx context.Context, cfg objectstore.Config, driver objectstore.Driver) error {
|
||||
testBackend, err := objectstore.NewBackend(ctx, cfg, driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("初始化测试存储实例失败: %w", err)
|
||||
return fmt.Errorf("failed to initialize test storage instance: %w", err)
|
||||
}
|
||||
if err := testBackend.Test(ctx); err != nil {
|
||||
return fmt.Errorf("存储连通性测试失败: %w", err)
|
||||
return fmt.Errorf("storage connectivity test failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,17 +7,17 @@ package task
|
||||
|
||||
// 任务管理相关错误消息
|
||||
const (
|
||||
InvalidTaskType = "无效的任务类型"
|
||||
InvalidTimeRange = "无效的时间范围"
|
||||
TaskDispatchFailed = "任务下发失败"
|
||||
UserIDRequired = "用户ID必填"
|
||||
TaskNotFound = "任务执行记录不存在"
|
||||
TaskNotRetryable = "该任务不支持重试"
|
||||
TaskNotFailed = "只有失败的任务才能重试"
|
||||
TaskMaxRetryExceeded = "已达到最大重试次数"
|
||||
TaskRetryFailed = "任务重试失败"
|
||||
InvalidCronExpression = "无效的 Cron 表达式"
|
||||
ScheduleNotFound = "定时任务不存在"
|
||||
ScheduleSaveFailed = "保存定时任务失败"
|
||||
ScheduleDeleteFailed = "删除定时任务失败"
|
||||
InvalidTaskType = "Invalid task type"
|
||||
InvalidTimeRange = "Invalid time range"
|
||||
TaskDispatchFailed = "Failed to dispatch task"
|
||||
UserIDRequired = "User ID is required"
|
||||
TaskNotFound = "Task execution was not found"
|
||||
TaskNotRetryable = "this task cannot be retried"
|
||||
TaskNotFailed = "Only failed tasks can be retried"
|
||||
TaskMaxRetryExceeded = "Maximum retry count reached"
|
||||
TaskRetryFailed = "Failed to retry task"
|
||||
InvalidCronExpression = "Invalid Cron expression"
|
||||
ScheduleNotFound = "Schedule was not found"
|
||||
ScheduleSaveFailed = "Failed to save schedule"
|
||||
ScheduleDeleteFailed = "Failed to delete schedule"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/pkg/i18n"
|
||||
)
|
||||
|
||||
// Localize copies, never the shared registry or persistent task records.
|
||||
func localizeTaskMeta(locale string, meta task.TaskMeta) task.TaskMeta {
|
||||
meta.Name = i18n.Translate(locale, meta.Name)
|
||||
meta.Description = i18n.Translate(locale, meta.Description)
|
||||
meta.Params = slices.Clone(meta.Params)
|
||||
for index := range meta.Params {
|
||||
param := &meta.Params[index]
|
||||
param.Label = i18n.Translate(locale, param.Label)
|
||||
param.Placeholder = i18n.Translate(locale, param.Placeholder)
|
||||
param.Description = i18n.Translate(locale, param.Description)
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func localizeTaskExecution(locale string, execution model.TaskExecution) model.TaskExecution {
|
||||
execution.TaskName = i18n.Translate(locale, execution.TaskName)
|
||||
execution.Log = i18n.TranslateLog(locale, execution.Log)
|
||||
execution.ErrorMessage = i18n.Translate(locale, execution.ErrorMessage)
|
||||
execution.Result = i18n.Translate(locale, execution.Result)
|
||||
return execution
|
||||
}
|
||||
|
||||
func localizeTaskExecutions(locale string, executions []model.TaskExecution) []model.TaskExecution {
|
||||
localized := slices.Clone(executions)
|
||||
for index := range localized {
|
||||
localized[index] = localizeTaskExecution(locale, localized[index])
|
||||
}
|
||||
return localized
|
||||
}
|
||||
|
||||
func localizeScheduleName(locale, name string, meta *task.TaskMeta) string {
|
||||
// Only the built-in default name is system text. Custom schedule names are data.
|
||||
if meta != nil && meta.Type == "system_cleanup" && i18n.Translate("en", name) == "Scheduled system cleanup" {
|
||||
return i18n.Translate(locale, "Scheduled system cleanup")
|
||||
}
|
||||
if meta != nil && i18n.Translate("en", name) == meta.Name {
|
||||
return i18n.Translate(locale, meta.Name)
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLocalizeTaskMetaDoesNotMutateSharedParams(t *testing.T) {
|
||||
source := task.TaskMeta{
|
||||
Name: "Switch log database", AsynqTask: "openflare:log_db_switch",
|
||||
Params: []task.TaskParam{{Name: "target", Label: "Target log database", Placeholder: "postgres|sqlite|clickhouse"}},
|
||||
}
|
||||
localized := localizeTaskMeta("zh-CN", source)
|
||||
assert.Equal(t, "切换日志数据库", localized.Name)
|
||||
assert.Equal(t, "目标日志库", localized.Params[0].Label)
|
||||
assert.Equal(t, "openflare:log_db_switch", localized.AsynqTask)
|
||||
assert.Equal(t, "target", localized.Params[0].Name)
|
||||
assert.Equal(t, "Target log database", source.Params[0].Label)
|
||||
}
|
||||
|
||||
func TestLocalizeTaskExecutionsPreservesSourceAndPayload(t *testing.T) {
|
||||
source := []model.TaskExecution{{
|
||||
TaskName: "OpenFlare SSL 自动续期", Log: "[12:34:56] 开始扫描待续期证书\n",
|
||||
Payload: `{"name":"开始扫描待续期证书"}`, TriggeredBy: "用户甲",
|
||||
}}
|
||||
localized := localizeTaskExecutions("en", source)
|
||||
assert.Equal(t, "OpenFlare automatic SSL renewal", localized[0].TaskName)
|
||||
assert.Equal(t, "[12:34:56] Scanning certificates due for renewal\n", localized[0].Log)
|
||||
assert.Equal(t, source[0].Payload, localized[0].Payload)
|
||||
assert.Equal(t, source[0].TriggeredBy, localized[0].TriggeredBy)
|
||||
assert.Equal(t, "OpenFlare SSL 自动续期", source[0].TaskName)
|
||||
}
|
||||
|
||||
func TestLocalizeScheduleNamePreservesCustomNames(t *testing.T) {
|
||||
meta := &task.TaskMeta{Type: "system_cleanup", Name: "System cleanup"}
|
||||
assert.Equal(t, "Scheduled system cleanup", localizeScheduleName("en", "系统定期垃圾清理", meta))
|
||||
assert.Equal(t, "系统定期垃圾清理", localizeScheduleName("zh-CN", "Scheduled system cleanup", meta))
|
||||
assert.Equal(t, "自定义每周清理", localizeScheduleName("en", "自定义每周清理", meta))
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -35,7 +36,11 @@ import (
|
||||
// @Failure 403 {object} response.Any "无管理员权限"
|
||||
// @Router /api/v1/admin/tasks/types [get]
|
||||
func ListTaskTypes(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response.OK(task.GetDispatchableTasks()))
|
||||
metas := task.GetDispatchableTasks()
|
||||
for index := range metas {
|
||||
metas[index] = localizeTaskMeta(response.Locale(c), metas[index])
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(metas))
|
||||
}
|
||||
|
||||
// DispatchTaskRequest 下发任务请求
|
||||
@@ -130,7 +135,7 @@ func ListTaskExecutions(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(gin.H{
|
||||
"items": executions,
|
||||
"items": localizeTaskExecutions(response.Locale(c), executions),
|
||||
"total": total,
|
||||
"page": req.Page,
|
||||
"page_size": req.PageSize,
|
||||
@@ -163,7 +168,8 @@ func GetTaskExecution(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(execution))
|
||||
localized := localizeTaskExecution(response.Locale(c), *execution)
|
||||
c.JSON(http.StatusOK, response.OK(localized))
|
||||
}
|
||||
|
||||
// RetryTask 重试失败的任务
|
||||
@@ -190,10 +196,11 @@ func RetryTask(c *gin.Context) {
|
||||
newTaskID, err := task.RetryTask(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
var retryErr *task.RetryError
|
||||
switch {
|
||||
case strings.Contains(errMsg, "不存在"):
|
||||
case errors.As(err, &retryErr) && retryErr.NotFound:
|
||||
response.AbortNotFound(c, errMsg)
|
||||
case strings.Contains(errMsg, "只有失败的任务") || strings.Contains(errMsg, "不支持重试") || strings.Contains(errMsg, "已达到最大重试"):
|
||||
case retryErr != nil:
|
||||
response.AbortBadRequest(c, errMsg)
|
||||
default:
|
||||
response.AbortInternal(c, fmt.Sprintf("%s: %v", TaskRetryFailed, err))
|
||||
@@ -226,6 +233,7 @@ func ListSchedules(c *gin.Context) {
|
||||
if meta != nil && meta.InternalOnly {
|
||||
continue
|
||||
}
|
||||
schedule.Name = localizeScheduleName(response.Locale(c), schedule.Name, meta)
|
||||
visible = append(visible, schedule)
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(visible))
|
||||
@@ -300,7 +308,7 @@ func CreateSchedule(c *gin.Context) {
|
||||
|
||||
// 触发调度服务重载
|
||||
if err := scheduler.ReloadScheduler(); err != nil {
|
||||
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
|
||||
logger.ErrorF(c.Request.Context(), "[TaskAdmin] Failed to reload scheduler: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(schedule))
|
||||
@@ -334,7 +342,7 @@ type UpdateScheduleRequest struct {
|
||||
func UpdateSchedule(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortBadRequest(c, "无效的定时任务ID")
|
||||
response.AbortBadRequest(c, "Invalid schedule ID")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -392,7 +400,7 @@ func UpdateSchedule(c *gin.Context) {
|
||||
|
||||
// 触发调度服务重载
|
||||
if err := scheduler.ReloadScheduler(); err != nil {
|
||||
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
|
||||
logger.ErrorF(c.Request.Context(), "[TaskAdmin] Failed to reload scheduler: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(schedule))
|
||||
@@ -423,7 +431,7 @@ func getAdminTaskMeta(taskType string) *task.TaskMeta {
|
||||
func DeleteSchedule(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.AbortBadRequest(c, "无效的定时任务ID")
|
||||
response.AbortBadRequest(c, "Invalid schedule ID")
|
||||
return
|
||||
}
|
||||
schedule, err := repository.GetScheduleByID(c.Request.Context(), id)
|
||||
@@ -443,7 +451,7 @@ func DeleteSchedule(c *gin.Context) {
|
||||
|
||||
// 触发调度服务重载
|
||||
if err := scheduler.ReloadScheduler(); err != nil {
|
||||
logger.ErrorF(c.Request.Context(), "[TaskAdmin] 重载调度器失败: %v", err)
|
||||
logger.ErrorF(c.Request.Context(), "[TaskAdmin] Failed to reload scheduler: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.OKNil())
|
||||
|
||||
@@ -410,7 +410,7 @@ func TestDispatchTask(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
var resp response.Any
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.Contains(t, resp.ErrorMsg, "无效的 JSON 格式")
|
||||
assert.Contains(t, resp.ErrorMsg, "invalid JSON format")
|
||||
})
|
||||
|
||||
t.Run("dispatch send_email task failure with missing fields", func(t *testing.T) {
|
||||
@@ -427,7 +427,7 @@ func TestDispatchTask(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
var resp response.Any
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.Contains(t, resp.ErrorMsg, "不能为空")
|
||||
assert.Contains(t, resp.ErrorMsg, "must not be empty")
|
||||
})
|
||||
|
||||
t.Run("dispatch invalid task type failure", func(t *testing.T) {
|
||||
|
||||
@@ -6,11 +6,11 @@ package template
|
||||
|
||||
// 模板管理相关错误消息
|
||||
const (
|
||||
TemplateNotFound = "模板不存在"
|
||||
TemplateKeyRequired = "模板标识符不能为空"
|
||||
TemplateNameRequired = "模板名称不能为空"
|
||||
TemplateContentRequired = "模板内容不能为空"
|
||||
TemplateKeyExists = "模板标识符已存在"
|
||||
SystemTemplateCannotDelete = "系统预置模板不可删除"
|
||||
SystemTemplateCannotModifyKey = "系统预置模板不可修改标识符"
|
||||
TemplateNotFound = "Template was not found"
|
||||
TemplateKeyRequired = "Template identifier must not be empty"
|
||||
TemplateNameRequired = "Template name must not be empty"
|
||||
TemplateContentRequired = "Template content must not be empty"
|
||||
TemplateKeyExists = "template identifier already exists"
|
||||
SystemTemplateCannotDelete = "built-in templates cannot be deleted"
|
||||
SystemTemplateCannotModifyKey = "Built-in template identifiers cannot be changed"
|
||||
)
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
package updater
|
||||
|
||||
const (
|
||||
errInvalidRepository = "上游仓库地址无效"
|
||||
errReleaseRequestFailed = "获取上游版本失败"
|
||||
errReleaseResponseInvalid = "上游版本响应无效"
|
||||
errNoCompatibleRelease = "未找到兼容的 Release"
|
||||
errNoCompatibleAsset = "未找到当前系统对应的 Release 资产"
|
||||
errDevelopmentBuild = "开发版本无法执行自动升级"
|
||||
errAlreadyUpToDate = "当前已是最新版本"
|
||||
errUpgradeAlreadyRunning = "已有升级任务正在执行"
|
||||
errAutomaticUpgradeBlocked = "当前平台暂不支持自动替换二进制"
|
||||
errInvalidRepository = "invalid upstream repository URL"
|
||||
errReleaseRequestFailed = "Failed to fetch upstream version"
|
||||
errReleaseResponseInvalid = "Invalid upstream version response"
|
||||
errNoCompatibleRelease = "no compatible release was found"
|
||||
errNoCompatibleAsset = "no release asset was found for the current platform"
|
||||
errDevelopmentBuild = "development builds do not support automatic updates"
|
||||
errAlreadyUpToDate = "already running the latest version"
|
||||
errUpgradeAlreadyRunning = "an update is already in progress"
|
||||
errAutomaticUpgradeBlocked = "automatic binary replacement is not supported on this platform"
|
||||
)
|
||||
|
||||
@@ -265,42 +265,42 @@ func (m *manager) status(ctx context.Context) (Status, releaseAsset, error) {
|
||||
|
||||
func downloadArchive(ctx context.Context, client releaseClient, asset releaseAsset, destination string) error {
|
||||
if asset.Size <= 0 || asset.Size > maxArchiveSize {
|
||||
return fmt.Errorf("release 资产大小无效: %d", asset.Size)
|
||||
return fmt.Errorf("invalid release asset size: %d", asset.Size)
|
||||
}
|
||||
logger.InfoF(ctx, "[Updater] Downloading release asset: %s", asset.Name)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.BrowserDownloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建升级下载请求失败: %w", err)
|
||||
return fmt.Errorf("failed to create update download request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "OpenFlare-Updater")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("下载升级资产失败: %w", err)
|
||||
return fmt.Errorf("failed to download update asset: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
// The downloaded body has already been validated by size before use.
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("下载升级资产失败: HTTP %d", resp.StatusCode)
|
||||
return fmt.Errorf("failed to download update asset: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, archiveFileMode) //nolint:gosec // destination is created inside the verified executable directory.
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建升级归档失败: %w", err)
|
||||
return fmt.Errorf("failed to create update archive: %w", err)
|
||||
}
|
||||
|
||||
written, err := io.Copy(file, io.LimitReader(resp.Body, maxArchiveSize+1))
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return fmt.Errorf("写入升级归档失败: %w", err)
|
||||
return fmt.Errorf("failed to write update archive: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return fmt.Errorf("关闭升级归档失败: %w", err)
|
||||
return fmt.Errorf("failed to close update archive: %w", err)
|
||||
}
|
||||
if written > maxArchiveSize || written != asset.Size {
|
||||
return fmt.Errorf("升级归档大小不匹配: got %d, want %d", written, asset.Size)
|
||||
return fmt.Errorf("update archive size mismatch: got %d, want %d", written, asset.Size)
|
||||
}
|
||||
logger.InfoF(ctx, "[Updater] Successfully downloaded release asset to %s", destination)
|
||||
return nil
|
||||
@@ -309,12 +309,12 @@ func downloadArchive(ctx context.Context, client releaseClient, asset releaseAss
|
||||
func safeArchivePath(destination, name string) (string, error) {
|
||||
cleanName := filepath.Clean(name)
|
||||
if filepath.IsAbs(cleanName) || cleanName == "." || strings.HasPrefix(cleanName, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("归档包含非法路径: %s", name)
|
||||
return "", fmt.Errorf("archive contains an invalid path: %s", name)
|
||||
}
|
||||
target := filepath.Join(destination, cleanName)
|
||||
relative, err := filepath.Rel(destination, target)
|
||||
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("归档路径越界: %s", name)
|
||||
return "", fmt.Errorf("archive path escapes the destination: %s", name)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
@@ -510,7 +510,7 @@ func extractTarGz(ctx context.Context, archivePath, destination, targetName stri
|
||||
return "", closeErr
|
||||
}
|
||||
if written > maxArchiveSize {
|
||||
return "", errors.New("解压后的程序文件超过大小限制")
|
||||
return "", errors.New("extracted executable exceeds the size limit")
|
||||
}
|
||||
logger.InfoF(ctx, "[Updater] Successfully extracted binary to %s", target)
|
||||
return target, nil
|
||||
@@ -563,7 +563,7 @@ func extractZip(ctx context.Context, archivePath, destination, targetName string
|
||||
return "", outputCloseErr
|
||||
}
|
||||
if written > maxArchiveSize {
|
||||
return "", errors.New("解压后的程序文件超过大小限制")
|
||||
return "", errors.New("extracted executable exceeds the size limit")
|
||||
}
|
||||
logger.InfoF(ctx, "[Updater] Successfully extracted binary to %s", target)
|
||||
return target, nil
|
||||
@@ -597,16 +597,16 @@ func (m *manager) prepareUpgrade(ctx context.Context) (string, string, error) {
|
||||
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("定位当前程序失败: %w", err)
|
||||
return "", "", fmt.Errorf("failed to locate current executable: %w", err)
|
||||
}
|
||||
executable, err = filepath.EvalSymlinks(executable)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("解析当前程序路径失败: %w", err)
|
||||
return "", "", fmt.Errorf("failed to resolve current executable path: %w", err)
|
||||
}
|
||||
|
||||
tempDir, err := os.MkdirTemp(filepath.Dir(executable), ".wavelet-update-*")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("创建升级目录失败: %w", err)
|
||||
return "", "", fmt.Errorf("failed to create update directory: %w", err)
|
||||
}
|
||||
|
||||
archivePath := filepath.Join(tempDir, asset.Name)
|
||||
@@ -628,7 +628,7 @@ func (m *manager) prepareUpgrade(ctx context.Context) (string, string, error) {
|
||||
if err != nil {
|
||||
// Cleanup is best effort because the extraction error is the actionable failure.
|
||||
_ = os.RemoveAll(tempDir)
|
||||
return "", "", fmt.Errorf("解压升级资产失败: %w", err)
|
||||
return "", "", fmt.Errorf("failed to extract update asset: %w", err)
|
||||
}
|
||||
logger.InfoF(ctx, "[Updater] Staged binary successfully prepared: %s", stagedBinary)
|
||||
m.upgrading = true
|
||||
|
||||
@@ -23,22 +23,22 @@ func replaceAndRestart(executable, stagedBinary string) error {
|
||||
backup := executable + ".old"
|
||||
|
||||
if err := os.Remove(backup); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("删除旧备份失败: %w", err)
|
||||
return fmt.Errorf("failed to remove previous backup: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(executable, backup); err != nil {
|
||||
return fmt.Errorf("备份当前程序失败: %w", err)
|
||||
return fmt.Errorf("failed to back up current executable: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(stagedBinary, executable); err != nil {
|
||||
_ = os.Rename(backup, executable)
|
||||
return fmt.Errorf("替换当前程序失败: %w", err)
|
||||
return fmt.Errorf("failed to replace current executable: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(executable, installedBinaryMode); err != nil { //nolint:gosec // the installed application binary must be executable.
|
||||
_ = os.Remove(executable)
|
||||
_ = os.Rename(backup, executable)
|
||||
return fmt.Errorf("设置程序执行权限失败: %w", err)
|
||||
return fmt.Errorf("failed to set executable permissions: %w", err)
|
||||
}
|
||||
|
||||
stagingDir := filepath.Dir(stagedBinary)
|
||||
|
||||
@@ -6,18 +6,18 @@
|
||||
package user
|
||||
|
||||
const (
|
||||
userNotFound = "用户不存在"
|
||||
cannotDisable = "不能禁用管理员用户"
|
||||
cannotDelete = "不能删除管理员用户"
|
||||
cannotDeleteSelf = "不能删除当前登录用户"
|
||||
updateUserFailed = "更新用户状态失败"
|
||||
deleteUserFailed = "删除用户失败"
|
||||
usernameExists = "用户名已存在"
|
||||
usernameRequired = "用户名不能为空"
|
||||
passwordTooShort = "密码长度不能少于 8 位" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
createUserFailed = "创建用户失败"
|
||||
emailRequired = "邮箱不能为空"
|
||||
emailExists = "邮箱已被注册"
|
||||
cannotRevokeSelfAdmin = "不能撤销当前登录用户的管理员权限"
|
||||
updateUserInfoFailed = "更新用户信息失败"
|
||||
userNotFound = "user was not found"
|
||||
cannotDisable = "administrator accounts cannot be disabled"
|
||||
cannotDelete = "administrator accounts cannot be deleted"
|
||||
cannotDeleteSelf = "the currently signed-in user cannot be deleted"
|
||||
updateUserFailed = "Failed to update user status"
|
||||
deleteUserFailed = "Failed to delete user"
|
||||
usernameExists = "username already exists"
|
||||
usernameRequired = "username must not be empty"
|
||||
passwordTooShort = "password must contain at least 8 characters" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
createUserFailed = "Failed to create user"
|
||||
emailRequired = "email address must not be empty"
|
||||
emailExists = "email address is already registered"
|
||||
cannotRevokeSelfAdmin = "administrator privileges cannot be revoked from the currently signed-in user"
|
||||
updateUserInfoFailed = "Failed to update user information"
|
||||
)
|
||||
|
||||
@@ -103,7 +103,7 @@ func abortUserLogicError(c *gin.Context, err error, notFoundMsg string, forbidde
|
||||
return true
|
||||
}
|
||||
logger.ErrorF(c.Request.Context(), "Admin user error: %v", err)
|
||||
response.AbortInternal(c, "内部服务器错误")
|
||||
response.AbortInternal(c, "Internal server error")
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func ListUsers(c *gin.Context) {
|
||||
total, modelUsers, err := listUsers(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
logger.ErrorF(c.Request.Context(), "List admin users failed: %v", err)
|
||||
response.AbortInternal(c, "获取用户列表失败")
|
||||
response.AbortInternal(c, "Failed to get user list")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -479,7 +479,7 @@ func (r *Runner) tryRegister(ctx context.Context, nodeID *string) (resultErr err
|
||||
}
|
||||
}()
|
||||
if strings.TrimSpace(r.Config.DiscoveryToken) == "" {
|
||||
return errors.New("agent token is empty and no discovery token is configured")
|
||||
return errors.New("agent_token is empty and discovery_token is not configured")
|
||||
}
|
||||
registrationKey, err := r.StateStore.EnsureRegistrationKey()
|
||||
if err != nil {
|
||||
|
||||
@@ -353,25 +353,25 @@ func joinManagedPath(base string, relative string) string {
|
||||
|
||||
func validate(cfg *Config) error {
|
||||
if cfg.ServerURL == "" {
|
||||
return errors.New("server_url 不能为空")
|
||||
return errors.New("server_url must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(cfg.AccessToken) == "" && strings.TrimSpace(cfg.DiscoveryToken) == "" {
|
||||
return errors.New("agent_token 和 discovery_token 不能同时为空")
|
||||
return errors.New("agent_token and discovery_token must not both be empty")
|
||||
}
|
||||
if cfg.NodeName == "" {
|
||||
return errors.New("node_name 不能为空")
|
||||
return errors.New("node_name must not be empty")
|
||||
}
|
||||
if cfg.NodeIP == "" {
|
||||
return errors.New("node_ip 不能为空")
|
||||
return errors.New("node_ip must not be empty")
|
||||
}
|
||||
if cfg.OpenrestyObservabilityPort <= 0 || cfg.OpenrestyObservabilityPort > 65535 {
|
||||
return errors.New("openresty_observability_port 必须在 1-65535 之间")
|
||||
return errors.New("openresty_observability_port must be between 1 and 65535")
|
||||
}
|
||||
if cfg.ObservabilityReplayMinutes <= 0 {
|
||||
return errors.New("observability_replay_minutes 必须大于 0")
|
||||
return errors.New("observability_replay_minutes must be greater than 0")
|
||||
}
|
||||
if cfg.MMDBUpdateInterval <= 0 {
|
||||
return errors.New("mmdb_update_interval 必须大于 0")
|
||||
return errors.New("mmdb_update_interval must be greater than 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -398,10 +398,10 @@ func (cfg *Config) toConfigFile() configFile {
|
||||
// Save persists the current configuration back to its original file path.
|
||||
func (cfg *Config) Save() error {
|
||||
if cfg == nil {
|
||||
return errors.New("config 不能为空")
|
||||
return errors.New("config must not be empty")
|
||||
}
|
||||
if cfg.configPath == "" {
|
||||
return errors.New("config path 未初始化")
|
||||
return errors.New("configuration path is not initialized")
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg.toConfigFile(), "", " ") //nolint:gosec // agent token must be persisted in local config file
|
||||
if err != nil {
|
||||
|
||||
@@ -325,7 +325,7 @@ func (m *Manager) ensureOpenRestyWorkerReadAccess() error {
|
||||
|
||||
func (m *Manager) activateConfig(ctx context.Context) error {
|
||||
if m.Executor == nil {
|
||||
return errors.New("executor 未配置")
|
||||
return errors.New("Executor is not configured")
|
||||
}
|
||||
if err := m.Executor.Test(ctx); err != nil {
|
||||
return err
|
||||
@@ -407,7 +407,7 @@ func (m *Manager) EnsureRuntime(ctx context.Context, recreate bool) error {
|
||||
m.runtimeMu.Lock()
|
||||
defer m.runtimeMu.Unlock()
|
||||
if m.Executor == nil {
|
||||
return errors.New("executor 未配置")
|
||||
return errors.New("Executor is not configured")
|
||||
}
|
||||
slog.Info("openresty ensure runtime requested", "recreate", recreate)
|
||||
return m.Executor.EnsureRuntime(ctx, recreate)
|
||||
@@ -422,7 +422,7 @@ func (m *Manager) EnsureSafeFallbackRuntime(ctx context.Context, reason string)
|
||||
|
||||
func (m *Manager) ensureSafeFallbackRuntime(ctx context.Context, reason string) error {
|
||||
if m.Executor == nil {
|
||||
return errors.New("executor 未配置")
|
||||
return errors.New("Executor is not configured")
|
||||
}
|
||||
trimmedReason := strings.TrimSpace(reason)
|
||||
if trimmedReason == "" {
|
||||
@@ -444,7 +444,7 @@ func (m *Manager) CheckHealth(ctx context.Context) error {
|
||||
m.runtimeMu.Lock()
|
||||
defer m.runtimeMu.Unlock()
|
||||
if m.Executor == nil {
|
||||
return errors.New("executor 未配置")
|
||||
return errors.New("Executor is not configured")
|
||||
}
|
||||
if m.MainConfigPath != "" {
|
||||
if _, err := os.Stat(m.MainConfigPath); os.IsNotExist(err) {
|
||||
@@ -462,7 +462,7 @@ func (m *Manager) Restart(ctx context.Context) error {
|
||||
m.runtimeMu.Lock()
|
||||
defer m.runtimeMu.Unlock()
|
||||
if m.Executor == nil {
|
||||
return errors.New("executor 未配置")
|
||||
return errors.New("Executor is not configured")
|
||||
}
|
||||
slog.Info("openresty restart requested")
|
||||
return m.Executor.Restart(ctx)
|
||||
@@ -473,10 +473,10 @@ func (m *Manager) CurrentChecksum() (string, error) {
|
||||
m.runtimeMu.Lock()
|
||||
defer m.runtimeMu.Unlock()
|
||||
if m.RouteConfigPath == "" {
|
||||
return "", errors.New("route config path 不能为空")
|
||||
return "", errors.New("route configuration path must not be empty")
|
||||
}
|
||||
if m.MainConfigPath == "" {
|
||||
return "", errors.New("main config path 不能为空")
|
||||
return "", errors.New("main configuration path must not be empty")
|
||||
}
|
||||
mainData, err := os.ReadFile(m.MainConfigPath)
|
||||
if err != nil {
|
||||
@@ -828,10 +828,10 @@ type managedFile struct {
|
||||
|
||||
func (m *Manager) backup() (*backupState, error) {
|
||||
if m.MainConfigPath == "" {
|
||||
return nil, errors.New("main config path 不能为空")
|
||||
return nil, errors.New("main configuration path must not be empty")
|
||||
}
|
||||
if m.RouteConfigPath == "" {
|
||||
return nil, errors.New("route config path 不能为空")
|
||||
return nil, errors.New("route configuration path must not be empty")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(m.MainConfigPath), nginxDirPerm); err != nil {
|
||||
return nil, err
|
||||
@@ -1143,10 +1143,10 @@ func (m *Manager) restoreRuntimeConfig(file *protocol.SupportFile, name string)
|
||||
|
||||
func (m *Manager) writeSafeDefaultFallbackFiles() error {
|
||||
if strings.TrimSpace(m.MainConfigPath) == "" {
|
||||
return errors.New("main config path 不能为空")
|
||||
return errors.New("main configuration path must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(m.RouteConfigPath) == "" {
|
||||
return errors.New("route config path 不能为空")
|
||||
return errors.New("route configuration path must not be empty")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(m.MainConfigPath), nginxDirPerm); err != nil {
|
||||
return err
|
||||
@@ -1203,7 +1203,7 @@ func removeLegacyPowConfig(path string) error {
|
||||
|
||||
func (m *Manager) certFileTargetPath(relativePath string) (string, error) {
|
||||
if strings.TrimSpace(m.CertDir) == "" {
|
||||
return "", errors.New("cert dir 不能为空")
|
||||
return "", errors.New("certificate directory must not be empty")
|
||||
}
|
||||
candidate := strings.TrimSpace(relativePath)
|
||||
if strings.Contains(candidate, `\`) {
|
||||
@@ -1211,7 +1211,7 @@ func (m *Manager) certFileTargetPath(relativePath string) (string, error) {
|
||||
}
|
||||
normalizedPath := filepath.Clean(filepath.FromSlash(candidate))
|
||||
if normalizedPath == "." || normalizedPath == "" {
|
||||
return "", errors.New("cert file path 不能为空")
|
||||
return "", errors.New("certificate file path must not be empty")
|
||||
}
|
||||
if filepath.IsAbs(normalizedPath) || filepath.VolumeName(normalizedPath) != "" {
|
||||
return "", fmt.Errorf("cert file path %q must be relative", relativePath)
|
||||
@@ -1240,7 +1240,7 @@ func certFileMode(relativePath string) fs.FileMode {
|
||||
|
||||
func luaFileTargetPath(baseDir string, relativePath string) (string, error) {
|
||||
if strings.TrimSpace(baseDir) == "" {
|
||||
return "", errors.New("lua dir 不能为空")
|
||||
return "", errors.New("lua directory must not be empty")
|
||||
}
|
||||
candidate := strings.TrimSpace(relativePath)
|
||||
if strings.Contains(candidate, `\`) {
|
||||
@@ -1248,7 +1248,7 @@ func luaFileTargetPath(baseDir string, relativePath string) (string, error) {
|
||||
}
|
||||
normalizedPath := filepath.Clean(filepath.FromSlash(candidate))
|
||||
if normalizedPath == "." || normalizedPath == "" {
|
||||
return "", errors.New("lua file path 不能为空")
|
||||
return "", errors.New("lua file path must not be empty")
|
||||
}
|
||||
if filepath.IsAbs(normalizedPath) || filepath.VolumeName(normalizedPath) != "" {
|
||||
return "", fmt.Errorf("lua file path %q must be relative", relativePath)
|
||||
|
||||
@@ -14,6 +14,12 @@ import (
|
||||
//go:embed pow_static
|
||||
var powStaticFS embed.FS
|
||||
|
||||
//go:embed pow_static/locales/challenge.en.json
|
||||
var powChallengeEnglish string
|
||||
|
||||
//go:embed pow_static/locales/challenge.zh-CN.json
|
||||
var powChallengeChinese string
|
||||
|
||||
const openRestyPowRuntimeLua = `local _M = {}
|
||||
|
||||
local source = debug.getinfo(1, "S").source or ""
|
||||
@@ -290,7 +296,7 @@ end
|
||||
return require("pow.runtime").check()
|
||||
`
|
||||
|
||||
const openRestyPowChallengeLua = `local cjson = require "cjson.safe"
|
||||
var openRestyPowChallengeLua = `local cjson = require "cjson.safe"
|
||||
|
||||
local pow_challenges = ngx.shared.openflare_pow_challenges
|
||||
local pow_config_dict = ngx.shared.openflare_pow_config
|
||||
@@ -353,21 +359,17 @@ if string.find(accept_lang, "zh") then
|
||||
lang = "zh-CN"
|
||||
end
|
||||
|
||||
local t_title = "Making sure you're not a bot!"
|
||||
local t_status = "Loading..."
|
||||
local t_protected = "This site is protected by a Proof-of-Work challenge. Your browser will solve a small puzzle before the upstream response is shown."
|
||||
local t_why = "Why am I seeing this?"
|
||||
local t_why_desc = "OpenFlare is asking your browser to complete a lightweight computation to distinguish normal browser traffic from automated abuse. This should finish automatically."
|
||||
local t_noscript = "JavaScript is required to pass this verification. Please enable JavaScript and reload."
|
||||
|
||||
if lang == "zh-CN" then
|
||||
t_title = "正在确认你是不是机器人!"
|
||||
t_status = "加载中..."
|
||||
t_protected = "本网站受工作量证明(Proof-of-Work)挑战保护。在显示源站响应之前,您的浏览器将解决一个微型谜题。"
|
||||
t_why = "为什么我会看到这个?"
|
||||
t_why_desc = "OpenFlare 正在要求您的浏览器完成一项轻量级计算,以区分正常的浏览器流量和自动化的恶意请求。这应该会自动完成。"
|
||||
t_noscript = "很遗憾,您必须启用 JavaScript 才能通过这项验证。请开启 JavaScript 并刷新页面。"
|
||||
end
|
||||
local translations = {
|
||||
en = cjson.decode([==[` + powChallengeEnglish + `]==]),
|
||||
["zh-CN"] = cjson.decode([==[` + powChallengeChinese + `]==])
|
||||
}
|
||||
local text = translations[lang] or translations.en
|
||||
local t_title = text.title
|
||||
local t_status = text.status
|
||||
local t_protected = text.protected
|
||||
local t_why = text.why
|
||||
local t_why_desc = text.why_description
|
||||
local t_noscript = text.noscript
|
||||
|
||||
ngx.header.content_type = "text/html; charset=utf-8"
|
||||
ngx.say([[<!DOCTYPE html>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"title": "Making sure you're not a bot!",
|
||||
"status": "Loading...",
|
||||
"protected": "This site is protected by a Proof-of-Work challenge. Your browser will solve a small puzzle before the upstream response is shown.",
|
||||
"why": "Why am I seeing this?",
|
||||
"why_description": "OpenFlare is asking your browser to complete a lightweight computation to distinguish normal browser traffic from automated abuse. This should finish automatically.",
|
||||
"noscript": "JavaScript is required to pass this verification. Please enable JavaScript and reload."
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"title": "正在确认你是不是机器人!",
|
||||
"status": "加载中...",
|
||||
"protected": "本网站受工作量证明(Proof-of-Work)挑战保护。在显示源站响应之前,您的浏览器将解决一个微型谜题。",
|
||||
"why": "为什么我会看到这个?",
|
||||
"why_description": "OpenFlare 正在要求您的浏览器完成一项轻量级计算,以区分正常的浏览器流量和自动化的恶意请求。这应该会自动完成。",
|
||||
"noscript": "很遗憾,您必须启用 JavaScript 才能通过这项验证。请开启 JavaScript 并刷新页面。"
|
||||
}
|
||||
@@ -5,6 +5,6 @@
|
||||
package cap
|
||||
|
||||
const (
|
||||
errCapTokenMissing = "验证码验证失败,缺少验证码凭证" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errCapTokenInvalidOrExpired = "验证码校验失败或已过期,请重试" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errCapTokenMissing = "CAPTCHA verification failed: missing verification credential" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errCapTokenInvalidOrExpired = "CAPTCHA verification failed or expired; please try again" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
)
|
||||
|
||||
@@ -70,7 +70,7 @@ func Challenge(c *gin.Context) {
|
||||
func Redeem(c *gin.Context) {
|
||||
var req redeemRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortBadRequest(c, "无效的参数")
|
||||
response.AbortBadRequest(c, "invalid parameters")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -115,10 +115,10 @@ func applyDefaults(cfg *Config, baseDir string) {
|
||||
|
||||
func validate(cfg *Config) error {
|
||||
if cfg.ServerURL == "" {
|
||||
return errors.New("server_url 不能为空")
|
||||
return errors.New("server_url must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(cfg.TunnelToken) == "" {
|
||||
return errors.New("tunnel_token 不能为空")
|
||||
return errors.New("tunnel_token must not be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -134,10 +134,10 @@ func (cfg *Config) InitialAuthToken() string {
|
||||
// Save writes the current configuration back to the loaded config path.
|
||||
func (cfg *Config) Save() error {
|
||||
if cfg == nil {
|
||||
return errors.New("config 不能为空")
|
||||
return errors.New("config must not be empty")
|
||||
}
|
||||
if cfg.configPath == "" {
|
||||
return errors.New("config path 未初始化")
|
||||
return errors.New("configuration path is not initialized")
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
|
||||
+13
-13
@@ -6,18 +6,18 @@ package oauth
|
||||
|
||||
// OAuth 认证相关错误消息
|
||||
const (
|
||||
errInvalidState = "非法登录请求"
|
||||
errIDTokenVerifyFailed = "ID Token 验证失败" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errInvalidState = "Invalid login request"
|
||||
errIDTokenVerifyFailed = "ID token verification failed" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errIDTokenVerifyFailedFormat = "%s: %w"
|
||||
errNonceMismatch = "nonce 不匹配,可能存在重放攻击"
|
||||
errNoActiveAuthSource = "未配置可用认证源"
|
||||
errServerAddressMissing = "服务器地址 (server_address) 未配置或配置为空,请在后台系统设置中配置后再试"
|
||||
errAuthSourceRequired = "认证源不能为空"
|
||||
errDiscoveryURLRequired = "OIDC 认证源必须配置 Discovery URL"
|
||||
errUsernameGenerateFailed = "无法生成可用用户名"
|
||||
errUsernameFromSourceFailed = "无法从认证源获取用户名"
|
||||
errAuthSourceDisabled = "认证源未启用"
|
||||
errInvalidExternalAccountBindingID = "绑定记录 ID 无效"
|
||||
ErrTokenAuthNotAllowed = "该端点不允许使用访问令牌进行身份验证" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errOAuthStateRateLimited = "登录请求过于频繁,请稍后再试"
|
||||
errNonceMismatch = "nonce mismatch; possible replay attack"
|
||||
errNoActiveAuthSource = "no available authentication source is configured"
|
||||
errServerAddressMissing = "server address (server_address) is not configured; configure it in system settings and try again"
|
||||
errAuthSourceRequired = "authentication source must not be empty"
|
||||
errDiscoveryURLRequired = "OIDC authentication sources require a Discovery URL"
|
||||
errUsernameGenerateFailed = "unable to generate an available username"
|
||||
errUsernameFromSourceFailed = "unable to get username from authentication source"
|
||||
errAuthSourceDisabled = "Authentication source is disabled"
|
||||
errInvalidExternalAccountBindingID = "Invalid account binding ID"
|
||||
ErrTokenAuthNotAllowed = "This endpoint does not allow access-token authentication" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errOAuthStateRateLimited = "too many login requests; please try again later"
|
||||
)
|
||||
|
||||
@@ -4,19 +4,19 @@
|
||||
package agent
|
||||
|
||||
const (
|
||||
errMissingAgentToken = "缺少 Agent Token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errInvalidAgentToken = "无权进行此操作,Agent Token 无效" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errInvalidDiscoveryToken = "无权进行此操作,注册 Token 无效" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errMissingAgentToken = "missing agent token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errInvalidAgentToken = "Operation not authorized: invalid agent token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errInvalidDiscoveryToken = "Operation not authorized: invalid registration token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errNodeMissingFromContext = "Node object missing from context"
|
||||
errNoActiveConfig = "当前没有激活版本"
|
||||
errNodeNotFound = "节点不存在"
|
||||
errNodeIDRequired = "node_id 不能为空"
|
||||
errVersionRequired = "version 不能为空"
|
||||
errInvalidApplyResult = "result 仅支持 success、warning 或 failed"
|
||||
errIPRequired = "ip 不能为空"
|
||||
errIPInvalid = "ip 格式无效"
|
||||
errAgentVersionRequired = "version 不能为空"
|
||||
errNodeIDConflict = "节点标识生成冲突,请重试"
|
||||
errNoActiveConfig = "no active configuration version"
|
||||
errNodeNotFound = "node was not found"
|
||||
errNodeIDRequired = "node_id must not be empty"
|
||||
errVersionRequired = "version must not be empty"
|
||||
errInvalidApplyResult = "result must be success, warning or failed"
|
||||
errIPRequired = "ip must not be empty"
|
||||
errIPInvalid = "invalid IP address format"
|
||||
errAgentVersionRequired = "version must not be empty"
|
||||
errNodeIDConflict = "node identifier conflict; please try again"
|
||||
errRegistrationKeyInvalid = "invalid discovery registration key"
|
||||
errRegistrationCredentialChanged = "discovery enrollment credential has changed; use the assigned agent token"
|
||||
)
|
||||
|
||||
@@ -270,12 +270,12 @@ func DownloadPagesProjectLatestPackageHandler(c *gin.Context) {
|
||||
func pagesUintParam(c *gin.Context, name string) (uint, bool) {
|
||||
raw := c.Param(name)
|
||||
if raw == "" {
|
||||
response.AbortBadRequest(c, "无效的 ID")
|
||||
response.AbortBadRequest(c, "Invalid ID")
|
||||
return 0, false
|
||||
}
|
||||
id64, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || id64 == 0 {
|
||||
response.AbortBadRequest(c, "无效的 ID")
|
||||
response.AbortBadRequest(c, "Invalid ID")
|
||||
return 0, false
|
||||
}
|
||||
return uint(id64), true
|
||||
|
||||
@@ -124,7 +124,7 @@ func buildAgentWAFIPGroups(ctx context.Context, ids []uint) ([]WAFIPGroup, error
|
||||
|
||||
func buildAgentWAFIPGroup(group *model.OpenFlareWAFIPGroup) (WAFIPGroup, error) {
|
||||
if group == nil {
|
||||
return WAFIPGroup{}, errors.New("IP 组不存在")
|
||||
return WAFIPGroup{}, errors.New("IP group was not found")
|
||||
}
|
||||
ips, err := decodeWAFIPGroupStringList(group.IPList)
|
||||
if err != nil {
|
||||
@@ -192,7 +192,7 @@ func activeConfigWAFIPGroupIDs(ctx context.Context) ([]uint, error) {
|
||||
}
|
||||
ids, err := runtimeIPMatchGroupIDs(node.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("活动配置 WAF 规则 %d 节点 %s 的 IP 匹配配置无效: %w", group.ID, nodeID, err)
|
||||
return nil, fmt.Errorf("invalid IP match configuration in active WAF rule %d, node %s: %w", group.ID, nodeID, err)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id > 0 {
|
||||
|
||||
@@ -150,8 +150,8 @@ func TestChangedWAFIPGroupsForAgentRejectsMalformedIPMatchConfig(t *testing.T) {
|
||||
}).Error)
|
||||
|
||||
_, err := ChangedWAFIPGroupsForAgent(ctx, nil, nil)
|
||||
require.ErrorContains(t, err, "规则 7 节点 match")
|
||||
require.ErrorContains(t, err, "IP 匹配配置无效")
|
||||
require.ErrorContains(t, err, "rule 7, node match")
|
||||
require.ErrorContains(t, err, "invalid IP match configuration")
|
||||
}
|
||||
|
||||
func TestChangedWAFIPGroupsForAgentRejectsOversizedSnapshotBeforeChecksumDelta(t *testing.T) {
|
||||
@@ -172,8 +172,8 @@ func TestChangedWAFIPGroupsForAgentRejectsOversizedSnapshotBeforeChecksumDelta(t
|
||||
_, err = ChangedWAFIPGroupsForAgent(ctx, []uint{ipGroup.ID}, map[string]string{
|
||||
strconv.FormatUint(uint64(ipGroup.ID), 10): agentGroup.Checksum,
|
||||
})
|
||||
require.ErrorContains(t, err, "WAF IP 组快照大小")
|
||||
require.ErrorContains(t, err, "超过上限")
|
||||
require.ErrorContains(t, err, "WAF IP group snapshot size")
|
||||
require.ErrorContains(t, err, "exceeds the limit")
|
||||
}
|
||||
|
||||
func TestChangedWAFIPGroupsForAgentReturnsChecksumDelta(t *testing.T) {
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const errInvalidParams = "参数错误"
|
||||
const errInvalidID = "无效的 ID"
|
||||
const errInvalidParams = "invalid parameters"
|
||||
const errInvalidID = "Invalid ID"
|
||||
|
||||
// BindJSON binds JSON body; returns false after aborting with 400.
|
||||
func BindJSON(c *gin.Context, dst any) bool {
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
package apply_log
|
||||
|
||||
const (
|
||||
errRetentionDaysOutOfRange = "retention_days 必须在 1 到 3650 之间"
|
||||
errRetentionDaysOutOfRange = "retention_days must be between 1 and 3650"
|
||||
)
|
||||
|
||||
@@ -49,8 +49,8 @@ var (
|
||||
var SSLRenewMeta = task.TaskMeta{
|
||||
Type: TaskTypeSSLRenew,
|
||||
AsynqTask: SSLRenewTask,
|
||||
Name: "OpenFlare SSL 自动续期",
|
||||
Description: "扫描即将到期的 ACME 证书并触发自动续期",
|
||||
Name: "OpenFlare automatic SSL renewal",
|
||||
Description: "Scan ACME certificates nearing expiration and renew them automatically",
|
||||
SupportsTime: false,
|
||||
MaxRetry: task.DefaultMaxRetry,
|
||||
Queue: task.QueueDefault,
|
||||
@@ -61,8 +61,8 @@ var SSLRenewMeta = task.TaskMeta{
|
||||
var WAFIPGroupSyncMeta = task.TaskMeta{
|
||||
Type: TaskTypeWAFIPGroupSync,
|
||||
AsynqTask: WAFIPGroupSyncTask,
|
||||
Name: "OpenFlare WAF IP 组同步",
|
||||
Description: "同步到期的自动规则与订阅类型 WAF IP 组",
|
||||
Name: "OpenFlare WAF IP group synchronization",
|
||||
Description: "Synchronize automatic and subscription WAF IP groups that are due for an update",
|
||||
SupportsTime: false,
|
||||
MaxRetry: task.DefaultMaxRetry,
|
||||
Queue: task.QueueDefault,
|
||||
@@ -73,8 +73,8 @@ var WAFIPGroupSyncMeta = task.TaskMeta{
|
||||
var UptimeKumaSyncMeta = task.TaskMeta{
|
||||
Type: TaskTypeUptimeKumaSync,
|
||||
AsynqTask: UptimeKumaSyncTask,
|
||||
Name: "OpenFlare Uptime Kuma 同步",
|
||||
Description: "将启用的代理规则同步到 Uptime Kuma 监控",
|
||||
Name: "OpenFlare Uptime Kuma synchronization",
|
||||
Description: "Synchronize enabled proxy routes with Uptime Kuma monitors",
|
||||
SupportsTime: false,
|
||||
MaxRetry: task.DefaultMaxRetry,
|
||||
Queue: task.QueueDefault,
|
||||
@@ -85,15 +85,15 @@ var UptimeKumaSyncMeta = task.TaskMeta{
|
||||
var LogDBSwitchMeta = task.TaskMeta{
|
||||
Type: TaskTypeLogDBSwitch,
|
||||
AsynqTask: LogDBSwitchTask,
|
||||
Name: "切换日志数据库",
|
||||
Description: "复制迁移日志数据并在成功后切换日志主库(期间禁止日志写入)",
|
||||
Name: "Switch log database",
|
||||
Description: "Copy log data and switch the log database on success; log writes are paused during migration",
|
||||
SupportsTime: false,
|
||||
MaxRetry: task.DefaultMaxRetry,
|
||||
Queue: task.QueueDefault,
|
||||
Retryable: true,
|
||||
Params: []task.TaskParam{
|
||||
{Name: "target", Label: "目标日志库", Type: "string", Required: true,
|
||||
Placeholder: "postgres|sqlite|clickhouse", Description: "迁移目标:postgres(主库为 PG 时)、sqlite(主库为 SQLite 时)或 clickhouse"},
|
||||
{Name: "target", Label: "Target log database", Type: "string", Required: true,
|
||||
Placeholder: "postgres|sqlite|clickhouse", Description: "Migration target: postgres for a PostgreSQL primary, sqlite for a SQLite primary, or clickhouse"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -102,12 +102,12 @@ type SSLRenewHandler struct{}
|
||||
|
||||
// Execute runs SSL certificate renewal for all due certificates.
|
||||
func (h *SSLRenewHandler) Execute(ctx context.Context, _ []byte) (*task.TaskResult, error) {
|
||||
task.AppendLog(ctx, "开始扫描待续期证书")
|
||||
task.AppendLog(ctx, "Scanning certificates due for renewal")
|
||||
if err := tasks.RunSSLRenewJob(ctx); err != nil {
|
||||
task.AppendLog(ctx, "SSL 自动续期失败: %v", err)
|
||||
task.AppendLog(ctx, "Automatic SSL renewal failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
msg := "SSL 自动续期任务完成"
|
||||
msg := "Automatic SSL renewal task completed"
|
||||
task.AppendLog(ctx, "%s", msg)
|
||||
return &task.TaskResult{Message: msg}, nil
|
||||
}
|
||||
@@ -117,12 +117,12 @@ type WAFIPGroupSyncHandler struct{}
|
||||
|
||||
// Execute syncs all due automatic/subscription WAF IP groups.
|
||||
func (h *WAFIPGroupSyncHandler) Execute(ctx context.Context, _ []byte) (*task.TaskResult, error) {
|
||||
task.AppendLog(ctx, "开始同步到期的 WAF IP 组")
|
||||
task.AppendLog(ctx, "Synchronizing WAF IP groups due for an update")
|
||||
if err := waf.SyncDueWAFIPGroups(ctx); err != nil {
|
||||
task.AppendLog(ctx, "WAF IP 组同步失败: %v", err)
|
||||
task.AppendLog(ctx, "WAF IP group synchronization failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
msg := "WAF IP 组同步完成"
|
||||
msg := "WAF IP group synchronization completed"
|
||||
task.AppendLog(ctx, "%s", msg)
|
||||
return &task.TaskResult{Message: msg}, nil
|
||||
}
|
||||
@@ -135,7 +135,7 @@ func (h *UptimeKumaSyncHandler) Execute(ctx context.Context, _ []byte) (*task.Ta
|
||||
// 从 SystemConfig 读取 UptimeKuma 配置
|
||||
enabled, _ := repository.GetBoolByKey(ctx, model.ConfigKeyUptimeKumaEnabled)
|
||||
if !enabled {
|
||||
msg := "Uptime Kuma 集成未启用,跳过执行"
|
||||
msg := "Uptime Kuma integration is disabled; skipping execution"
|
||||
task.AppendLog(ctx, "%s", msg)
|
||||
return &task.TaskResult{Message: msg}, nil
|
||||
}
|
||||
@@ -145,26 +145,26 @@ func (h *UptimeKumaSyncHandler) Execute(ctx context.Context, _ []byte) (*task.Ta
|
||||
interval = 5
|
||||
}
|
||||
if time.Since(lastUptimeKumaSyncTime) < time.Duration(interval)*time.Minute {
|
||||
msg := fmt.Sprintf("距上次同步不足 %d 分钟,跳过执行", interval)
|
||||
msg := fmt.Sprintf("Less than %d minutes since the last synchronization; skipping execution", interval)
|
||||
task.AppendLog(ctx, "%s", msg)
|
||||
return &task.TaskResult{Message: msg}, nil
|
||||
}
|
||||
|
||||
if !uptimeKumaSyncMutex.TryLock() {
|
||||
msg := "Uptime Kuma 同步任务正在执行,跳过本次调度"
|
||||
msg := "Uptime Kuma synchronization is already running; skipping this schedule"
|
||||
task.AppendLog(ctx, "%s", msg)
|
||||
return &task.TaskResult{Message: msg}, nil
|
||||
}
|
||||
defer uptimeKumaSyncMutex.Unlock()
|
||||
|
||||
task.AppendLog(ctx, "开始同步代理规则到 Uptime Kuma")
|
||||
task.AppendLog(ctx, "Synchronizing proxy routes with Uptime Kuma")
|
||||
if err := uptimekuma.SyncToUptimeKuma(ctx); err != nil {
|
||||
task.AppendLog(ctx, "Uptime Kuma 同步失败: %v", err)
|
||||
task.AppendLog(ctx, "Uptime Kuma synchronization failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lastUptimeKumaSyncTime = time.Now()
|
||||
msg := "Uptime Kuma 同步完成"
|
||||
msg := "Uptime Kuma synchronization completed"
|
||||
task.AppendLog(ctx, "%s", msg)
|
||||
return &task.TaskResult{Message: msg}, nil
|
||||
}
|
||||
|
||||
@@ -31,5 +31,5 @@ func TestUptimeKumaSyncHandlerSkipsWhenDisabled(t *testing.T) {
|
||||
result, err := (&UptimeKumaSyncHandler{}).Execute(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.Contains(t, result.Message, "未启用")
|
||||
assert.Contains(t, result.Message, "disabled")
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ func (client *HTTPClient) VerifyToken(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
if result.Status != "active" {
|
||||
return errors.New("cloudflare API Token 未激活")
|
||||
return errors.New("cloudflare API token is inactive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -131,7 +131,7 @@ func (client *HTTPClient) FindZone(ctx context.Context, name string) (*Zone, err
|
||||
return nil, err
|
||||
}
|
||||
if len(zones) != 1 {
|
||||
return nil, fmt.Errorf("cloudflare 中未找到唯一 Zone %s", name)
|
||||
return nil, fmt.Errorf("no unique Cloudflare zone found for %s", name)
|
||||
}
|
||||
return &zones[0], nil
|
||||
}
|
||||
@@ -220,7 +220,7 @@ func (client *HTTPClient) do(ctx context.Context, method, path string, query url
|
||||
}
|
||||
return decodeAPIResponse(statusCode, responseBody, result)
|
||||
}
|
||||
return errors.New("cloudflare API 请求超过重试次数")
|
||||
return errors.New("cloudflare API retry limit exceeded")
|
||||
}
|
||||
|
||||
func encodeRequestBody(body any) ([]byte, error) {
|
||||
@@ -251,7 +251,7 @@ func (client *HTTPClient) send(ctx context.Context, method, requestURL string, b
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return 0, "", nil, fmt.Errorf("cloudflare API 请求失败: %w", err)
|
||||
return 0, "", nil, fmt.Errorf("cloudflare API request failed: %w", err)
|
||||
}
|
||||
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, maxResponseBodyBytes))
|
||||
closeErr := response.Body.Close()
|
||||
@@ -281,7 +281,7 @@ func decodeAPIResponse(statusCode int, responseBody []byte, result any) error {
|
||||
return fmt.Errorf("decode Cloudflare response: %w", err)
|
||||
}
|
||||
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices || !envelope.Success {
|
||||
message := "cloudflare API 请求失败"
|
||||
message := "Cloudflare API request failed"
|
||||
if len(envelope.Errors) > 0 && strings.TrimSpace(envelope.Errors[0].Message) != "" {
|
||||
message = envelope.Errors[0].Message
|
||||
}
|
||||
|
||||
@@ -4,25 +4,25 @@
|
||||
package cloudflare
|
||||
|
||||
const (
|
||||
errConnectionNotConfigured = "尚未配置 Cloudflare 连接"
|
||||
errConnectionSourceInvalid = "无效的 Cloudflare 连接来源"
|
||||
errStandaloneInputRequired = "请填写 Cloudflare API Token"
|
||||
errStandaloneInputInvalid = "配置的 Cloudflare API Token 无效"
|
||||
errDNSAccountInvalid = "请选择有效的 Cloudflare DNS 账号"
|
||||
errGroupNameRequired = "分组名称不能为空"
|
||||
errGroupNodeSame = "主节点和备用节点不能相同"
|
||||
errGroupTargetModeInvalid = "请选择有效的指向方式"
|
||||
errRecordTypeInvalid = "请选择 CNAME、A 或 AAAA 记录类型"
|
||||
errRecordContentInvalid = "DNS 记录值无效,请检查域名或 IP 地址"
|
||||
errNodeInvalid = "请选择有效的边缘节点"
|
||||
errNodeIPv4Required = "生效节点必须配置合法 IPv4"
|
||||
errGroupDisabled = "指向分组已停用"
|
||||
errMemberExists = "该域名已加入其他指向分组"
|
||||
errMultipleDNSRecords = "检测到 Cloudflare 中存在多条同名 DNS 记录,请先手动清理"
|
||||
errSyncFailed = "Cloudflare DNS 同步失败"
|
||||
errDeleteRemoteFailed = "删除 Cloudflare DNS 记录失败"
|
||||
errTaskDispatchFailed = "无法投递 Cloudflare 同步任务"
|
||||
errTargetGroupSame = "目标分组不能为当前分组"
|
||||
errTargetGroupInvalid = "目标分组不存在"
|
||||
errNoMembersSelected = "未选择任何成员"
|
||||
errConnectionNotConfigured = "no Cloudflare connection is configured"
|
||||
errConnectionSourceInvalid = "invalid Cloudflare connection source"
|
||||
errStandaloneInputRequired = "enter a Cloudflare API token"
|
||||
errStandaloneInputInvalid = "configured Cloudflare API token is invalid"
|
||||
errDNSAccountInvalid = "select a valid Cloudflare DNS account"
|
||||
errGroupNameRequired = "group name must not be empty"
|
||||
errGroupNodeSame = "primary and standby nodes must be different"
|
||||
errGroupTargetModeInvalid = "select a valid target mode"
|
||||
errRecordTypeInvalid = "select a CNAME, A or AAAA record type"
|
||||
errRecordContentInvalid = "invalid DNS record value; check the domain name or IP address"
|
||||
errNodeInvalid = "select a valid edge node"
|
||||
errNodeIPv4Required = "the active node requires a valid IPv4 address"
|
||||
errGroupDisabled = "target group is disabled"
|
||||
errMemberExists = "this domain already belongs to another target group"
|
||||
errMultipleDNSRecords = "multiple Cloudflare DNS records have the same name; remove the duplicates manually first"
|
||||
errSyncFailed = "Cloudflare DNS synchronization failed"
|
||||
errDeleteRemoteFailed = "failed to delete Cloudflare DNS record"
|
||||
errTaskDispatchFailed = "unable to enqueue Cloudflare synchronization task"
|
||||
errTargetGroupSame = "target group must differ from the current group"
|
||||
errTargetGroupInvalid = "target group was not found"
|
||||
errNoMembersSelected = "no members selected"
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ func abortLogic(c *gin.Context, err error) bool {
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
response.AbortNotFound(c, "Cloudflare 资源不存在")
|
||||
response.AbortNotFound(c, "Cloudflare resource was not found")
|
||||
case err.Error() == errMemberExists:
|
||||
response.AbortConflict(c, err.Error())
|
||||
case err.Error() == errTaskDispatchFailed:
|
||||
|
||||
@@ -42,8 +42,8 @@ const (
|
||||
var FailoverCheckMeta = task.TaskMeta{
|
||||
Type: TaskTypeFailoverCheck,
|
||||
AsynqTask: FailoverCheckTask,
|
||||
Name: "Cloudflare 节点故障回退检查",
|
||||
Description: "根据节点在线和应用状态切换分组主备节点",
|
||||
Name: "Cloudflare node failover check",
|
||||
Description: "Switch group primary and standby nodes based on node availability and configuration status",
|
||||
SupportsTime: false,
|
||||
MaxRetry: 2,
|
||||
Queue: task.QueueDefault,
|
||||
@@ -55,8 +55,8 @@ var FailoverCheckMeta = task.TaskMeta{
|
||||
var SyncMemberMeta = task.TaskMeta{
|
||||
Type: TaskTypeSyncMember,
|
||||
AsynqTask: SyncMemberTask,
|
||||
Name: "Cloudflare 域名同步",
|
||||
Description: "同步单个域名的 Cloudflare DNS 记录",
|
||||
Name: "Cloudflare domain synchronization",
|
||||
Description: "Synchronize Cloudflare DNS records for a single domain",
|
||||
SupportsTime: false,
|
||||
MaxRetry: 3,
|
||||
Queue: task.QueueDefault,
|
||||
@@ -64,11 +64,11 @@ var SyncMemberMeta = task.TaskMeta{
|
||||
Params: []task.TaskParam{
|
||||
{
|
||||
Name: "member_id",
|
||||
Label: "成员 ID",
|
||||
Label: "Member ID",
|
||||
Type: "number",
|
||||
Required: true,
|
||||
Placeholder: "请输入 Cloudflare 指向成员 ID",
|
||||
Description: "of_cf_pointing_members 表中的成员主键 ID",
|
||||
Placeholder: "Enter the Cloudflare target member ID",
|
||||
Description: "Member primary key in the of_cf_pointing_members table",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -77,8 +77,8 @@ var SyncMemberMeta = task.TaskMeta{
|
||||
var SyncGroupMeta = task.TaskMeta{
|
||||
Type: TaskTypeSyncGroup,
|
||||
AsynqTask: SyncGroupTask,
|
||||
Name: "Cloudflare 分组同步",
|
||||
Description: "同步指向分组内全部域名",
|
||||
Name: "Cloudflare group synchronization",
|
||||
Description: "Synchronize all domains in a target group",
|
||||
SupportsTime: false,
|
||||
MaxRetry: 2,
|
||||
Queue: task.QueueDefault,
|
||||
@@ -86,11 +86,11 @@ var SyncGroupMeta = task.TaskMeta{
|
||||
Params: []task.TaskParam{
|
||||
{
|
||||
Name: "group_id",
|
||||
Label: "分组 ID",
|
||||
Label: "Group ID",
|
||||
Type: "number",
|
||||
Required: true,
|
||||
Placeholder: "请输入 Cloudflare 指向分组 ID",
|
||||
Description: "of_cf_pointing_groups 表中的分组主键 ID",
|
||||
Placeholder: "Enter the Cloudflare target group ID",
|
||||
Description: "Group primary key in the of_cf_pointing_groups table",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -99,8 +99,8 @@ var SyncGroupMeta = task.TaskMeta{
|
||||
var SyncByNodeMeta = task.TaskMeta{
|
||||
Type: TaskTypeSyncByNode,
|
||||
AsynqTask: SyncByNodeTask,
|
||||
Name: "Cloudflare 节点同步",
|
||||
Description: "同步当前指向指定节点的全部域名",
|
||||
Name: "Cloudflare node synchronization",
|
||||
Description: "Synchronize all domains currently targeting the specified node",
|
||||
SupportsTime: false,
|
||||
MaxRetry: 2,
|
||||
Queue: task.QueueDefault,
|
||||
@@ -161,14 +161,14 @@ type SyncMemberTaskHandler struct{}
|
||||
// ValidatePayload validates a one-member task payload.
|
||||
func (handler *SyncMemberTaskHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, errors.New("任务参数不能为空")
|
||||
return nil, errors.New("task parameters must not be empty")
|
||||
}
|
||||
var input SyncMemberPayload
|
||||
if err := decodePayload(payload, &input); err != nil {
|
||||
return nil, fmt.Errorf("无效的 Cloudflare 成员同步参数: %w", err)
|
||||
return nil, fmt.Errorf("invalid Cloudflare member synchronization parameters: %w", err)
|
||||
}
|
||||
if input.MemberID == 0 {
|
||||
return nil, errors.New("成员 ID 不能为空或零")
|
||||
return nil, errors.New("member ID must be nonzero")
|
||||
}
|
||||
return json.Marshal(input)
|
||||
}
|
||||
@@ -184,14 +184,14 @@ func (handler *SyncMemberTaskHandler) Execute(ctx context.Context, payload []byt
|
||||
|
||||
state, loadErr := repository.GetCFPointingMemberContext(ctx, input.MemberID)
|
||||
if loadErr != nil {
|
||||
task.AppendLog(ctx, "加载成员上下文失败: member_id=%d error=%v", input.MemberID, loadErr)
|
||||
task.AppendLog(ctx, "Failed to load member context: member_id=%d error=%v", input.MemberID, loadErr)
|
||||
} else {
|
||||
target := fmt.Sprintf("%s(%s)", state.Node.Name, strings.TrimSpace(state.Node.IP))
|
||||
if state.Group.TargetMode == model.CFPointingTargetModeCustom {
|
||||
target = fmt.Sprintf("%s %s", state.Group.RecordType, state.Group.RecordContent)
|
||||
}
|
||||
task.AppendLog(ctx,
|
||||
"开始域名同步: domain=%s zone=%s group=%s(#%d) target=%s proxied=%v member_id=%d",
|
||||
"Starting domain synchronization: domain=%s zone=%s group=%s(#%d) target=%s proxied=%v member_id=%d",
|
||||
state.Domain.Domain,
|
||||
state.Zone.Domain,
|
||||
state.Group.Name,
|
||||
@@ -204,28 +204,28 @@ func (handler *SyncMemberTaskHandler) Execute(ctx context.Context, payload []byt
|
||||
|
||||
if err = ReconcileMember(ctx, input.MemberID); err != nil {
|
||||
if state != nil {
|
||||
task.AppendLog(ctx, "域名同步失败: domain=%s member_id=%d error=%v",
|
||||
task.AppendLog(ctx, "Domain synchronization failed: domain=%s member_id=%d error=%v",
|
||||
state.Domain.Domain, input.MemberID, err)
|
||||
} else {
|
||||
task.AppendLog(ctx, "域名同步失败: member_id=%d error=%v", input.MemberID, err)
|
||||
task.AppendLog(ctx, "Domain synchronization failed: member_id=%d error=%v", input.MemberID, err)
|
||||
}
|
||||
return nil, fmt.Errorf("%s: %w", errSyncFailed, err)
|
||||
}
|
||||
|
||||
message := "Cloudflare 域名同步成功"
|
||||
message := "Cloudflare domain synchronization succeeded"
|
||||
if state != nil {
|
||||
target := strings.TrimSpace(state.Node.IP)
|
||||
if state.Group.TargetMode == model.CFPointingTargetModeCustom {
|
||||
target = fmt.Sprintf("%s %s", state.Group.RecordType, state.Group.RecordContent)
|
||||
}
|
||||
message = fmt.Sprintf("Cloudflare 域名同步成功: %s → %s (proxied=%v)",
|
||||
message = fmt.Sprintf("Cloudflare domain synchronization succeeded: %s -> %s (proxied=%v)",
|
||||
state.Domain.Domain, target, state.Member.Proxied)
|
||||
task.AppendLog(ctx,
|
||||
"域名同步成功: domain=%s desired_target=%s proxied=%v group=%s target_mode=%s",
|
||||
"Domain synchronization succeeded: domain=%s desired_target=%s proxied=%v group=%s target_mode=%s",
|
||||
state.Domain.Domain, target, state.Member.Proxied, state.Group.Name, state.Group.TargetMode,
|
||||
)
|
||||
} else {
|
||||
task.AppendLog(ctx, "域名同步成功: member_id=%d", input.MemberID)
|
||||
task.AppendLog(ctx, "Domain synchronization succeeded: member_id=%d", input.MemberID)
|
||||
}
|
||||
return &task.TaskResult{Message: message}, nil
|
||||
}
|
||||
@@ -236,14 +236,14 @@ type SyncGroupTaskHandler struct{}
|
||||
// ValidatePayload validates a group task payload.
|
||||
func (handler *SyncGroupTaskHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, errors.New("任务参数不能为空")
|
||||
return nil, errors.New("task parameters must not be empty")
|
||||
}
|
||||
var input SyncGroupPayload
|
||||
if err := decodePayload(payload, &input); err != nil {
|
||||
return nil, fmt.Errorf("无效的 Cloudflare 分组同步参数: %w", err)
|
||||
return nil, fmt.Errorf("invalid Cloudflare group synchronization parameters: %w", err)
|
||||
}
|
||||
if input.GroupID == 0 {
|
||||
return nil, errors.New("分组 ID 不能为空或零")
|
||||
return nil, errors.New("group ID must be nonzero")
|
||||
}
|
||||
return json.Marshal(input)
|
||||
}
|
||||
@@ -262,18 +262,18 @@ func (handler *SyncGroupTaskHandler) Execute(ctx context.Context, payload []byte
|
||||
scopeName := fmt.Sprintf("#%d", input.GroupID)
|
||||
activeNode := ""
|
||||
if group, groupErr := repository.GetCFPointingGroup(ctx, input.GroupID); groupErr != nil {
|
||||
task.AppendLog(ctx, "加载分组失败: group_id=%d error=%v", input.GroupID, groupErr)
|
||||
task.AppendLog(ctx, "Failed to load group: group_id=%d error=%v", input.GroupID, groupErr)
|
||||
} else {
|
||||
scopeName = group.Name
|
||||
activeNode = groupSyncTarget(ctx, group)
|
||||
task.AppendLog(ctx,
|
||||
"准备分组同步: group=%s id=%d enabled=%v target=%s default_proxied=%v",
|
||||
"Preparing group synchronization: group=%s id=%d enabled=%v target=%s default_proxied=%v",
|
||||
group.Name, group.ID, group.Enabled, activeNode, group.DefaultProxied,
|
||||
)
|
||||
}
|
||||
|
||||
members, err := repository.ListCFPointingMembersByGroupID(ctx, input.GroupID)
|
||||
return executeBatchSync(ctx, members, err, "分组", scopeName, input.GroupID, activeNode)
|
||||
return executeBatchSync(ctx, members, err, "group", scopeName, input.GroupID, activeNode)
|
||||
}
|
||||
|
||||
func groupSyncTarget(ctx context.Context, group *model.CFPointingGroup) string {
|
||||
@@ -282,7 +282,7 @@ func groupSyncTarget(ctx context.Context, group *model.CFPointingGroup) string {
|
||||
}
|
||||
node, err := repository.GetOpenFlareNodeByID(ctx, group.ActiveNodeID)
|
||||
if err != nil {
|
||||
task.AppendLog(ctx, "加载生效节点失败: group=%s active_node_id=%d error=%v",
|
||||
task.AppendLog(ctx, "Failed to load active node: group=%s active_node_id=%d error=%v",
|
||||
group.Name, group.ActiveNodeID, err)
|
||||
return ""
|
||||
}
|
||||
@@ -296,7 +296,7 @@ type SyncByNodeTaskHandler struct{}
|
||||
func (handler *SyncByNodeTaskHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||
var input SyncByNodePayload
|
||||
if err := decodePayload(payload, &input); err != nil || input.NodeID == 0 {
|
||||
return nil, errors.New("无效的 Cloudflare 节点同步参数")
|
||||
return nil, errors.New("invalid Cloudflare node synchronization parameters")
|
||||
}
|
||||
return json.Marshal(input)
|
||||
}
|
||||
@@ -315,16 +315,16 @@ func (handler *SyncByNodeTaskHandler) Execute(ctx context.Context, payload []byt
|
||||
scopeName := fmt.Sprintf("#%d", input.NodeID)
|
||||
activeNode := ""
|
||||
if node, nodeErr := repository.GetOpenFlareNodeByID(ctx, input.NodeID); nodeErr != nil {
|
||||
task.AppendLog(ctx, "加载节点失败: node_id=%d error=%v", input.NodeID, nodeErr)
|
||||
task.AppendLog(ctx, "Failed to load node: node_id=%d error=%v", input.NodeID, nodeErr)
|
||||
} else {
|
||||
scopeName = node.Name
|
||||
activeNode = fmt.Sprintf("%s(%s)", node.Name, strings.TrimSpace(node.IP))
|
||||
task.AppendLog(ctx, "准备节点同步: node=%s id=%d ip=%s",
|
||||
task.AppendLog(ctx, "Preparing node synchronization: node=%s id=%d ip=%s",
|
||||
node.Name, node.ID, strings.TrimSpace(node.IP))
|
||||
}
|
||||
|
||||
members, err := repository.ListCFPointingMembersByActiveNodeID(ctx, input.NodeID)
|
||||
return executeBatchSync(ctx, members, err, "节点", scopeName, input.NodeID, activeNode)
|
||||
return executeBatchSync(ctx, members, err, "node", scopeName, input.NodeID, activeNode)
|
||||
}
|
||||
|
||||
func executeBatchSync(
|
||||
@@ -336,15 +336,15 @@ func executeBatchSync(
|
||||
activeNode string,
|
||||
) (*task.TaskResult, error) {
|
||||
if listErr != nil {
|
||||
task.AppendLog(ctx, "列出%s成员失败: name=%s id=%d error=%v",
|
||||
task.AppendLog(ctx, "Failed to list %s members: name=%s id=%d error=%v",
|
||||
scope, scopeName, scopeID, listErr)
|
||||
return nil, listErr
|
||||
}
|
||||
|
||||
task.AppendLog(ctx, "开始%s同步: name=%s id=%d target=%s 域名数=%d",
|
||||
task.AppendLog(ctx, "Starting %s synchronization: name=%s id=%d target=%s domains=%d",
|
||||
scope, scopeName, scopeID, activeNode, len(members))
|
||||
if len(members) == 0 {
|
||||
message := fmt.Sprintf("Cloudflare %s同步完成: %s 无域名成员", scope, scopeName)
|
||||
message := fmt.Sprintf("Cloudflare %s synchronization completed: %s has no domain members", scope, scopeName)
|
||||
task.AppendLog(ctx, "%s", message)
|
||||
return &task.TaskResult{Message: message}, nil
|
||||
}
|
||||
@@ -355,28 +355,28 @@ func executeBatchSync(
|
||||
if domain, domainErr := repository.GetZoneDomainByID(ctx, member.ZoneDomainID); domainErr == nil {
|
||||
domainName = domain.Domain
|
||||
} else if errors.Is(domainErr, gorm.ErrRecordNotFound) {
|
||||
task.AppendLog(ctx, "[%d/%d] 域名记录已不存在,清理孤立成员: member_id=%d zone_domain_id=%d",
|
||||
task.AppendLog(ctx, "[%d/%d] Domain record no longer exists; removing orphaned member: member_id=%d zone_domain_id=%d",
|
||||
index+1, len(members), member.ID, member.ZoneDomainID)
|
||||
if delErr := repository.DeleteCFPointingMember(ctx, &member); delErr != nil {
|
||||
task.AppendLog(ctx, "[%d/%d] 清理孤立成员失败: member_id=%d error=%v",
|
||||
task.AppendLog(ctx, "[%d/%d] Failed to remove orphaned member: member_id=%d error=%v",
|
||||
index+1, len(members), member.ID, delErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
task.AppendLog(ctx, "[%d/%d] 同步域名 %s (member_id=%d proxied=%v)",
|
||||
task.AppendLog(ctx, "[%d/%d] Synchronizing domain %s (member_id=%d proxied=%v)",
|
||||
index+1, len(members), domainName, member.ID, member.Proxied)
|
||||
if err := ReconcileMember(ctx, member.ID); err != nil {
|
||||
task.AppendLog(ctx, "[%d/%d] 失败: domain=%s member_id=%d error=%v",
|
||||
task.AppendLog(ctx, "[%d/%d] Failed: domain=%s member_id=%d error=%v",
|
||||
index+1, len(members), domainName, member.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
syncedCount++
|
||||
task.AppendLog(ctx, "[%d/%d] 成功: domain=%s", index+1, len(members), domainName)
|
||||
task.AppendLog(ctx, "[%d/%d] Succeeded: domain=%s", index+1, len(members), domainName)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("Cloudflare %s同步完成: %s 共 %d 个域名", scope, scopeName, syncedCount)
|
||||
message := fmt.Sprintf("Cloudflare %s synchronization completed: %s, %d domains", scope, scopeName, syncedCount)
|
||||
if activeNode != "" {
|
||||
message = fmt.Sprintf("Cloudflare %s同步完成: %s → %s,共 %d 个域名",
|
||||
message = fmt.Sprintf("Cloudflare %s synchronization completed: %s -> %s, %d domains",
|
||||
scope, scopeName, activeNode, syncedCount)
|
||||
}
|
||||
task.AppendLog(ctx, "%s", message)
|
||||
@@ -403,7 +403,7 @@ type FailoverCheckTaskHandler struct{}
|
||||
func (handler *FailoverCheckTaskHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||
var input struct{}
|
||||
if err := decodePayload(payload, &input); err != nil {
|
||||
return nil, fmt.Errorf("无效的 Cloudflare 故障回退检查参数: %w", err)
|
||||
return nil, fmt.Errorf("invalid Cloudflare failover check parameters: %w", err)
|
||||
}
|
||||
return json.Marshal(input)
|
||||
}
|
||||
@@ -413,13 +413,13 @@ func (handler *FailoverCheckTaskHandler) Execute(ctx context.Context, payload []
|
||||
if _, err := handler.ValidatePayload(payload); err != nil {
|
||||
return nil, task.PermanentError(err.Error())
|
||||
}
|
||||
task.AppendLog(ctx, "开始检查 Cloudflare 节点分组主备状态")
|
||||
task.AppendLog(ctx, "Checking Cloudflare group primary and standby node status")
|
||||
changed, err := CheckNodeFailover(ctx)
|
||||
if err != nil {
|
||||
task.AppendLog(ctx, "Cloudflare 节点故障回退检查失败: error=%v", err)
|
||||
task.AppendLog(ctx, "Cloudflare node failover check failed: error=%v", err)
|
||||
return nil, err
|
||||
}
|
||||
message := fmt.Sprintf("Cloudflare 节点故障回退检查完成,切换分组 %d 个", changed)
|
||||
message := fmt.Sprintf("Cloudflare node failover check completed; switched %d groups", changed)
|
||||
task.AppendLog(ctx, "%s", message)
|
||||
return &task.TaskResult{Message: message}, nil
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
package config_version
|
||||
|
||||
const (
|
||||
errNoActiveVersion = "当前没有激活版本"
|
||||
errNoEnabledRoutes = "没有可发布的启用规则"
|
||||
errNoChangesToPublish = "当前规则没有变更,不能重复发布"
|
||||
errVersionConflict = "版本号生成冲突,请重试"
|
||||
errInvalidSnapshotFormat = "历史版本快照格式不合法"
|
||||
errNoActiveVersion = "no active configuration version"
|
||||
errNoEnabledRoutes = "no enabled rules are available to publish"
|
||||
errNoChangesToPublish = "the current rules have not changed and cannot be published again"
|
||||
errVersionConflict = "version identifier conflict; please try again"
|
||||
errInvalidSnapshotFormat = "invalid historical version snapshot format"
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
cleanupSuccessMessage = "清理成功"
|
||||
cleanupSuccessMessage = "Cleanup completed"
|
||||
minConfigVersionKeepCount = 3
|
||||
)
|
||||
|
||||
|
||||
@@ -26,45 +26,45 @@ func buildPagesRouteSnapshot(
|
||||
route *model.ProxyRoute,
|
||||
) (originURL string, upstreams []string, pagesProjectID *uint, deployment *openrestyrender.PagesDeployment, err error) {
|
||||
if route == nil {
|
||||
return "", nil, nil, nil, errors.New("pages 路由配置无效")
|
||||
return "", nil, nil, nil, errors.New("invalid Pages route configuration")
|
||||
}
|
||||
if !repository.HasPagesProjectsTable(ctx) {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 模块不可用", route.SiteName)
|
||||
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages module is unavailable", route.SiteName)
|
||||
}
|
||||
if route.PagesProjectID == nil || *route.PagesProjectID == 0 {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: 未绑定 Pages 项目", route.SiteName)
|
||||
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: no Pages project is bound", route.SiteName)
|
||||
}
|
||||
project, err := repository.GetPagesProjectByID(ctx, *route.PagesProjectID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目不存在", route.SiteName)
|
||||
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project was not found", route.SiteName)
|
||||
}
|
||||
return "", nil, nil, nil, err
|
||||
}
|
||||
if !project.Enabled {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目未启用", route.SiteName)
|
||||
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project is disabled", route.SiteName)
|
||||
}
|
||||
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目没有激活部署", route.SiteName)
|
||||
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project has no active deployment", route.SiteName)
|
||||
}
|
||||
activeDeployment, err := repository.GetPagesDeploymentByID(ctx, *project.ActiveDeploymentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不存在", route.SiteName)
|
||||
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: active Pages deployment was not found", route.SiteName)
|
||||
}
|
||||
return "", nil, nil, nil, err
|
||||
}
|
||||
if activeDeployment.ProjectID != project.ID {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不匹配", route.SiteName)
|
||||
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: active Pages deployment does not match", route.SiteName)
|
||||
}
|
||||
if strings.TrimSpace(activeDeployment.Checksum) == "" {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 部署校验和缺失", route.SiteName)
|
||||
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages deployment checksum is missing", route.SiteName)
|
||||
}
|
||||
|
||||
pagesProjectID = route.PagesProjectID
|
||||
deployment, err = buildSnapshotPagesDeployment(project, activeDeployment)
|
||||
if err != nil {
|
||||
return "", nil, nil, nil, fmt.Errorf("路由 %s Pages 配置无效: %w", route.SiteName, err)
|
||||
return "", nil, nil, nil, fmt.Errorf("invalid Pages configuration for route %s: %w", route.SiteName, err)
|
||||
}
|
||||
originURL = fmt.Sprintf("openflare-pages://project/%d", project.ID)
|
||||
return originURL, []string{originURL}, pagesProjectID, deployment, nil
|
||||
@@ -75,11 +75,11 @@ func buildSnapshotPagesDeployment(
|
||||
activeDeployment *model.PagesDeployment,
|
||||
) (*openrestyrender.PagesDeployment, error) {
|
||||
if project == nil || activeDeployment == nil {
|
||||
return nil, errors.New("pages 项目或部署为空")
|
||||
return nil, errors.New("pages project or deployment is empty")
|
||||
}
|
||||
rootDir, err := pagesarchive.NormalizeLogicalPath(strings.TrimSpace(project.RootDir), true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pages 根目录不合法: %w", err)
|
||||
return nil, fmt.Errorf("invalid Pages root directory: %w", err)
|
||||
}
|
||||
entryFile := strings.TrimSpace(project.EntryFile)
|
||||
if entryFile == "" {
|
||||
@@ -87,7 +87,7 @@ func buildSnapshotPagesDeployment(
|
||||
}
|
||||
entryFile, err = pagesarchive.NormalizeLogicalPath(entryFile, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pages 入口文件不合法: %w", err)
|
||||
return nil, fmt.Errorf("invalid Pages entry file: %w", err)
|
||||
}
|
||||
fallbackPath := strings.TrimSpace(project.SPAFallbackPath)
|
||||
if fallbackPath == "" {
|
||||
|
||||
@@ -15,13 +15,13 @@ func handleLogicError(c *gin.Context, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return apiutil.AbortNotFoundIfMissing(c, err, "记录不存在")
|
||||
return apiutil.AbortNotFoundIfMissing(c, err, "Record was not found")
|
||||
}
|
||||
|
||||
func versionParam(c *gin.Context) (string, bool) {
|
||||
version := c.Param("id")
|
||||
if version == "" {
|
||||
response.AbortBadRequest(c, "无效的版本号")
|
||||
response.AbortBadRequest(c, "Invalid version identifier")
|
||||
return "", false
|
||||
}
|
||||
return version, true
|
||||
|
||||
@@ -252,13 +252,13 @@ func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]sna
|
||||
}
|
||||
customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.SiteName)
|
||||
return nil, fmt.Errorf("invalid custom request headers for route %s", route.SiteName)
|
||||
}
|
||||
upstreamType := normalizeUpstreamType(route.UpstreamType)
|
||||
originURL := route.OriginURL
|
||||
upstreams, err := decodeStoredUpstreams(route.Upstreams, route.OriginURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("路由 %s 上游配置无效", route.SiteName)
|
||||
return nil, fmt.Errorf("invalid upstream configuration for route %s", route.SiteName)
|
||||
}
|
||||
var tunnelNodeID *uint
|
||||
var tunnelTargetAddr string
|
||||
@@ -280,7 +280,7 @@ func buildSnapshotRoutes(ctx context.Context, routes []*model.ProxyRoute) ([]sna
|
||||
}
|
||||
cacheRules, err := decodeStoredCacheRules(route.CacheRules)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("路由 %s 缓存规则无效", route.SiteName)
|
||||
return nil, fmt.Errorf("invalid cache rules for route %s", route.SiteName)
|
||||
}
|
||||
items = append(items, snapshotRoute{
|
||||
ID: route.ID,
|
||||
@@ -333,14 +333,14 @@ func buildSnapshotWAFDocument(ctx context.Context, routes []*model.ProxyRoute) (
|
||||
}
|
||||
var editorGraph waf.RuleGraph
|
||||
if err = json.Unmarshal([]byte(group.Graph), &editorGraph); err != nil {
|
||||
return snapshotWAFDocument{}, fmt.Errorf("WAF 规则 %s 的图数据无效: %w", group.Name, err)
|
||||
return snapshotWAFDocument{}, fmt.Errorf("invalid graph data for WAF rule %s: %w", group.Name, err)
|
||||
}
|
||||
if err = waf.ValidateRuleGraph(ctx, editorGraph, snapshotWAFIPGroupExists); err != nil {
|
||||
return snapshotWAFDocument{}, fmt.Errorf("WAF 规则 %s 的图无效: %w", group.Name, err)
|
||||
return snapshotWAFDocument{}, fmt.Errorf("invalid graph for WAF rule %s: %w", group.Name, err)
|
||||
}
|
||||
runtimeGraph, compileErr := waf.CompileRuleGraph(editorGraph)
|
||||
if compileErr != nil {
|
||||
return snapshotWAFDocument{}, fmt.Errorf("WAF 规则 %s 编译失败: %w", group.Name, compileErr)
|
||||
return snapshotWAFDocument{}, fmt.Errorf("failed to compile WAF rule %s: %w", group.Name, compileErr)
|
||||
}
|
||||
ruleGroups = append(ruleGroups, snapshotWAFRuleGroup{
|
||||
ID: group.ID, Name: group.Name, Enabled: group.Enabled, IsGlobal: group.IsGlobal, Graph: runtimeGraph,
|
||||
@@ -445,7 +445,7 @@ func buildSnapshotWAFIPGroups(ctx context.Context, idSet map[uint]struct{}) ([]s
|
||||
for _, id := range ids {
|
||||
group := groupByID[id]
|
||||
if group == nil {
|
||||
return nil, fmt.Errorf("IP 组 %d 不存在", id)
|
||||
return nil, fmt.Errorf("IP group %d was not found", id)
|
||||
}
|
||||
ipList, decodeErr := decodeIPList(group.IPList)
|
||||
if decodeErr != nil {
|
||||
|
||||
@@ -43,8 +43,8 @@ func TestBuildSnapshotRejectsOversizedAggregateWAFIPGroups(t *testing.T) {
|
||||
createSnapshotRule(t, ctx, "oversized-aggregate", snapshotIPMatchGraphForGroups(groupIDs))
|
||||
|
||||
_, err = buildSnapshotWAFDocument(ctx, nil)
|
||||
require.ErrorContains(t, err, "WAF IP 组快照大小")
|
||||
require.ErrorContains(t, err, "超过上限")
|
||||
require.ErrorContains(t, err, "WAF IP group snapshot size")
|
||||
require.ErrorContains(t, err, "exceeds the limit")
|
||||
}
|
||||
|
||||
func TestWAFGraphSnapshotPreservesOrderAndGraphReferences(t *testing.T) {
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
package flared
|
||||
|
||||
const (
|
||||
errTunnelTokenInvalid = "无权进行此操作,Tunnel Token 无效" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errTunnelNodeTypeMismatch = "此节点不是 TunnelClient 类型"
|
||||
errTunnelTokenInvalid = "Operation not authorized: invalid tunnel token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errTunnelNodeTypeMismatch = "This node is not a TunnelClient"
|
||||
)
|
||||
|
||||
@@ -169,13 +169,13 @@ func ReportApplyLog(ctx context.Context, payload ApplyLogPayload) (*model.OpenFl
|
||||
now := time.Now().UTC()
|
||||
payload = normalizeApplyLogPayload(payload)
|
||||
if payload.NodeID == "" {
|
||||
return nil, errors.New("node_id 不能为空")
|
||||
return nil, errors.New("node_id must not be empty")
|
||||
}
|
||||
if payload.Version == "" {
|
||||
return nil, errors.New("version 不能为空")
|
||||
return nil, errors.New("version must not be empty")
|
||||
}
|
||||
if payload.Result != applyResultOK && payload.Result != applyResultWarn && payload.Result != applyResultFail {
|
||||
return nil, errors.New("result 仅支持 success、warning 或 failed")
|
||||
return nil, errors.New("result must be success, warning or failed")
|
||||
}
|
||||
|
||||
latest, err := repository.GetLatestOpenFlareApplyLogByNodeID(ctx, payload.NodeID)
|
||||
|
||||
@@ -23,10 +23,10 @@ type LookupView struct {
|
||||
}
|
||||
|
||||
const (
|
||||
errProviderInvalid = "归属方式仅支持 disabled、mmdb、ip-api、geojs、ipinfo"
|
||||
errIPEmpty = "IP 不能为空"
|
||||
errIPInvalid = "IP 格式无效"
|
||||
errLookupEmpty = "未获取到 IP 归属结果"
|
||||
errProviderInvalid = "geolocation provider must be disabled, mmdb, ip-api, geojs or ipinfo"
|
||||
errIPEmpty = "IP address must not be empty"
|
||||
errIPInvalid = "invalid IP address format"
|
||||
errLookupEmpty = "no IP geolocation result was returned"
|
||||
)
|
||||
|
||||
// IsValidProvider reports whether provider is a supported GeoIP backend.
|
||||
|
||||
@@ -7,18 +7,18 @@ package node
|
||||
const (
|
||||
errInvalidNodeListQuery = "invalid node list parameters"
|
||||
errListNodesFailed = "Failed to load nodes"
|
||||
errNodeNameRequired = "节点名不能为空"
|
||||
errNodeIPTooLong = "节点 IP 不能超过 64 个字符"
|
||||
errNodeIPInvalid = "节点 IP 格式无效"
|
||||
errNodeIPManualRequired = "锁定节点 IP 时必须填写节点 IP"
|
||||
errNodeGeoNameTooLong = "节点位置名不能超过 128 个字符"
|
||||
errNodeGeoCoordinateMismatch = "地图坐标必须同时填写纬度和经度"
|
||||
errNodeGeoLatitudeInvalid = "纬度必须在 -90 到 90 之间"
|
||||
errNodeGeoLongitudeInvalid = "经度必须在 -180 到 180 之间"
|
||||
errNodeIDConflict = "节点标识生成冲突,请重试"
|
||||
errNodeNotFound = "节点不存在"
|
||||
errNodeForceSyncFailed = "节点不在线或通过 WebSocket 发送同步指令失败"
|
||||
errNoActiveConfigVersion = "当前没有激活版本"
|
||||
errAgentPreviewTagInvalid = "指定版本不是 preview 发布"
|
||||
errAgentStableTagInvalid = "正式版更新不能选择 preview 发布"
|
||||
errNodeNameRequired = "node name must not be empty"
|
||||
errNodeIPTooLong = "Node IP must not exceed 64 characters"
|
||||
errNodeIPInvalid = "Invalid node IP address format"
|
||||
errNodeIPManualRequired = "A node IP is required when IP locking is enabled"
|
||||
errNodeGeoNameTooLong = "Node location name must not exceed 128 characters"
|
||||
errNodeGeoCoordinateMismatch = "Both latitude and longitude are required"
|
||||
errNodeGeoLatitudeInvalid = "Latitude must be between -90 and 90"
|
||||
errNodeGeoLongitudeInvalid = "Longitude must be between -180 and 180"
|
||||
errNodeIDConflict = "node identifier conflict; please try again"
|
||||
errNodeNotFound = "node was not found"
|
||||
errNodeForceSyncFailed = "node is offline or the synchronization command could not be sent over WebSocket"
|
||||
errNoActiveConfigVersion = "no active configuration version"
|
||||
errAgentPreviewTagInvalid = "the selected version is not a preview release"
|
||||
errAgentStableTagInvalid = "a preview release cannot be selected for a stable update"
|
||||
)
|
||||
|
||||
@@ -332,15 +332,15 @@ func fetchLatestStableGitHubRelease(ctx context.Context, repo string) (*githubRe
|
||||
url := fmt.Sprintf(githubReleasesAPIBase+"/latest", strings.TrimSpace(repo))
|
||||
req, err := newGitHubReleaseRequest(ctx, url)
|
||||
if err != nil {
|
||||
return nil, errors.New("创建更新请求失败")
|
||||
return nil, errors.New("failed to create update request")
|
||||
}
|
||||
resp, err := releaseHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取最新版本失败: %w", err)
|
||||
return nil, fmt.Errorf("failed to get latest version: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
|
||||
return nil, fmt.Errorf("GitHub returned an unexpected status: %s", resp.Status)
|
||||
}
|
||||
return decodeGitHubRelease(resp.Body)
|
||||
}
|
||||
@@ -349,19 +349,19 @@ func fetchLatestPreviewGitHubRelease(ctx context.Context, repo string) (*githubR
|
||||
url := fmt.Sprintf(githubReleasesAPIBase+"?per_page=20", strings.TrimSpace(repo))
|
||||
req, err := newGitHubReleaseRequest(ctx, url)
|
||||
if err != nil {
|
||||
return nil, errors.New("创建更新请求失败")
|
||||
return nil, errors.New("failed to create update request")
|
||||
}
|
||||
resp, err := releaseHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 preview 版本失败: %w", err)
|
||||
return nil, fmt.Errorf("failed to get preview version: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
|
||||
return nil, fmt.Errorf("GitHub returned an unexpected status: %s", resp.Status)
|
||||
}
|
||||
var releases []githubReleaseResponse
|
||||
if err = json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
return nil, errors.New("解析 preview 版本信息失败")
|
||||
return nil, errors.New("failed to parse preview version information")
|
||||
}
|
||||
for _, release := range releases {
|
||||
if release.Draft || !release.Prerelease {
|
||||
@@ -370,29 +370,29 @@ func fetchLatestPreviewGitHubRelease(ctx context.Context, repo string) (*githubR
|
||||
releaseCopy := release
|
||||
return &releaseCopy, nil
|
||||
}
|
||||
return nil, errors.New("当前没有可用的 preview 发布")
|
||||
return nil, errors.New("no preview release is currently available")
|
||||
}
|
||||
|
||||
func fetchGitHubReleaseByTag(ctx context.Context, repo string, tag string) (*githubReleaseResponse, error) {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
return nil, errors.New("缺少发布版本号")
|
||||
return nil, errors.New("missing release version identifier")
|
||||
}
|
||||
url := fmt.Sprintf(githubReleasesAPIBase+"/tags/%s", strings.TrimSpace(repo), tag)
|
||||
req, err := newGitHubReleaseRequest(ctx, url)
|
||||
if err != nil {
|
||||
return nil, errors.New("创建更新请求失败")
|
||||
return nil, errors.New("failed to create update request")
|
||||
}
|
||||
resp, err := releaseHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取指定版本失败: %w", err)
|
||||
return nil, fmt.Errorf("failed to get specified version: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("未找到指定版本: %s", tag)
|
||||
return nil, fmt.Errorf("specified version was not found: %s", tag)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
|
||||
return nil, fmt.Errorf("GitHub returned an unexpected status: %s", resp.Status)
|
||||
}
|
||||
return decodeGitHubRelease(resp.Body)
|
||||
}
|
||||
@@ -410,7 +410,7 @@ func newGitHubReleaseRequest(ctx context.Context, url string) (*http.Request, er
|
||||
func decodeGitHubRelease(reader io.Reader) (*githubReleaseResponse, error) {
|
||||
var release githubReleaseResponse
|
||||
if err := json.NewDecoder(reader).Decode(&release); err != nil {
|
||||
return nil, errors.New("解析版本信息失败")
|
||||
return nil, errors.New("failed to parse version information")
|
||||
}
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
@@ -363,9 +363,9 @@ func RequestForceSync(ctx context.Context, id uint) (*View, error) {
|
||||
activeConfig, err := repository.GetActiveConfigVersion(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("无法获取当前激活的配置版本:%s", errNoActiveConfigVersion)
|
||||
return nil, fmt.Errorf("unable to get the currently active configuration version: %s", errNoActiveConfigVersion)
|
||||
}
|
||||
return nil, fmt.Errorf("无法获取当前激活的配置版本:%w", err)
|
||||
return nil, fmt.Errorf("unable to get the currently active configuration version: %w", err)
|
||||
}
|
||||
if !ofws.SendForceSyncConfig(node.NodeID, forceSyncConfigPayload{
|
||||
Version: activeConfig.Version,
|
||||
@@ -418,14 +418,14 @@ type forceSyncConfigPayload struct {
|
||||
func ValidateDiscoveryToken(ctx context.Context, token string) error {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return errors.New("缺少 Discovery Token")
|
||||
return errors.New("missing discovery token")
|
||||
}
|
||||
discoveryToken, err := ensureGlobalDiscoveryToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !tokenEqual(token, discoveryToken) {
|
||||
return errors.New("discovery Token 无效") // error 消息首字母小写
|
||||
return errors.New("invalid discovery token") // error 消息首字母小写
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ func RequestAgentUpdateHandler(c *gin.Context) {
|
||||
var request AgentUpdateInput
|
||||
if c.Request.ContentLength > 0 {
|
||||
if err := bindOptionalJSON(c.Request.Body, &request); err != nil {
|
||||
response.AbortBadRequest(c, "参数错误")
|
||||
response.AbortBadRequest(c, "invalid parameters")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -283,7 +283,7 @@ func GetObservabilityHandler(c *gin.Context) {
|
||||
}
|
||||
var query ObservabilityQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
response.AbortBadRequest(c, "参数错误")
|
||||
response.AbortBadRequest(c, "invalid parameters")
|
||||
return
|
||||
}
|
||||
view, err := GetObservability(c.Request.Context(), id, query)
|
||||
|
||||
@@ -860,7 +860,7 @@ func limitDistributionItems(items []DistributionItem, limit int) []DistributionI
|
||||
func normalizeAccessLogIPAnalysisQuery(input AccessLogIPAnalysisQuery) (AccessLogIPAnalysisQuery, error) {
|
||||
remoteAddr := strings.TrimSpace(input.RemoteAddr)
|
||||
if remoteAddr == "" {
|
||||
return AccessLogIPAnalysisQuery{}, errors.New("remote_addr 不能为空")
|
||||
return AccessLogIPAnalysisQuery{}, errors.New("remote_addr must not be empty")
|
||||
}
|
||||
hours := input.Hours
|
||||
if hours <= 0 {
|
||||
@@ -880,7 +880,7 @@ func normalizeAccessLogIPAnalysisQuery(input AccessLogIPAnalysisQuery) (AccessLo
|
||||
// CleanupAccessLogs removes access logs older than retention days.
|
||||
func CleanupAccessLogs(ctx context.Context, input AccessLogCleanupInput) (*AccessLogCleanupResult, error) {
|
||||
if input.RetentionDays <= 0 || input.RetentionDays > nodeAccessLogRetentionDays {
|
||||
return nil, errors.New("retention_days 必须在 1 到 90 之间")
|
||||
return nil, errors.New("retention_days must be between 1 and 90")
|
||||
}
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(input.RetentionDays) * 24 * time.Hour)
|
||||
deleted, err := repository.DeleteOpenFlareAccessLogsBefore(ctx, cutoff)
|
||||
@@ -923,18 +923,18 @@ func buildModelAccessLogQuery(input AccessLogQuery) (model.OpenFlareAccessLogQue
|
||||
// 且 until 需晚于 since。
|
||||
func resolveAccessLogWindow(sinceRaw, untilRaw string) (time.Time, time.Time, error) {
|
||||
if sinceRaw == "" || untilRaw == "" {
|
||||
return time.Time{}, time.Time{}, errors.New("since 与 until 需同时提供")
|
||||
return time.Time{}, time.Time{}, errors.New("since and until must be provided together")
|
||||
}
|
||||
parsedSince, err := time.Parse(time.RFC3339, sinceRaw)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, errors.New("since 时间格式无效,需为 RFC3339")
|
||||
return time.Time{}, time.Time{}, errors.New("invalid since timestamp; RFC3339 is required")
|
||||
}
|
||||
parsedUntil, err := time.Parse(time.RFC3339, untilRaw)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, errors.New("until 时间格式无效,需为 RFC3339")
|
||||
return time.Time{}, time.Time{}, errors.New("invalid until timestamp; RFC3339 is required")
|
||||
}
|
||||
if !parsedUntil.After(parsedSince) {
|
||||
return time.Time{}, time.Time{}, errors.New("until 必须晚于 since")
|
||||
return time.Time{}, time.Time{}, errors.New("until must be later than since")
|
||||
}
|
||||
return parsedSince, parsedUntil, nil
|
||||
}
|
||||
@@ -1021,23 +1021,23 @@ func resolveAccessLogIPSummaryWindow(sinceRaw, untilRaw string, hours int) (time
|
||||
return until.Add(-time.Duration(hours) * time.Hour), until, hours, nil
|
||||
}
|
||||
if sinceRaw == "" || untilRaw == "" {
|
||||
return time.Time{}, time.Time{}, 0, errors.New("since 与 until 需同时提供")
|
||||
return time.Time{}, time.Time{}, 0, errors.New("since and until must be provided together")
|
||||
}
|
||||
parsedSince, err := time.Parse(time.RFC3339, sinceRaw)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, 0, errors.New("since 必须为 RFC3339 时间")
|
||||
return time.Time{}, time.Time{}, 0, errors.New("since must be an RFC3339 timestamp")
|
||||
}
|
||||
parsedUntil, err := time.Parse(time.RFC3339, untilRaw)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, 0, errors.New("until 必须为 RFC3339 时间")
|
||||
return time.Time{}, time.Time{}, 0, errors.New("until must be an RFC3339 timestamp")
|
||||
}
|
||||
since := parsedSince.UTC()
|
||||
until := parsedUntil.UTC()
|
||||
if !until.After(since) {
|
||||
return time.Time{}, time.Time{}, 0, errors.New("until 必须晚于 since")
|
||||
return time.Time{}, time.Time{}, 0, errors.New("until must be later than since")
|
||||
}
|
||||
if until.Sub(since) > time.Duration(maxAccessLogOverviewHours)*time.Hour {
|
||||
return time.Time{}, time.Time{}, 0, errors.New("时间范围不能超过 30 天")
|
||||
return time.Time{}, time.Time{}, 0, errors.New("time range must not exceed 30 days")
|
||||
}
|
||||
hours = int(until.Sub(since).Hours())
|
||||
if hours <= 0 {
|
||||
@@ -1053,7 +1053,7 @@ func normalizeFoldedAccessLogIPQuery(input FoldedAccessLogIPQuery) (FoldedAccess
|
||||
}
|
||||
bucketStartedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(input.BucketStartedAt))
|
||||
if err != nil {
|
||||
return FoldedAccessLogIPQuery{}, time.Time{}, errors.New("bucket_started_at 必须为 RFC3339 时间")
|
||||
return FoldedAccessLogIPQuery{}, time.Time{}, errors.New("bucket_started_at must be an RFC3339 timestamp")
|
||||
}
|
||||
normalizedSortBy := strings.TrimSpace(input.SortBy)
|
||||
switch normalizedSortBy {
|
||||
@@ -1078,7 +1078,7 @@ func normalizeFoldedAccessLogIPQuery(input FoldedAccessLogIPQuery) (FoldedAccess
|
||||
func normalizeAccessLogIPTrendQuery(input AccessLogIPTrendQuery) (AccessLogIPTrendQuery, error) {
|
||||
remoteAddr := strings.TrimSpace(input.RemoteAddr)
|
||||
if remoteAddr == "" {
|
||||
return AccessLogIPTrendQuery{}, errors.New("remote_addr 不能为空")
|
||||
return AccessLogIPTrendQuery{}, errors.New("remote_addr must not be empty")
|
||||
}
|
||||
hours := input.Hours
|
||||
if hours <= 0 {
|
||||
@@ -1094,7 +1094,7 @@ func normalizeAccessLogIPTrendQuery(input AccessLogIPTrendQuery) (AccessLogIPTre
|
||||
switch bucketMinutes {
|
||||
case 5, 10, 15, 30, 60:
|
||||
default:
|
||||
return AccessLogIPTrendQuery{}, errors.New("bucket_minutes 仅支持 5、10、15、30、60")
|
||||
return AccessLogIPTrendQuery{}, errors.New("bucket_minutes must be 5, 10, 15, 30 or 60")
|
||||
}
|
||||
return AccessLogIPTrendQuery{
|
||||
NodeID: strings.TrimSpace(input.NodeID),
|
||||
@@ -1166,7 +1166,7 @@ func normalizeFoldMinutes(value int) (int, error) {
|
||||
case 3, 5:
|
||||
return value, nil
|
||||
default:
|
||||
return 0, errors.New("fold_minutes 仅支持 3 或 5")
|
||||
return 0, errors.New("fold_minutes must be 3 or 5")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
package observability
|
||||
|
||||
const (
|
||||
errInvalidStatusCode = "status_code 必须为 100-599 之间的整数"
|
||||
errInvalidStatusCode = "status_code must be an integer between 100 and 599"
|
||||
)
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
package option
|
||||
|
||||
const (
|
||||
errInvalidParams = "无效的参数"
|
||||
errOptionInitFailed = "系统选项初始化失败"
|
||||
errGeoIPProvider = "归属方式仅支持 disabled、mmdb、ip-api、geojs、ipinfo"
|
||||
errGeoIPIPEmpty = "IP 不能为空"
|
||||
errGeoIPIPInvalid = "IP 格式无效"
|
||||
errGeoIPLookupDisabled = "GeoIP 查询已禁用"
|
||||
errInvalidParams = "invalid parameters"
|
||||
errOptionInitFailed = "Failed to initialize system options"
|
||||
errGeoIPProvider = "geolocation provider must be disabled, mmdb, ip-api, geojs or ipinfo"
|
||||
errGeoIPIPEmpty = "IP address must not be empty"
|
||||
errGeoIPIPInvalid = "invalid IP address format"
|
||||
errGeoIPLookupDisabled = "GeoIP lookup is disabled"
|
||||
)
|
||||
|
||||
@@ -93,7 +93,7 @@ func validateOpenRestyDefaultServerReturnStatus(key, trimmed string) error {
|
||||
}
|
||||
statusCode, _ := strconv.Atoi(trimmed)
|
||||
if statusCode < 100 || statusCode > 999 {
|
||||
return fmt.Errorf("%s 必须在 100 到 999 之间", key)
|
||||
return fmt.Errorf("%s must be between 100 and 999", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func validateOpenRestyGzipCompLevel(key, trimmed string) error {
|
||||
}
|
||||
level, _ := strconv.Atoi(trimmed)
|
||||
if level > maxOpenRestyGzipCompLevel {
|
||||
return fmt.Errorf("%s 不能大于 %d", key, maxOpenRestyGzipCompLevel)
|
||||
return fmt.Errorf("%s must not exceed %d", key, maxOpenRestyGzipCompLevel)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func validateOpenRestyEventsUse(key, trimmed string) error {
|
||||
case "epoll", "kqueue", "poll", "select", "rtsig", "/dev/poll", "eventport":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("%s 仅支持 epoll、kqueue、poll、select、rtsig、/dev/poll、eventport 或留空", key)
|
||||
return fmt.Errorf("%s must be epoll, kqueue, poll, select, rtsig, /dev/poll, eventport or empty", key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ func validateOpenRestyResolvers(key, trimmed string) error {
|
||||
return nil
|
||||
}
|
||||
if !regexp.MustCompile(`^[a-zA-Z0-9.:\-\s]+$`).MatchString(trimmed) {
|
||||
return fmt.Errorf("%s 包含非法字符,请填入有效的 IP 地址或域名,以空格分隔", key)
|
||||
return fmt.Errorf("%s contains invalid characters; enter valid IP addresses or domain names separated by spaces", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -142,19 +142,19 @@ func validateOpenRestyProxyBuffers(key, trimmed string) error {
|
||||
if openRestyProxyBuffersPattern.MatchString(trimmed) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s 格式必须类似 \"16 16k\"", key)
|
||||
return fmt.Errorf("%s must use a format such as \\\"16 16k\\\"", key)
|
||||
}
|
||||
|
||||
func validateOpenRestySizeValue(key, trimmed string) error {
|
||||
if openRestySizePattern.MatchString(trimmed) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s 格式必须为整数或带 k/m/g 单位的大小值", key)
|
||||
return fmt.Errorf("%s must be an integer or a size with a k/m/g suffix", key)
|
||||
}
|
||||
|
||||
func validateOpenRestyCachePath(key, trimmed string) error {
|
||||
if strings.ContainsAny(trimmed, "\r\n\t") {
|
||||
return fmt.Errorf("%s 不能包含换行或制表符", key)
|
||||
return fmt.Errorf("%s must not contain newlines or tabs", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -163,29 +163,29 @@ func validateOpenRestyCacheLevels(key, trimmed string) error {
|
||||
if openRestyCacheLevelsPattern.MatchString(trimmed) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s 格式必须类似 \"1:2\" 或 \"1:2:2\"", key)
|
||||
return fmt.Errorf("%s must use a format such as \\\"1:2\\\" or \\\"1:2:2\\\"", key)
|
||||
}
|
||||
|
||||
func validateOpenRestyDurationToken(key, trimmed string) error {
|
||||
if openRestyDurationTokenPattern.MatchString(trimmed) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s 格式必须为带单位的时长,例如 30m 或 5s", key)
|
||||
return fmt.Errorf("%s must be a duration with a unit, such as 30m or 5s", key)
|
||||
}
|
||||
|
||||
func validateOpenRestyCacheKeyTemplate(key, trimmed string) error {
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("%s 不能为空", key)
|
||||
return fmt.Errorf("%s must not be empty", key)
|
||||
}
|
||||
if strings.ContainsAny(trimmed, "\r\n") {
|
||||
return fmt.Errorf("%s 不能包含换行", key)
|
||||
return fmt.Errorf("%s must not contain newlines", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOpenRestyCacheUseStale(key, trimmed string) error {
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("%s 不能为空", key)
|
||||
return fmt.Errorf("%s must not be empty", key)
|
||||
}
|
||||
allowedTokens := map[string]struct{}{
|
||||
"error": {}, "timeout": {}, "invalid_header": {}, "updating": {},
|
||||
@@ -194,7 +194,7 @@ func validateOpenRestyCacheUseStale(key, trimmed string) error {
|
||||
}
|
||||
for token := range strings.FieldsSeq(trimmed) {
|
||||
if _, ok := allowedTokens[token]; !ok {
|
||||
return fmt.Errorf("%s 包含不支持的值 %q", key, token)
|
||||
return fmt.Errorf("%s contains unsupported value %q", key, token)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -202,7 +202,7 @@ func validateOpenRestyCacheUseStale(key, trimmed string) error {
|
||||
|
||||
func validateOpenRestyMainConfigTemplate(key, value string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("%s 不能为空", key)
|
||||
return fmt.Errorf("%s must not be empty", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -210,15 +210,15 @@ func validateOpenRestyMainConfigTemplate(key, value string) error {
|
||||
func validateOpenRestyTrustedProxyCIDRs(key, value string) error {
|
||||
var cidrs []string
|
||||
if err := json.Unmarshal([]byte(value), &cidrs); err != nil || cidrs == nil {
|
||||
return fmt.Errorf("%s 必须为 JSON 字符串数组", key)
|
||||
return fmt.Errorf("%s must be a JSON array of strings", key)
|
||||
}
|
||||
if len(cidrs) > maxTrustedProxyCIDRs {
|
||||
return fmt.Errorf("%s 最多允许 %d 个网段", key, maxTrustedProxyCIDRs)
|
||||
return fmt.Errorf("%s must contain at most %d network ranges", key, maxTrustedProxyCIDRs)
|
||||
}
|
||||
for _, cidr := range cidrs {
|
||||
prefix, err := netip.ParsePrefix(strings.TrimSpace(cidr))
|
||||
if err != nil || prefix != prefix.Masked() {
|
||||
return fmt.Errorf("%s 包含无效网段 %q", key, cidr)
|
||||
return fmt.Errorf("%s contains invalid network range %q", key, cidr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -229,7 +229,7 @@ func validateOpenRestyDefaultLimitRate(key, trimmed string) error {
|
||||
return nil
|
||||
}
|
||||
if !openRestyDefaultLimitRatePattern.MatchString(strings.ToLower(trimmed)) {
|
||||
return fmt.Errorf("%s 格式不合法,请使用 512k、1m 或纯数字,空表示关闭", key)
|
||||
return fmt.Errorf("invalid %s format; use 512k, 1m or a number, or leave empty to disable", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -241,35 +241,35 @@ func validateOpenRestyDefaultLimitReqPerIP(key, trimmed string) error {
|
||||
return nil
|
||||
}
|
||||
if !openRestyDefaultLimitReqPerIPPattern.MatchString(strings.ToLower(trimmed)) {
|
||||
return fmt.Errorf("%s 格式不合法,请输入类似 10r/s、100r/m,或留空关闭", key)
|
||||
return fmt.Errorf("invalid %s format; use a rate such as 10r/s or 100r/m, or leave empty to disable", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOriginErrorPageStatusCodes(key, trimmed string) error {
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("%s 不能为空", key)
|
||||
return fmt.Errorf("%s must not be empty", key)
|
||||
}
|
||||
var tags []string
|
||||
if err := json.Unmarshal([]byte(trimmed), &tags); err != nil {
|
||||
return fmt.Errorf("%s 必须为 JSON 字符串数组", key)
|
||||
return fmt.Errorf("%s must be a JSON array of strings", key)
|
||||
}
|
||||
if len(tags) == 0 {
|
||||
return fmt.Errorf("%s 至少包含一个状态码标签", key)
|
||||
return fmt.Errorf("%s must contain at least one status code tag", key)
|
||||
}
|
||||
codes, err := openrestyrender.ExpandStatusCodeTags(tags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
if len(codes) == 0 {
|
||||
return fmt.Errorf("%s 展开后不能为空", key)
|
||||
return fmt.Errorf("%s must not be empty after expansion", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOriginErrorPageHTML(key, value string) error {
|
||||
if len(value) > maxOriginErrorPageHTMLBytes {
|
||||
return fmt.Errorf("%s 长度不能超过 %d 字节(256 KiB)", key, maxOriginErrorPageHTMLBytes)
|
||||
return fmt.Errorf("%s must not exceed %d bytes (256 KiB)", key, maxOriginErrorPageHTMLBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -281,22 +281,22 @@ func validateSWOfflineHTML(key, value string) error {
|
||||
func validateSWOfflineDomains(key, value string) error {
|
||||
var domains []string
|
||||
if err := json.Unmarshal([]byte(value), &domains); err != nil || domains == nil {
|
||||
return fmt.Errorf("%s 必须为 JSON 字符串数组", key)
|
||||
return fmt.Errorf("%s must be a JSON array of strings", key)
|
||||
}
|
||||
if len(domains) > maxSWOfflineDomains {
|
||||
return fmt.Errorf("%s 最多支持 %d 个域名", key, maxSWOfflineDomains)
|
||||
return fmt.Errorf("%s supports at most %d domain names", key, maxSWOfflineDomains)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(domains))
|
||||
for _, raw := range domains {
|
||||
domain := strings.ToLower(strings.TrimSpace(raw))
|
||||
if domain == "" {
|
||||
return fmt.Errorf("%s 包含空域名", key)
|
||||
return fmt.Errorf("%s contains an empty domain name", key)
|
||||
}
|
||||
if raw != domain {
|
||||
return fmt.Errorf("%s 域名必须为小写且不含首尾空格:%s", key, raw)
|
||||
return fmt.Errorf("%s domain name must be lowercase without surrounding whitespace: %s", key, raw)
|
||||
}
|
||||
if _, ok := seen[domain]; ok {
|
||||
return fmt.Errorf("%s 包含重复域名 %s", key, domain)
|
||||
return fmt.Errorf("%s contains duplicate domain name %s", key, domain)
|
||||
}
|
||||
seen[domain] = struct{}{}
|
||||
}
|
||||
|
||||
@@ -32,27 +32,27 @@ func TestValidateOriginErrorPageStatusCodes(t *testing.T) {
|
||||
{
|
||||
name: "非法标签",
|
||||
value: `["abc"]`,
|
||||
wantErr: "无效状态码",
|
||||
wantErr: "invalid status code",
|
||||
},
|
||||
{
|
||||
name: "非 JSON 数组",
|
||||
value: `500-599`,
|
||||
wantErr: "必须为 JSON 字符串数组",
|
||||
wantErr: "must be a JSON array of strings",
|
||||
},
|
||||
{
|
||||
name: "空数组",
|
||||
value: `[]`,
|
||||
wantErr: "至少包含一个状态码标签",
|
||||
wantErr: "at least one status code tag",
|
||||
},
|
||||
{
|
||||
name: "越界状态码",
|
||||
value: `["399"]`,
|
||||
wantErr: "状态码须在",
|
||||
wantErr: "status code must be between",
|
||||
},
|
||||
{
|
||||
name: "空字符串",
|
||||
value: "",
|
||||
wantErr: "不能为空",
|
||||
wantErr: "must not be empty",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestValidateOriginErrorPageHTML(t *testing.T) {
|
||||
oversized := strings.Repeat("a", maxOriginErrorPageHTMLBytes+1)
|
||||
err := validateOpenRestyOption(model.ConfigKeyOriginErrorPageHTML, oversized)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "长度不能超过")
|
||||
assert.Contains(t, err.Error(), "must not exceed")
|
||||
|
||||
// 恰好上限应通过
|
||||
atLimit := strings.Repeat("b", maxOriginErrorPageHTMLBytes)
|
||||
@@ -101,7 +101,7 @@ func TestValidateOriginErrorPageEnabled(t *testing.T) {
|
||||
require.NoError(t, validateOpenRestyOption(model.ConfigKeyOriginErrorPageEnabled, "false"))
|
||||
err := validateOpenRestyOption(model.ConfigKeyOriginErrorPageEnabled, "yes")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "true 或 false")
|
||||
assert.Contains(t, err.Error(), "true or false")
|
||||
}
|
||||
|
||||
func TestValidateSWOfflineDomains(t *testing.T) {
|
||||
|
||||
@@ -139,5 +139,5 @@ func SyncUptimeKumaHandler(c *gin.Context) {
|
||||
if apiutil.AbortBadRequestOnError(c, syncUptimeKuma(c.Request.Context())) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK("同步成功"))
|
||||
c.JSON(http.StatusOK, response.OK("Synchronization succeeded"))
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ var (
|
||||
const optionValueTrue = "true"
|
||||
|
||||
// protectedConfigKeyMessage 命中受保护 key 时返回给管理员的业务错误文案。
|
||||
const protectedConfigKeyMessage = "该配置项由系统任务管理,禁止手动修改"
|
||||
const protectedConfigKeyMessage = "this configuration is managed by system tasks and cannot be changed manually"
|
||||
|
||||
// protectedConfigKeys 仅允许内部(迁移任务/bootstrap)写入的 key。
|
||||
var protectedConfigKeys = map[string]bool{
|
||||
@@ -79,7 +79,7 @@ func validateOptionWithState(ctx context.Context, option model.OpenFlareOption,
|
||||
func validatePositiveIntegerOption(key, value string) error {
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil || intValue <= 0 {
|
||||
return fmt.Errorf("%s 必须为大于 0 的整数", key)
|
||||
return fmt.Errorf("%s must be an integer greater than 0", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func validatePositiveIntegerOption(key, value string) error {
|
||||
func validateNonNegativeIntegerOption(key, value string) error {
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil || intValue < 0 {
|
||||
return fmt.Errorf("%s 必须为大于等于 0 的整数", key)
|
||||
return fmt.Errorf("%s must be a nonnegative integer", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func validateBooleanOption(key, value string) error {
|
||||
case optionValueTrue, "false":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("%s 必须为 true 或 false", key)
|
||||
return fmt.Errorf("%s must be true or false", key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ func validateGeoIPOption(key, value string) error {
|
||||
if geoip.IsValidProvider(value) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s 仅支持 disabled、mmdb、ip-api、geojs、ipinfo", key)
|
||||
return fmt.Errorf("%s must be disabled, mmdb, ip-api, geojs or ipinfo", key)
|
||||
}
|
||||
|
||||
func validateLogRetentionOption(key, value string) error {
|
||||
@@ -116,7 +116,7 @@ func validateLogRetentionOption(key, value string) error {
|
||||
case model.ConfigKeyLogRetentionDaysPostgres, model.ConfigKeyLogRetentionDaysSQLite, model.ConfigKeyLogRetentionDaysClickHouse:
|
||||
intValue, err := strconv.Atoi(value)
|
||||
if err != nil || intValue < 1 {
|
||||
return fmt.Errorf("%s 必须为大于等于 1 的整数天", key)
|
||||
return fmt.Errorf("%s must be a whole number of days greater than or equal to 1", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -135,12 +135,12 @@ func validatePagesOption(key, value string) error {
|
||||
case model.ConfigKeyPagesMaxPackageSizeMB:
|
||||
intValue, err := strconv.Atoi(trimmed)
|
||||
if err != nil || intValue < 1 || intValue > 2048 {
|
||||
return fmt.Errorf("%s 必须为 1~2048 的整数(MiB)", key)
|
||||
return fmt.Errorf("%s must be an integer between 1 and 2048 (MiB)", key)
|
||||
}
|
||||
case model.ConfigKeyPagesMaxHistoryCount:
|
||||
intValue, err := strconv.Atoi(trimmed)
|
||||
if err != nil || intValue < 0 {
|
||||
return fmt.Errorf("%s 必须为大于等于 0 的整数(0 表示不限制)", key)
|
||||
return fmt.Errorf("%s must be a nonnegative integer (0 means unlimited)", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -176,16 +176,16 @@ func validateUptimeKumaEnabled(ctx context.Context, key, trimmed string, state m
|
||||
username := strings.TrimSpace(state[model.ConfigKeyUptimeKumaUsername])
|
||||
password := strings.TrimSpace(state[model.ConfigKeyUptimeKumaPassword])
|
||||
if url == "" {
|
||||
return errors.New("启用 Uptime Kuma 时地址不能为空")
|
||||
return errors.New("uptime Kuma address is required when the integration is enabled")
|
||||
}
|
||||
if username == "" {
|
||||
return errors.New("启用 Uptime Kuma 时用户名不能为空")
|
||||
return errors.New("uptime Kuma username is required when the integration is enabled")
|
||||
}
|
||||
// 如果待验证的密码为空,且当前配置中也没有密码,则报错
|
||||
if password == "" {
|
||||
existingPwd, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeyUptimeKumaPassword)
|
||||
if strings.TrimSpace(existingPwd.Value) == "" {
|
||||
return errors.New("启用 Uptime Kuma 时密码不能为空")
|
||||
return errors.New("uptime Kuma password is required when the integration is enabled")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -193,21 +193,21 @@ func validateUptimeKumaEnabled(ctx context.Context, key, trimmed string, state m
|
||||
|
||||
func validateUptimeKumaUsername(trimmed string, state map[string]string) error {
|
||||
if trimmed == "" && state[model.ConfigKeyUptimeKumaEnabled] == optionValueTrue {
|
||||
return errors.New("启用 Uptime Kuma 时用户名不能为空")
|
||||
return errors.New("uptime Kuma username is required when the integration is enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUptimeKumaURL(trimmed string) error {
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "http://") && !strings.HasPrefix(trimmed, "https://") {
|
||||
return errors.New("uptime Kuma 地址必须以 http:// 或 https:// 开头")
|
||||
return errors.New("uptime Kuma address must start with http:// or https://")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUptimeKumaMonitorScope(trimmed string) error {
|
||||
if trimmed != "all" && trimmed != "selected" {
|
||||
return errors.New("监控范围必须为全部站点 (all) 或选择站点 (selected)")
|
||||
return errors.New("monitor scope must be all or selected sites")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -215,7 +215,7 @@ func validateUptimeKumaMonitorScope(trimmed string) error {
|
||||
func validateUptimeKumaRetry(key, trimmed string) error {
|
||||
intValue, err := strconv.Atoi(trimmed)
|
||||
if err != nil || intValue < 0 {
|
||||
return fmt.Errorf("%s 必须为大于等于 0 的整数", key)
|
||||
return fmt.Errorf("%s must be a nonnegative integer", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
package origin
|
||||
|
||||
const (
|
||||
errOriginAddressRequired = "源站地址不能为空"
|
||||
errOriginAddressInvalid = "源站地址格式不合法"
|
||||
errOriginAddressExists = "源站地址已存在"
|
||||
errOriginDeleteReferenced = "该源站仍被规则引用,无法删除"
|
||||
errOriginMissingPort = "源站地址缺少端口"
|
||||
errOriginNotFound = "源站不存在"
|
||||
errOriginAddressRequired = "origin address must not be empty"
|
||||
errOriginAddressInvalid = "invalid origin address format"
|
||||
errOriginAddressExists = "origin address already exists"
|
||||
errOriginDeleteReferenced = "this origin is still referenced by rules and cannot be deleted"
|
||||
errOriginMissingPort = "origin address is missing a port"
|
||||
errOriginNotFound = "Origin was not found"
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
func TestParseAndValidatePagesDownloadURL(t *testing.T) {
|
||||
_, err := parseAndValidatePagesDownloadURL("")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "填写")
|
||||
assert.Contains(t, err.Error(), "enter")
|
||||
|
||||
_, err = parseAndValidatePagesDownloadURL("ftp://example.com/a.zip")
|
||||
require.Error(t, err)
|
||||
|
||||
@@ -5,62 +5,62 @@
|
||||
package pages
|
||||
|
||||
const (
|
||||
errPagesProjectNotFound = "pages 项目不存在"
|
||||
errPagesSlugExists = "pages 项目标识已存在"
|
||||
errPagesNameRequired = "pages 项目名称不能为空"
|
||||
errPagesSlugInvalid = "pages 项目标识只能包含小写字母、数字和连字符"
|
||||
errPagesDeleteReferenced = "pages 项目已被规则引用,不能删除"
|
||||
errPagesDeploymentNotFound = "pages 部署不存在"
|
||||
errPagesDeploymentMismatch = "pages 部署不属于该项目"
|
||||
errPagesDeleteActiveDeploy = "不能删除当前激活的 Pages 部署"
|
||||
errPagesPackageMissing = "缺少 Pages 部署包"
|
||||
errPagesPackageURLRequired = "请填写部署包下载链接"
|
||||
errPagesPackageURLInvalid = "部署包下载链接无效,仅支持 http/https"
|
||||
errPagesPackageURLDownloadFailed = "从链接下载部署包失败"
|
||||
errPagesPackageURLTooLarge = "链接指向的部署包超过大小限制"
|
||||
errPagesPackageNotZip = "pages 部署包必须是 .zip 文件" // legacy alias kept for tests
|
||||
errPagesPackageUnsupported = "pages 部署包仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式"
|
||||
errPagesPackageInvalidZip = "pages 部署包不是有效 zip 文件" // legacy alias
|
||||
errPagesPackageInvalid = "pages 部署包不是有效的压缩文件"
|
||||
errPagesPackageEmpty = "pages 部署包不能为空"
|
||||
errPagesPackageExtractedTooLarge = "pages 部署包展开后体积超过限制"
|
||||
errPagesPackageFileTooLarge = "pages 部署包内文件过大"
|
||||
errPagesAPIProxyPathRequired = "启用 API 反代时,匹配路径不能为空"
|
||||
errPagesAPIProxyPathPrefix = "API 反代匹配路径必须以 '/' 开头"
|
||||
errPagesAPIProxyPassRequired = "启用 API 反代时,后端服务地址不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errPagesAPIProxyPassInvalid = "API 反代后端服务地址必须是有效的 HTTP/HTTPS URL" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errPagesPackagePathEmpty = "pages 部署包路径为空"
|
||||
errPagesPackageUploadMissing = "pages 部署包上传记录不存在"
|
||||
errPagesPackageNotInActiveConfig = "pages 部署尚未进入激活配置"
|
||||
errPagesDeploymentHashMissing = "pages 部署包哈希缺失"
|
||||
errPagesInvalidSnapshotFormat = "配置快照格式无效"
|
||||
errPagesActorMissing = "无法识别当前用户"
|
||||
errPagesEntryFileMissing = "当前激活部署中不存在指定入口文件"
|
||||
errPagesSourceNotFound = "pages 部署源不存在"
|
||||
errPagesSourceTypeRequired = "请选择 pages 部署源类型"
|
||||
errPagesSourceTypeUnsupported = "pages 部署源类型不受支持"
|
||||
errPagesSourceRemoteFields = "远程地址来源不能包含 GitHub 或自动更新配置"
|
||||
errPagesSourceRemoteURLRequired = "请提供远程部署包地址"
|
||||
errPagesSourceRemoteURLInvalid = "远程部署包地址无效,仅支持不含用户信息和片段的 http/https 地址"
|
||||
errPagesSourceGitHubFields = "GitHub Release 来源不能包含远程地址配置"
|
||||
errPagesSourceRepositoryInvalid = "GitHub 仓库地址无效,仅支持 https://github.com/{owner}/{repo}"
|
||||
errPagesSourceSelectorInvalid = "GitHub Release 选择方式无效"
|
||||
errPagesSourceAssetNameInvalid = "GitHub Release 资源名称必须是安全的文件名"
|
||||
errPagesSourceCheckInterval = "GitHub latest 检查间隔必须在 5 到 1440 分钟之间"
|
||||
errPagesSourceAutoNotAvailable = "自动更新将在后续阶段开放,当前必须保持关闭"
|
||||
errPagesSourceReleaseNotFound = "未找到符合配置的 GitHub Release 资源"
|
||||
errPagesSourceDigestInvalid = "GitHub Release 资源摘要格式无效"
|
||||
errPagesSourceDigestMismatch = "GitHub Release 资源摘要校验失败"
|
||||
errPagesSourceConfirmationNeeded = "检测到同一 Release 的资源已被替换,请刷新并确认当前版本"
|
||||
errPagesSourceConfirmationStale = "确认的版本已变化,请刷新后重新确认"
|
||||
errPagesSourceInitialCheckWarning = "部署源已保存,但首次检查任务入队失败,请稍后手动检查"
|
||||
errPagesSourceCheckUnsupported = "远程地址来源不支持检查更新,请使用立即同步"
|
||||
errPagesSourceActionBusy = "pages 部署源任务正在执行"
|
||||
errPagesSourceActionInvalid = "pages 部署源任务参数无效"
|
||||
errPagesSourceActionStale = "pages 部署源配置已变化,本次任务已跳过"
|
||||
errPagesSourceLeaseLost = "pages 部署源任务执行权已失效"
|
||||
errPagesSourceLeaseExpired = "上次 pages 部署源任务租约已过期"
|
||||
errPagesSourceSyncFailed = "pages 部署源同步失败"
|
||||
errPagesSourceTaskDispatchFailed = "pages 部署源任务入队失败"
|
||||
errPagesSourceInternal = "pages 部署源操作失败,请稍后重试"
|
||||
errPagesProjectNotFound = "pages project was not found"
|
||||
errPagesSlugExists = "pages project slug already exists"
|
||||
errPagesNameRequired = "pages project name must not be empty"
|
||||
errPagesSlugInvalid = "pages project slug may only contain lowercase letters, digits and hyphens"
|
||||
errPagesDeleteReferenced = "pages project is referenced by rules and cannot be deleted"
|
||||
errPagesDeploymentNotFound = "pages deployment was not found"
|
||||
errPagesDeploymentMismatch = "pages deployment does not belong to this project"
|
||||
errPagesDeleteActiveDeploy = "the active Pages deployment cannot be deleted"
|
||||
errPagesPackageMissing = "missing Pages deployment archive"
|
||||
errPagesPackageURLRequired = "enter the deployment archive download URL"
|
||||
errPagesPackageURLInvalid = "invalid deployment archive URL; only HTTP and HTTPS are supported"
|
||||
errPagesPackageURLDownloadFailed = "Failed to download deployment archive from URL"
|
||||
errPagesPackageURLTooLarge = "Deployment archive at the URL exceeds the size limit"
|
||||
errPagesPackageNotZip = "Pages deployment archive must be a .zip file" // legacy alias kept for tests
|
||||
errPagesPackageUnsupported = "pages archives support zip, tar.gz, tar.xz, tar.bz2, tar and 7z formats only"
|
||||
errPagesPackageInvalidZip = "Pages deployment archive is not a valid ZIP file" // legacy alias
|
||||
errPagesPackageInvalid = "pages deployment archive is not a valid compressed archive"
|
||||
errPagesPackageEmpty = "pages deployment archive must not be empty"
|
||||
errPagesPackageExtractedTooLarge = "extracted Pages deployment exceeds the size limit"
|
||||
errPagesPackageFileTooLarge = "a file in the Pages deployment archive exceeds the size limit"
|
||||
errPagesAPIProxyPathRequired = "match path is required when the API reverse proxy is enabled"
|
||||
errPagesAPIProxyPathPrefix = "API reverse proxy match path must start with '/'"
|
||||
errPagesAPIProxyPassRequired = "backend URL is required when the API reverse proxy is enabled" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errPagesAPIProxyPassInvalid = "API reverse proxy backend must be a valid HTTP/HTTPS URL" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||
errPagesPackagePathEmpty = "Pages deployment archive path is empty"
|
||||
errPagesPackageUploadMissing = "pages deployment archive upload record was not found"
|
||||
errPagesPackageNotInActiveConfig = "pages deployment is not included in the active configuration"
|
||||
errPagesDeploymentHashMissing = "pages deployment archive hash is missing"
|
||||
errPagesInvalidSnapshotFormat = "invalid configuration snapshot format"
|
||||
errPagesActorMissing = "Unable to identify the current user"
|
||||
errPagesEntryFileMissing = "The specified entry file does not exist in the active deployment"
|
||||
errPagesSourceNotFound = "pages deployment source was not found"
|
||||
errPagesSourceTypeRequired = "select a Pages deployment source type"
|
||||
errPagesSourceTypeUnsupported = "unsupported Pages deployment source type"
|
||||
errPagesSourceRemoteFields = "remote URL sources must not include GitHub or automatic update configuration"
|
||||
errPagesSourceRemoteURLRequired = "provide the remote deployment archive URL"
|
||||
errPagesSourceRemoteURLInvalid = "invalid remote archive URL; use HTTP/HTTPS without user information or fragments"
|
||||
errPagesSourceGitHubFields = "GitHub Release sources must not include remote URL configuration"
|
||||
errPagesSourceRepositoryInvalid = "invalid GitHub repository URL; use https://github.com/{owner}/{repo}"
|
||||
errPagesSourceSelectorInvalid = "invalid GitHub Release selection mode"
|
||||
errPagesSourceAssetNameInvalid = "GitHub Release asset name must be a safe filename"
|
||||
errPagesSourceCheckInterval = "GitHub latest check interval must be between 5 and 1440 minutes"
|
||||
errPagesSourceAutoNotAvailable = "Automatic updates will be available in a later stage and must remain disabled for now"
|
||||
errPagesSourceReleaseNotFound = "no GitHub Release asset matches the configuration"
|
||||
errPagesSourceDigestInvalid = "invalid GitHub Release asset digest format"
|
||||
errPagesSourceDigestMismatch = "GitHub Release asset digest verification failed"
|
||||
errPagesSourceConfirmationNeeded = "an asset in the same release was replaced; refresh and confirm the current version"
|
||||
errPagesSourceConfirmationStale = "the confirmed version has changed; refresh and confirm again"
|
||||
errPagesSourceInitialCheckWarning = "Deployment source saved, but the initial check could not be enqueued; run a manual check later"
|
||||
errPagesSourceCheckUnsupported = "remote URL sources do not support update checks; use Sync now"
|
||||
errPagesSourceActionBusy = "a Pages deployment source task is already running"
|
||||
errPagesSourceActionInvalid = "invalid Pages deployment source task parameters"
|
||||
errPagesSourceActionStale = "Pages deployment source configuration changed; task skipped"
|
||||
errPagesSourceLeaseLost = "pages deployment source task ownership is no longer valid"
|
||||
errPagesSourceLeaseExpired = "Previous Pages deployment source task lease expired"
|
||||
errPagesSourceSyncFailed = "pages deployment source synchronization failed"
|
||||
errPagesSourceTaskDispatchFailed = "failed to enqueue Pages deployment source task"
|
||||
errPagesSourceInternal = "Pages deployment source operation failed; please try again later"
|
||||
)
|
||||
|
||||
@@ -80,7 +80,7 @@ func checkGitHubSource(
|
||||
if snapshot == nil || snapshot.SourceType != PagesSourceTypeGitHubRelease {
|
||||
return nil, errors.New(errPagesSourceTypeUnsupported)
|
||||
}
|
||||
task.AppendLog(ctx, "[check] 正在检查 GitHub Release:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
|
||||
task.AppendLog(ctx, "[check] Checking GitHub Release: repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
|
||||
client := newGitHubReleaseClient()
|
||||
result, err := client.Resolve(ctx, githubrelease.ResolveRequest{
|
||||
Repository: snapshot.GitHubRepository,
|
||||
@@ -111,7 +111,7 @@ func checkGitHubSource(
|
||||
}
|
||||
detail, _ := json.Marshal(map[string]string{"revision": revision, pagesDeploymentColumnStatus: status})
|
||||
return &githubCheckTaskResult{
|
||||
Message: "GitHub Release 检查完成,内容未变化",
|
||||
Message: "GitHub Release check completed; content is unchanged",
|
||||
Detail: string(detail),
|
||||
Revision: revision,
|
||||
Status: status,
|
||||
@@ -140,12 +140,12 @@ func checkGitHubSource(
|
||||
return nil, err
|
||||
}
|
||||
detail, _ := json.Marshal(map[string]string{"revision": target.Revision, pagesDeploymentColumnStatus: status})
|
||||
message := "GitHub Release 检查完成"
|
||||
message := "GitHub Release check completed"
|
||||
switch status {
|
||||
case pagesSourceStatusUpdateAvailable:
|
||||
message = "发现新的 GitHub Release 部署包"
|
||||
message = "A new GitHub Release deployment archive is available"
|
||||
case pagesSourceStatusAttention:
|
||||
message = "检测到同一 Release 的资源被替换,需要确认"
|
||||
message = "An asset in the same release was replaced; confirmation is required"
|
||||
}
|
||||
return &githubCheckTaskResult{
|
||||
Message: message, Detail: string(detail), Revision: target.Revision,
|
||||
@@ -454,7 +454,7 @@ func activatePreparedGitHubSource(
|
||||
triggerType string,
|
||||
prepared *preparedGitHubSource,
|
||||
) (*sourceSyncOutcome, error) {
|
||||
task.AppendLog(ctx, "[activate] 正在原子切换 GitHub Release 部署")
|
||||
task.AppendLog(ctx, "[activate] Atomically switching GitHub Release deployment")
|
||||
deployment, reused, referenced, err := commitSourceDeploymentWithTrigger(
|
||||
ctx, snapshot, prepared.target.Revision, prepared.download.SHA256,
|
||||
prepared.target.Detail, prepared.target.DetailJSON, actor, triggerType, prepared.manifest,
|
||||
@@ -482,7 +482,7 @@ func resolveAndGuardGitHubSync(
|
||||
targetRevision string,
|
||||
confirmedRevision string,
|
||||
) (*githubSourceTarget, *sourceSyncOutcome, error) {
|
||||
task.AppendLog(ctx, "[resolve] 正在解析 GitHub Release:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
|
||||
task.AppendLog(ctx, "[resolve] Resolving GitHub Release: repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
|
||||
resolved, err := client.Resolve(ctx, githubrelease.ResolveRequest{
|
||||
Repository: snapshot.GitHubRepository,
|
||||
Selector: githubrelease.Selector(snapshot.ReleaseSelector),
|
||||
@@ -557,7 +557,7 @@ func prepareGitHubSyncPackage(
|
||||
target *githubSourceTarget,
|
||||
) (*preparedGitHubSource, error) {
|
||||
limits := resolvePagesLimits(ctx)
|
||||
task.AppendLog(ctx, "[download] 正在下载 GitHub Release asset:repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
|
||||
task.AppendLog(ctx, "[download] Downloading GitHub Release asset: repo=%s asset=%s", snapshot.GitHubRepository, snapshot.AssetName)
|
||||
download, err := client.Download(ctx, githubrelease.DownloadRequest{
|
||||
Repository: snapshot.GitHubRepository,
|
||||
Asset: target.Asset,
|
||||
@@ -606,7 +606,7 @@ func inspectAndIngestGitHubPackage(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task.AppendLog(ctx, "[verify] 正在校验 GitHub Release 归档与入口")
|
||||
task.AppendLog(ctx, "[verify] Verifying GitHub Release archive and entry file")
|
||||
manifest, err := inspectPagesPackage(download.Path, format, rootDir, entryFile, limits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -633,7 +633,7 @@ func resolveGitHubSourceIngest(
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
task.AppendLog(ctx, "[ingest] 正在保存 GitHub Release 部署包")
|
||||
task.AppendLog(ctx, "[ingest] Saving GitHub Release deployment archive")
|
||||
result, err := ingestPagesDeploymentPackageWithSource(
|
||||
ctx, download.Path, download.SHA256, snapshot.ProjectID, snapshot.SourceID, target.Asset.Name, format,
|
||||
)
|
||||
|
||||
@@ -116,11 +116,11 @@ func normalizePagesSlug(raw string) string {
|
||||
func validateAndNormalizePagesRootDir(raw string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if len(value) > pagesMaxPathLength {
|
||||
return "", errors.New("pages 根目录长度不能超过 512")
|
||||
return "", errors.New("pages root directory must not exceed 512 characters")
|
||||
}
|
||||
normalized, err := pagesarchive.NormalizeLogicalPath(value, true)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pages 根目录不合法: %w", err)
|
||||
return "", fmt.Errorf("invalid Pages root directory: %w", err)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
@@ -131,33 +131,33 @@ func normalizePagesFallbackPath(raw string) (string, error) {
|
||||
value = defaultPagesFallbackPath
|
||||
}
|
||||
if len(value) > pagesMaxPathLength {
|
||||
return "", errors.New("spa fallback 回退路径长度不能超过 512")
|
||||
return "", errors.New("SPA fallback path must not exceed 512 characters")
|
||||
}
|
||||
if !strings.HasPrefix(value, "/") {
|
||||
return "", errors.New("spa fallback 回退路径必须以 / 开头")
|
||||
return "", errors.New("SPA fallback path must start with /")
|
||||
}
|
||||
if value == "/" || strings.HasSuffix(value, "/") {
|
||||
return "", errors.New("spa fallback 回退路径必须指向具体文件")
|
||||
return "", errors.New("SPA fallback path must point to a specific file")
|
||||
}
|
||||
if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") {
|
||||
return "", errors.New("spa fallback 回退路径包含不支持的字符")
|
||||
return "", errors.New("SPA fallback path contains unsupported characters")
|
||||
}
|
||||
for _, r := range value {
|
||||
if r <= 0x20 || r == 0x7f {
|
||||
return "", errors.New("spa fallback 回退路径不能包含空白或控制字符")
|
||||
return "", errors.New("SPA fallback path must not contain whitespace or control characters")
|
||||
}
|
||||
}
|
||||
for segment := range strings.SplitSeq(value, "/") {
|
||||
if segment == "." || segment == ".." {
|
||||
return "", errors.New("spa fallback 回退路径不能包含 . 或 .. 路径段")
|
||||
return "", errors.New("SPA fallback path must not contain . or .. segments")
|
||||
}
|
||||
}
|
||||
cleaned := path.Clean(value)
|
||||
if cleaned == "." || !strings.HasPrefix(cleaned, "/") {
|
||||
return "", errors.New("spa fallback 回退路径不合法")
|
||||
return "", errors.New("invalid SPA fallback path")
|
||||
}
|
||||
if cleaned == "/" || strings.HasSuffix(cleaned, "/") {
|
||||
return "", errors.New("spa fallback 回退路径必须指向具体文件")
|
||||
return "", errors.New("SPA fallback path must point to a specific file")
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
@@ -176,11 +176,11 @@ func validateAndNormalizePagesEntryFile(raw string) (string, error) {
|
||||
value = defaultPagesEntryFile
|
||||
}
|
||||
if len(value) > pagesMaxPathLength {
|
||||
return "", errors.New("pages 入口文件长度不能超过 512")
|
||||
return "", errors.New("pages entry file must not exceed 512 characters")
|
||||
}
|
||||
normalized, err := pagesarchive.NormalizeLogicalPath(value, false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pages 入口文件不合法: %w", err)
|
||||
return "", fmt.Errorf("invalid Pages entry file: %w", err)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
@@ -209,7 +209,7 @@ func persistPagesUploadTemp(fileHeader *multipart.FileHeader, maxPackageBytes in
|
||||
}
|
||||
if written > maxPackageBytes {
|
||||
_ = os.Remove(temp.Name())
|
||||
return "", "", 0, "", fmt.Errorf("pages 部署包不能超过 %d MiB", maxPackageBytes/bytesPerMiB)
|
||||
return "", "", 0, "", fmt.Errorf("pages deployment archive must not exceed %d MiB", maxPackageBytes/bytesPerMiB)
|
||||
}
|
||||
return temp.Name(), hex.EncodeToString(hash.Sum(nil)), written, format, nil
|
||||
}
|
||||
@@ -337,7 +337,7 @@ func removePagesUploadIfUnreferenced(ctx context.Context, projectID uint, upload
|
||||
return err
|
||||
}
|
||||
if uploadRecord.Type != upload.ReservedPagesDeploymentType {
|
||||
return fmt.Errorf("pages 部署包上传类型不匹配: %s", uploadRecord.Type)
|
||||
return fmt.Errorf("pages deployment archive upload type mismatch: %s", uploadRecord.Type)
|
||||
}
|
||||
|
||||
var references int64
|
||||
@@ -406,7 +406,7 @@ func mapPagesArchiveError(err error) error {
|
||||
case strings.Contains(message, "missing entry file"):
|
||||
return err
|
||||
case strings.Contains(message, "file count exceeds"):
|
||||
return fmt.Errorf("pages 部署文件数不能超过 %d", pagesMaxDeploymentFiles)
|
||||
return fmt.Errorf("pages deployment must not contain more than %d files", pagesMaxDeploymentFiles)
|
||||
case strings.Contains(message, "extracted size exceeds"):
|
||||
return errors.New(errPagesPackageExtractedTooLarge)
|
||||
case strings.Contains(message, "file too large"), strings.Contains(message, "size out of bounds"):
|
||||
|
||||
@@ -958,7 +958,7 @@ func deploymentPackageHash(ctx context.Context, deployment *model.PagesDeploymen
|
||||
}
|
||||
uploadRecord, err := upload.GetActiveUpload(ctx, deployment.UploadID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pages 部署包不存在: %w", err)
|
||||
return "", fmt.Errorf("pages deployment archive was not found: %w", err)
|
||||
}
|
||||
hash := strings.TrimSpace(uploadRecord.Hash)
|
||||
if hash == "" {
|
||||
@@ -973,7 +973,7 @@ func deploymentPackageHash(ctx context.Context, deployment *model.PagesDeploymen
|
||||
func openDeploymentPackageFromUpload(ctx context.Context, uploadID uint64, deploymentID uint) (DeploymentPackage, error) {
|
||||
opened, err := upload.OpenStoredUpload(ctx, uploadID)
|
||||
if err != nil {
|
||||
return DeploymentPackage{}, fmt.Errorf("pages 部署包不存在: %w", err)
|
||||
return DeploymentPackage{}, fmt.Errorf("pages deployment archive was not found: %w", err)
|
||||
}
|
||||
contentType := opened.ContentType
|
||||
if contentType == "" {
|
||||
@@ -1028,7 +1028,7 @@ func hydrateLegacyDeploymentUpload(
|
||||
RelativePaths: pagesLegacyRelativeCandidates(project, deployment),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pages 部署包不存在: %w", err)
|
||||
return nil, fmt.Errorf("pages deployment archive was not found: %w", err)
|
||||
}
|
||||
|
||||
ingestResult, err := ingestPagesDeploymentPackage(
|
||||
|
||||
@@ -163,7 +163,7 @@ func TestCreateProjectRejectsUnsafeFallbackPath(t *testing.T) {
|
||||
SPAFallbackPath: "/index.html; proxy_pass http://evil",
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "回退路径")
|
||||
assert.Contains(t, err.Error(), "fallback path")
|
||||
}
|
||||
|
||||
func TestCreateProjectRejectsUnsafeContentPaths(t *testing.T) {
|
||||
@@ -412,7 +412,7 @@ func TestOpenDeploymentPackageRequiresActiveConfigSnapshot(t *testing.T) {
|
||||
|
||||
_, err = OpenDeploymentPackage(ctx, deployment.ID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "激活配置")
|
||||
assert.Contains(t, err.Error(), "active configuration")
|
||||
|
||||
require.NoError(t, db.DB(ctx).Create(&model.ConfigVersion{
|
||||
Version: "v2026-001",
|
||||
|
||||
@@ -45,7 +45,7 @@ func GetProjectLatestPackageMetadata(ctx context.Context, projectID uint) (*Proj
|
||||
|
||||
uploadRecord, err := upload.GetActiveUpload(ctx, deployment.UploadID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pages 部署包不存在: %w", err)
|
||||
return nil, fmt.Errorf("pages deployment archive was not found: %w", err)
|
||||
}
|
||||
hash := strings.TrimSpace(uploadRecord.Hash)
|
||||
if hash == "" {
|
||||
|
||||
@@ -100,7 +100,7 @@ func rebindPagesRouteMaps(ctx context.Context, routes []map[string]json.RawMessa
|
||||
if siteName == "" {
|
||||
siteName = "pages"
|
||||
}
|
||||
return false, fmt.Errorf("路由 %s %w", siteName, err)
|
||||
return false, fmt.Errorf("route %s %w", siteName, err)
|
||||
}
|
||||
project, activeDeployment, err := loadActivePagesProject(ctx, projectID, siteName)
|
||||
if err != nil {
|
||||
@@ -152,7 +152,7 @@ func resolveProjectIDFromRouteMap(route map[string]json.RawMessage) (uint, error
|
||||
return deployment.ProjectID, nil
|
||||
}
|
||||
}
|
||||
return 0, errors.New("pages 配置无效: 缺少 pages_project_id")
|
||||
return 0, errors.New("invalid Pages configuration: missing pages_project_id")
|
||||
}
|
||||
|
||||
func loadActivePagesProject(ctx context.Context, projectID uint, siteName string) (*model.PagesProject, *model.PagesDeployment, error) {
|
||||
@@ -162,28 +162,28 @@ func loadActivePagesProject(ctx context.Context, projectID uint, siteName string
|
||||
project, err := repository.GetPagesProjectByID(ctx, projectID)
|
||||
if err != nil {
|
||||
if errorsIsNotFound(err) {
|
||||
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目不存在", siteName)
|
||||
return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project was not found", siteName)
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
if !project.Enabled {
|
||||
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目未启用", siteName)
|
||||
return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project is disabled", siteName)
|
||||
}
|
||||
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 {
|
||||
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 项目没有激活部署", siteName)
|
||||
return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages project has no active deployment", siteName)
|
||||
}
|
||||
activeDeployment, err := repository.GetPagesDeploymentByID(ctx, *project.ActiveDeploymentID)
|
||||
if err != nil {
|
||||
if errorsIsNotFound(err) {
|
||||
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不存在", siteName)
|
||||
return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: active Pages deployment was not found", siteName)
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
if activeDeployment.ProjectID != project.ID {
|
||||
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 激活部署不匹配", siteName)
|
||||
return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: active Pages deployment does not match", siteName)
|
||||
}
|
||||
if strings.TrimSpace(activeDeployment.Checksum) == "" {
|
||||
return nil, nil, fmt.Errorf("路由 %s Pages 配置无效: pages 部署校验和缺失", siteName)
|
||||
return nil, nil, fmt.Errorf("invalid Pages configuration for route %s: Pages deployment checksum is missing", siteName)
|
||||
}
|
||||
return project, activeDeployment, nil
|
||||
}
|
||||
|
||||
@@ -86,12 +86,12 @@ func decodeStrictJSON(c *gin.Context, target any, allowEmpty bool) bool {
|
||||
func deploymentIDParam(c *gin.Context) (uint, bool) {
|
||||
raw := c.Param("deployment_id")
|
||||
if raw == "" {
|
||||
response.AbortBadRequest(c, "无效的 ID")
|
||||
response.AbortBadRequest(c, "Invalid ID")
|
||||
return 0, false
|
||||
}
|
||||
id64, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || id64 == 0 {
|
||||
response.AbortBadRequest(c, "无效的 ID")
|
||||
response.AbortBadRequest(c, "Invalid ID")
|
||||
return 0, false
|
||||
}
|
||||
return uint(id64), true
|
||||
|
||||
@@ -46,13 +46,13 @@ func (providerError remoteProviderError) Error() string {
|
||||
}
|
||||
|
||||
const (
|
||||
errRemoteProviderInvalidLimit remoteProviderError = "远程来源部署包大小限制无效"
|
||||
errRemoteProviderRedirectLimit remoteProviderError = "远程来源重定向次数超过限制"
|
||||
errRemoteProviderInvalidLimit remoteProviderError = "Invalid remote source archive size limit"
|
||||
errRemoteProviderRedirectLimit remoteProviderError = "Remote source redirect limit exceeded"
|
||||
errRemoteProviderDownloadFailed remoteProviderError = errPagesPackageURLDownloadFailed
|
||||
errRemoteProviderTooLarge remoteProviderError = errPagesPackageURLTooLarge
|
||||
errRemoteProviderEmpty remoteProviderError = errPagesPackageEmpty
|
||||
errRemoteProviderUnsupported remoteProviderError = errPagesPackageUnsupported
|
||||
errRemoteProviderCleanupFailed remoteProviderError = "清理远程来源临时文件失败"
|
||||
errRemoteProviderCleanupFailed remoteProviderError = "Failed to clean up remote source temporary file"
|
||||
)
|
||||
|
||||
// RemoteSourceRequest describes one immutable Remote URL package fetch.
|
||||
|
||||
@@ -30,8 +30,8 @@ const (
|
||||
var PagesSourceScanMeta = task.TaskMeta{
|
||||
Type: TaskTypePagesSourceScan,
|
||||
AsynqTask: PagesSourceScanTask,
|
||||
Name: "OpenFlare Pages 部署源扫描",
|
||||
Description: "补偿孤儿部署包、恢复过期执行权并串行检查到期的 GitHub latest 部署源",
|
||||
Name: "OpenFlare Pages deployment source scan",
|
||||
Description: "Clean up orphaned archives, recover expired source leases, and check GitHub sources tracking the latest release for updates",
|
||||
SupportsTime: false,
|
||||
MaxRetry: 0,
|
||||
Queue: task.QueueDefault,
|
||||
@@ -125,7 +125,7 @@ func (handler *SourceScanHandler) Execute(ctx context.Context, payload []byte) (
|
||||
summary.OrphanCleanup = orphanSummary
|
||||
task.AppendLog(
|
||||
ctx,
|
||||
"[cleanup] orphan 候选=%d,已补偿=%d,仍被引用=%d,lease busy=%d,非法 marker=%d,跳过=%d,失败=%d",
|
||||
"[cleanup] orphan candidates=%d, compensated=%d, referenced=%d, lease busy=%d, invalid markers=%d, skipped=%d, failed=%d",
|
||||
orphanSummary.Candidates,
|
||||
orphanSummary.Reconciled,
|
||||
orphanSummary.Referenced,
|
||||
@@ -143,7 +143,7 @@ func (handler *SourceScanHandler) Execute(ctx context.Context, payload []byte) (
|
||||
return nil, err
|
||||
}
|
||||
message := fmt.Sprintf(
|
||||
"Pages 部署源扫描完成:恢复 %d 个租约,补偿 %d 个孤儿记录,检查 %d 个来源,投递 %d 个自动更新,积压 %d 个",
|
||||
"Pages source scan completed: recovered %d leases, compensated %d orphan records, checked %d sources, dispatched %d automatic updates, backlog %d",
|
||||
summary.RecoveredLeases,
|
||||
summary.OrphanCleanup.Reconciled,
|
||||
summary.CheckedSources,
|
||||
@@ -195,7 +195,7 @@ func recoverExpiredPagesSourceLeases(
|
||||
}
|
||||
if recovered {
|
||||
summary.RecoveredLeases++
|
||||
task.AppendLog(ctx, "[recover] 已恢复过期来源租约:source_id=%d", candidate.SourceID)
|
||||
task.AppendLog(ctx, "[recover] Recovered expired source lease: source_id=%d", candidate.SourceID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -223,7 +223,7 @@ func scanDueGitHubSources(
|
||||
summary.SelectedSources = len(candidates)
|
||||
task.AppendLog(
|
||||
ctx,
|
||||
"[scan] 到期来源=%d,本批=%d",
|
||||
"[scan] Due sources=%d, batch=%d",
|
||||
summary.DueSources,
|
||||
summary.SelectedSources,
|
||||
)
|
||||
@@ -238,7 +238,7 @@ func scanDueGitHubSources(
|
||||
return err
|
||||
}
|
||||
summary.Backlog = int(remainingDue)
|
||||
task.AppendLog(ctx, "[scan] 本批处理后仍到期来源=%d", summary.Backlog)
|
||||
task.AppendLog(ctx, "[scan] Sources still due after this batch=%d", summary.Backlog)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func scanOneDueGitHubSource(
|
||||
switch outcome {
|
||||
case sourceLeaseBusy:
|
||||
summary.BusySources++
|
||||
task.AppendLog(ctx, "[check] 来源正在执行其它任务,跳过:source_id=%d", candidate.SourceID)
|
||||
task.AppendLog(ctx, "[check] Source is running another task; skipping: source_id=%d", candidate.SourceID)
|
||||
return
|
||||
case sourceLeaseStale:
|
||||
summary.StaleSources++
|
||||
@@ -333,7 +333,7 @@ func recordPagesSourceProviderBackoff(
|
||||
})
|
||||
task.AppendLog(
|
||||
ctx,
|
||||
"[check] GitHub provider 退避:source_id=%d status=%d retry_at=%s",
|
||||
"[check] GitHub provider backoff: source_id=%d status=%d retry_at=%s",
|
||||
sourceID,
|
||||
domainError.statusCode,
|
||||
retryAtText,
|
||||
@@ -369,7 +369,7 @@ func handleCheckedGitHubSource(
|
||||
if receipt != nil {
|
||||
task.AppendLog(
|
||||
ctx,
|
||||
"[dispatch] 已投递自动更新:source_id=%d execution_id=%s revision=%s",
|
||||
"[dispatch] Automatic update dispatched: source_id=%d execution_id=%s revision=%s",
|
||||
snapshot.SourceID,
|
||||
receipt.ExecutionID,
|
||||
checkResult.Revision,
|
||||
|
||||
@@ -224,7 +224,7 @@ func syncRemoteSourceWithTrigger(
|
||||
return &sourceSyncOutcome{Stale: true}, nil
|
||||
}
|
||||
|
||||
task.AppendLog(ctx, "[activate] 正在原子切换生产部署")
|
||||
task.AppendLog(ctx, "[activate] Atomically switching production deployment")
|
||||
deployment, reused, referenced, err := commitSourceDeploymentWithTrigger(
|
||||
ctx,
|
||||
snapshot,
|
||||
@@ -266,7 +266,7 @@ func prepareRemoteSource(
|
||||
snapshot *sourceExecutionSnapshot,
|
||||
limits pagesLimits,
|
||||
) (*preparedRemoteSource, error) {
|
||||
task.AppendLog(ctx, "[download] 正在获取远程部署包")
|
||||
task.AppendLog(ctx, "[download] Fetching remote deployment archive")
|
||||
candidate, err := FetchRemoteSource(ctx, RemoteSourceRequest{
|
||||
URL: snapshot.RemoteURL,
|
||||
AllowInsecure: snapshot.AllowInsecure,
|
||||
@@ -288,7 +288,7 @@ func prepareRemoteSource(
|
||||
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
||||
return nil, err
|
||||
}
|
||||
task.AppendLog(ctx, "[verify] 正在校验归档结构与入口文件")
|
||||
task.AppendLog(ctx, "[verify] Verifying archive structure and entry file")
|
||||
manifest, err := inspectPagesPackage(candidate.TempPath, candidate.Format, rootDir, entryFile, limits)
|
||||
if err != nil {
|
||||
cleanupFailedRemoteCandidate(ctx, snapshot, candidate)
|
||||
@@ -335,7 +335,7 @@ func resolveSourceIngest(
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
task.AppendLog(ctx, "[ingest] 正在保存受管部署包")
|
||||
task.AppendLog(ctx, "[ingest] Saving managed deployment archive")
|
||||
result, err := ingestPagesDeploymentPackageWithSource(
|
||||
ctx,
|
||||
prepared.Candidate.TempPath,
|
||||
@@ -361,7 +361,7 @@ func compensateSourceIngest(
|
||||
}
|
||||
cleanupCtx, cancel := sourceCleanupContext(ctx)
|
||||
defer cancel()
|
||||
task.AppendLog(cleanupCtx, "[cleanup] 正在补偿未引用的部署包记录")
|
||||
task.AppendLog(cleanupCtx, "[cleanup] Compensating unreferenced deployment archive records")
|
||||
if err := removePagesUploadIfUnreferenced(cleanupCtx, snapshot.ProjectID, state.Result.Upload.ID); err != nil {
|
||||
logger.ErrorF(cleanupCtx,
|
||||
"[PagesSource] compensate upload failed: project_id=%d source_id=%d upload_id=%d error=%v",
|
||||
|
||||
@@ -36,8 +36,8 @@ var errUnexpectedJSONTrailingValue = errors.New("unexpected trailing JSON value"
|
||||
var PagesSourceActionMeta = task.TaskMeta{
|
||||
Type: TaskTypePagesSourceAction,
|
||||
AsynqTask: PagesSourceActionTask,
|
||||
Name: "OpenFlare Pages 部署源操作",
|
||||
Description: "检查或同步 Pages 项目部署源",
|
||||
Name: "OpenFlare Pages deployment source operation",
|
||||
Description: "Check or synchronize a Pages project deployment source",
|
||||
SupportsTime: false,
|
||||
MaxRetry: 2,
|
||||
Queue: task.QueueDefault,
|
||||
@@ -138,14 +138,14 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas
|
||||
source, err := repository.GetPagesProjectSourceByID(ctx, input.SourceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
task.AppendLog(ctx, "[resolve] 来源已不存在,本次任务跳过")
|
||||
task.AppendLog(ctx, "[resolve] Source no longer exists; skipping task")
|
||||
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
||||
}
|
||||
logger.ErrorF(ctx, "[PagesSource] load source failed: source_id=%d error=%v", input.SourceID, err)
|
||||
return nil, errors.New(errPagesSourceSyncFailed)
|
||||
}
|
||||
if source.ConfigVersion != input.ConfigVersion {
|
||||
task.AppendLog(ctx, "[resolve] 来源配置已变化,本次任务跳过")
|
||||
task.AppendLog(ctx, "[resolve] Source configuration changed; skipping task")
|
||||
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
||||
}
|
||||
if input.Action == sourceActionCheck && source.SourceType == PagesSourceTypeRemoteURL {
|
||||
@@ -159,7 +159,7 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas
|
||||
return nil, task.PermanentError(errPagesSourceActionInvalid)
|
||||
}
|
||||
|
||||
task.AppendLog(ctx, "[resolve] 正在获取来源执行权")
|
||||
task.AppendLog(ctx, "[resolve] Acquiring source task ownership")
|
||||
snapshot, outcome, err := acquireSourceLease(ctx, input.SourceID, input.ConfigVersion, input.Action)
|
||||
if err != nil {
|
||||
logger.ErrorF(ctx, "[PagesSource] acquire lease failed: source_id=%d error=%v", input.SourceID, err)
|
||||
@@ -167,10 +167,10 @@ func (h *SourceActionHandler) Execute(ctx context.Context, payload []byte) (*tas
|
||||
}
|
||||
switch outcome {
|
||||
case sourceLeaseBusy:
|
||||
task.AppendLog(ctx, "[resolve] 已有来源任务正在执行,本次任务跳过")
|
||||
task.AppendLog(ctx, "[resolve] Another source task is running; skipping task")
|
||||
return &task.TaskResult{Message: errPagesSourceActionBusy}, nil
|
||||
case sourceLeaseStale:
|
||||
task.AppendLog(ctx, "[resolve] 来源配置或执行权已变化,本次任务跳过")
|
||||
task.AppendLog(ctx, "[resolve] Source configuration or ownership changed; skipping task")
|
||||
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
||||
case sourceLeaseAcquired:
|
||||
// 已获取执行权,继续执行。
|
||||
@@ -220,12 +220,12 @@ func executeSourceSyncAction(
|
||||
return nil, errors.New(errPagesSourceSyncFailed)
|
||||
}
|
||||
if result == nil || result.Stale {
|
||||
task.AppendLog(ctx, "[activate] 来源配置或执行权已变化,本次任务未切换部署")
|
||||
task.AppendLog(ctx, "[activate] Source configuration or ownership changed; deployment was not switched")
|
||||
return &task.TaskResult{Message: errPagesSourceActionStale}, nil
|
||||
}
|
||||
message := "Pages 部署源同步并发布成功"
|
||||
message := "Pages deployment source synchronized and published successfully"
|
||||
if result.Reused {
|
||||
message = "Pages 部署源内容未变化,已重新激活现有部署"
|
||||
message = "Pages source content is unchanged; existing deployment reactivated"
|
||||
}
|
||||
return &task.TaskResult{Message: message, Detail: sourceSyncResultDetail(result)}, nil
|
||||
}
|
||||
|
||||
@@ -43,12 +43,12 @@ const (
|
||||
errProxyRouteTunnelAddrReq = "tunnel_target_addr is required for tunnel upstream"
|
||||
errProxyRouteTunnelProtocol = "tunnel_target_protocol must be http or https"
|
||||
errProxyRoutePagesProjectReq = "pages_project_id is required for Pages upstream"
|
||||
errProxyRoutePagesNotFound = "pages 项目不存在"
|
||||
errProxyRoutePagesDisabled = "pages 项目未启用"
|
||||
errProxyRoutePagesNoDeploy = "pages 项目没有激活部署"
|
||||
errProxyRouteOriginSchemeOnly = "源站协议仅支持 http 或 https"
|
||||
errProxyRouteOriginPort = "端口格式不合法"
|
||||
errProxyRouteOriginPortEmpty = "端口不能为空"
|
||||
errProxyRouteOriginURI = "源站路径需以 / 或 ? 开头"
|
||||
errProxyRouteOriginURIProto = "源站路径不能包含协议"
|
||||
errProxyRoutePagesNotFound = "pages project was not found"
|
||||
errProxyRoutePagesDisabled = "pages project is disabled"
|
||||
errProxyRoutePagesNoDeploy = "pages project has no active deployment"
|
||||
errProxyRouteOriginSchemeOnly = "origin protocol must be http or https"
|
||||
errProxyRouteOriginPort = "invalid port format"
|
||||
errProxyRouteOriginPortEmpty = "port must not be empty"
|
||||
errProxyRouteOriginURI = "origin path must start with / or ?"
|
||||
errProxyRouteOriginURIProto = "origin path must not contain a protocol"
|
||||
)
|
||||
|
||||
@@ -361,7 +361,7 @@ func normalizeProxyRouteLimitReqPerIP(raw string) (string, error) {
|
||||
return "-1", nil
|
||||
}
|
||||
if !proxyRouteLimitReqPattern.MatchString(normalized) {
|
||||
return "", errors.New("请求频率格式不合法,请使用类似 10r/s、100r/m,或 -1 禁用")
|
||||
return "", errors.New("invalid request rate; use a rate such as 10r/s or 100r/m, or -1 to disable")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@ package relay
|
||||
|
||||
const (
|
||||
//nolint:gosec // error message text, not a credential
|
||||
errAgentTokenInvalid = "无权进行此操作,Agent Token 无效"
|
||||
errRelayNodeTypeMismatch = "此节点不是 TunnelRelay 类型"
|
||||
errAgentTokenInvalid = "Operation not authorized: invalid agent token"
|
||||
errRelayNodeTypeMismatch = "This node is not a TunnelRelay"
|
||||
)
|
||||
|
||||
@@ -40,11 +40,11 @@ type LogDBSwitchHandler struct{}
|
||||
func (h *LogDBSwitchHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||
var p logDBSwitchPayload
|
||||
if err := json.Unmarshal(payload, &p); err != nil {
|
||||
return nil, fmt.Errorf("参数解析失败: %w", err)
|
||||
return nil, fmt.Errorf("failed to parse parameters: %w", err)
|
||||
}
|
||||
p.Target = normalizeTarget(p.Target)
|
||||
if !validTarget(p.Target) {
|
||||
return nil, fmt.Errorf("目标日志库不合法: %s", p.Target)
|
||||
return nil, fmt.Errorf("invalid target log database: %s", p.Target)
|
||||
}
|
||||
out, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
@@ -73,7 +73,7 @@ func validTarget(v string) bool {
|
||||
func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
||||
var p logDBSwitchPayload
|
||||
if err := json.Unmarshal(payload, &p); err != nil {
|
||||
return nil, fmt.Errorf("参数解析失败: %w", err)
|
||||
return nil, fmt.Errorf("failed to parse parameters: %w", err)
|
||||
}
|
||||
p.Target = normalizeTarget(p.Target)
|
||||
if err := validateSwitch(ctx, p.Target); err != nil {
|
||||
@@ -82,10 +82,10 @@ func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*task
|
||||
|
||||
source, err := currentLogDatabase(ctx)
|
||||
if err != nil {
|
||||
task.AppendLog(ctx, "读取日志主库失败: %v", err)
|
||||
task.AppendLog(ctx, "Failed to read log database: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
task.AppendLog(ctx, "开始切换日志数据库:%s -> %s", source, p.Target)
|
||||
task.AppendLog(ctx, "Starting log database migration: %s -> %s", source, p.Target)
|
||||
|
||||
// 设置迁移冻结标记(置位后由 ensureWritable 拒绝新写入)。
|
||||
if err := setMigrationFlag(ctx, "migrating"); err != nil {
|
||||
@@ -94,14 +94,14 @@ func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*task
|
||||
// 失败也清除(SaveOrUpdateSystemConfig 会失效 RAM 缓存并广播),保持源库可写。
|
||||
defer func() {
|
||||
if err := setMigrationFlag(ctx, ""); err != nil {
|
||||
logger.ErrorF(ctx, "清除日志迁移冻结标记失败: %v", err)
|
||||
logger.ErrorF(ctx, "Failed to clear log migration freeze marker: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// 冻结标记置位后再排空在途批次(chwriter + 用户访问日志 writer),
|
||||
// 保证排空完成后不再有新批次进入源库。
|
||||
if err := drainLogWriters(ctx); err != nil {
|
||||
return nil, fmt.Errorf("排空日志写入队列失败: %w", err)
|
||||
return nil, fmt.Errorf("failed to drain log write queue: %w", err)
|
||||
}
|
||||
|
||||
src, err := logstore.Active(ctx)
|
||||
@@ -138,8 +138,8 @@ func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*task
|
||||
if err := flipLogDatabase(ctx, p.Target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task.AppendLog(ctx, "日志数据库已切换为 %s,写入恢复", p.Target)
|
||||
return &task.TaskResult{Message: fmt.Sprintf("日志数据库已从 %s 切换为 %s", source, p.Target)}, nil
|
||||
task.AppendLog(ctx, "Log database switched to %s; writes resumed", p.Target)
|
||||
return &task.TaskResult{Message: fmt.Sprintf("Log database switched from %s to %s", source, p.Target)}, nil
|
||||
}
|
||||
|
||||
func validateSwitch(ctx context.Context, target string) error {
|
||||
@@ -148,20 +148,20 @@ func validateSwitch(ctx context.Context, target string) error {
|
||||
return err
|
||||
}
|
||||
if source == target {
|
||||
return errors.New("目标日志库与当前日志库相同,无需迁移")
|
||||
return errors.New("target log database is already active; no migration is required")
|
||||
}
|
||||
switch target {
|
||||
case "clickhouse":
|
||||
if !config.Config.ClickHouse.Enabled {
|
||||
return errors.New("ClickHouse 未启用,无法迁移到 ClickHouse")
|
||||
return errors.New("ClickHouse is disabled; migration to ClickHouse is unavailable")
|
||||
}
|
||||
case "postgres":
|
||||
if !config.Config.Database.Enabled {
|
||||
return errors.New("PostgreSQL 未启用(当前主库为 SQLite),无法迁移到 PostgreSQL")
|
||||
return errors.New("PostgreSQL is disabled and SQLite is the primary database; migration to PostgreSQL is unavailable")
|
||||
}
|
||||
case "sqlite":
|
||||
if config.Config.Database.Enabled {
|
||||
return errors.New("当前主库为 PostgreSQL,日志库不能设置为 SQLite")
|
||||
return errors.New("PostgreSQL is the primary database; SQLite cannot be selected for log storage")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -170,10 +170,10 @@ func validateSwitch(ctx context.Context, target string) error {
|
||||
func currentLogDatabase(ctx context.Context) (string, error) {
|
||||
cfg, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyLogDatabase)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取日志主库失败: %w", err)
|
||||
return "", fmt.Errorf("failed to read log database: %w", err)
|
||||
}
|
||||
if cfg.Value == "" {
|
||||
return "", errors.New("日志主库配置为空")
|
||||
return "", errors.New("log database configuration is empty")
|
||||
}
|
||||
return cfg.Value, nil
|
||||
}
|
||||
@@ -205,10 +205,10 @@ func clearTargetLogTables(ctx context.Context, dst *logstore.Store) error {
|
||||
// 依次清空 6 张表:AccessLogs.DeleteAll、UserAccessLogs.DeleteAll、Observability.DeleteAll*
|
||||
// (SQLite/PG 用 DeleteAll;CH 用 TRUNCATE 语义)。
|
||||
if _, err := dst.AccessLogs.DeleteAll(ctx); err != nil {
|
||||
return fmt.Errorf("清空目标访问日志失败: %w", err)
|
||||
return fmt.Errorf("failed to clear target access logs: %w", err)
|
||||
}
|
||||
if _, err := dst.UserAccessLogs.DeleteAll(ctx); err != nil {
|
||||
return fmt.Errorf("清空目标用户访问日志失败: %w", err)
|
||||
return fmt.Errorf("failed to clear target user access logs: %w", err)
|
||||
}
|
||||
for _, fn := range []func(context.Context) (int64, error){
|
||||
dst.Observability.DeleteAllMetricSnapshots,
|
||||
@@ -234,13 +234,13 @@ func ensureTargetPartitions(ctx context.Context, src, dst *logstore.Store, targe
|
||||
return err
|
||||
}
|
||||
if from.IsZero() || to.IsZero() {
|
||||
task.AppendLog(ctx, "源库无日志数据,跳过分区预建")
|
||||
task.AppendLog(ctx, "Source database contains no logs; skipping partition precreation")
|
||||
return nil
|
||||
}
|
||||
if err := dst.AccessLogs.EnsurePartitions(ctx, from, to.AddDate(0, 1, 0)); err != nil {
|
||||
return fmt.Errorf("预建目标 PG 分区失败: %w", err)
|
||||
return fmt.Errorf("failed to precreate target PostgreSQL partitions: %w", err)
|
||||
}
|
||||
task.AppendLog(ctx, "已为目标 PG 预建分区 %s ~ %s", from.Format("2006-01"), to.Format("2006-01"))
|
||||
task.AppendLog(ctx, "Precreated target PostgreSQL partitions from %s to %s", from.Format("2006-01"), to.Format("2006-01"))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -249,11 +249,11 @@ func ensureTargetPartitions(ctx context.Context, src, dst *logstore.Store, targe
|
||||
func migrationRange(ctx context.Context, src *logstore.Store) (time.Time, time.Time, error) {
|
||||
fromAccess, toAccess, err := src.AccessLogs.MigrationRange(ctx)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("读取源访问日志时间范围失败: %w", err)
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("failed to read source access log time range: %w", err)
|
||||
}
|
||||
fromUser, toUser, err := src.UserAccessLogs.MigrationRange(ctx)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("读取源用户访问日志时间范围失败: %w", err)
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("failed to read source user access log time range: %w", err)
|
||||
}
|
||||
return minTime(fromAccess, fromUser), maxTime(toAccess, toUser), nil
|
||||
}
|
||||
@@ -297,9 +297,9 @@ func copyAccessLogs(ctx context.Context, src, dst *logstore.Store) error {
|
||||
break
|
||||
}
|
||||
if err := dst.AccessLogs.BatchInsertNodeAccessLogs(ctx, rows); err != nil {
|
||||
return fmt.Errorf("写入目标访问日志失败(批 %d): %w", lastID, err)
|
||||
return fmt.Errorf("failed to write target access logs (batch %d): %w", lastID, err)
|
||||
}
|
||||
task.AppendLog(ctx, "已复制访问日志 %d 条(截至 id=%d)", len(rows), rows[len(rows)-1].ID)
|
||||
task.AppendLog(ctx, "Copied %d access logs (through id=%d)", len(rows), rows[len(rows)-1].ID)
|
||||
lastID = rows[len(rows)-1].ID
|
||||
if len(rows) < copyBatchSize {
|
||||
break
|
||||
@@ -324,10 +324,10 @@ func copyUserAccessLogs(ctx context.Context, src, dst *logstore.Store) error {
|
||||
return nil
|
||||
}
|
||||
if err := dst.UserAccessLogs.BatchInsert(ctx, rows); err != nil {
|
||||
return fmt.Errorf("写入目标用户访问日志失败(批 %d): %w", lastID, err)
|
||||
return fmt.Errorf("failed to write target user access logs (batch %d): %w", lastID, err)
|
||||
}
|
||||
lastID = rows[len(rows)-1].ID
|
||||
task.AppendLog(ctx, "已复制用户访问日志 %d 条(截至 id=%d)", len(rows), lastID)
|
||||
task.AppendLog(ctx, "Copied %d user access logs (through id=%d)", len(rows), lastID)
|
||||
if len(rows) < copyBatchSize {
|
||||
return nil
|
||||
}
|
||||
@@ -374,16 +374,16 @@ func copyObsTable[T any](ctx context.Context, name string,
|
||||
for {
|
||||
rows, err := list(ctx, lastID, copyBatchSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("复制 %s 失败: %w", name, err)
|
||||
return fmt.Errorf("failed to copy %s: %w", name, err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := insert(ctx, rows); err != nil {
|
||||
return fmt.Errorf("复制 %s 失败: %w", name, err)
|
||||
return fmt.Errorf("failed to copy %s: %w", name, err)
|
||||
}
|
||||
lastID = idOf(rows)
|
||||
task.AppendLog(ctx, "已复制 %s %d 条(截至 id=%d)", name, len(rows), lastID)
|
||||
task.AppendLog(ctx, "Copied %s: %d records (through id=%d)", name, len(rows), lastID)
|
||||
if len(rows) < copyBatchSize {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -231,21 +231,21 @@ func TestValidateSwitch(t *testing.T) {
|
||||
config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = false, false
|
||||
err := validateSwitch(ctx, "sqlite")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "相同")
|
||||
assert.Contains(t, err.Error(), "already active")
|
||||
})
|
||||
t.Run("clickhouse disabled rejected", func(t *testing.T) {
|
||||
setLogDB("sqlite")
|
||||
config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = false, false
|
||||
err := validateSwitch(ctx, "clickhouse")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "ClickHouse 未启用")
|
||||
assert.Contains(t, err.Error(), "ClickHouse is disabled")
|
||||
})
|
||||
t.Run("postgres requires main db enabled", func(t *testing.T) {
|
||||
setLogDB("sqlite")
|
||||
config.Config.Database.Enabled, config.Config.ClickHouse.Enabled = false, false
|
||||
err := validateSwitch(ctx, "postgres")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "PostgreSQL 未启用")
|
||||
assert.Contains(t, err.Error(), "PostgreSQL is disabled")
|
||||
})
|
||||
t.Run("sqlite rejected when main db is postgres", func(t *testing.T) {
|
||||
setLogDB("postgres")
|
||||
|
||||
@@ -15,7 +15,7 @@ const (
|
||||
errCertificateAlreadyApplying = "certificate is already applying"
|
||||
errCertificateOnlyACMERenew = "only acme certificates can be renewed"
|
||||
errCertificateFilesRequired = "certificate file and key file cannot be empty"
|
||||
errCertificatePEMInvalid = "证书 PEM 内容不合法"
|
||||
errCertificatePEMInvalid = "invalid certificate PEM content"
|
||||
|
||||
errDNSAccountInUse = "该 DNS 账号已被证书使用,无法删除"
|
||||
errDNSAccountInUse = "this DNS account is used by certificates and cannot be deleted"
|
||||
)
|
||||
|
||||
@@ -329,7 +329,7 @@ func CreateDNSAccount(ctx context.Context, input DNSAccountInput) (*model.DNSAcc
|
||||
Authorization: authorization,
|
||||
}
|
||||
if account.Name == "" || account.Type == "" || authorization == "" {
|
||||
return nil, errors.New("DNS 账号参数不完整")
|
||||
return nil, errors.New("DNS account parameters are incomplete")
|
||||
}
|
||||
if err := repository.CreateDNSAccountRecord(ctx, account); err != nil {
|
||||
return nil, err
|
||||
@@ -351,7 +351,7 @@ func UpdateDNSAccount(ctx context.Context, id uint, input DNSAccountInput) (*mod
|
||||
account.Type = strings.TrimSpace(input.Type)
|
||||
account.Authorization = authorization
|
||||
if account.Name == "" || account.Type == "" || authorization == "" {
|
||||
return nil, errors.New("DNS 账号参数不完整")
|
||||
return nil, errors.New("DNS account parameters are incomplete")
|
||||
}
|
||||
if err := repository.SaveDNSAccount(ctx, account); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -35,25 +35,25 @@ func SetObtainCertificateFuncForTest(fn func(context.Context, *model.TLSCertific
|
||||
}
|
||||
|
||||
func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error {
|
||||
task.AppendLog(ctx, "【续签任务】开始续签,设置申请状态为 applying...")
|
||||
task.AppendLog(ctx, "[Renewal] Starting renewal; setting application status to applying...")
|
||||
cert.ApplyStatus = tlsApplyStatusApplying
|
||||
if err := repository.SaveTLSCertificate(ctx, cert); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task.AppendLog(ctx, "【续签任务】正在解析 ACME 账户...")
|
||||
task.AppendLog(ctx, "[Renewal] Resolving ACME account...")
|
||||
acmeAccount, err := resolveAcmeAccount(ctx, cert)
|
||||
if err != nil {
|
||||
return updateCertError(ctx, cert, fmt.Sprintf("Failed to get ACME account: %v", err))
|
||||
}
|
||||
|
||||
task.AppendLog(ctx, "【续签任务】正在解析 DNS 账户信息 (ID=%d)...", cert.DNSAccountID)
|
||||
task.AppendLog(ctx, "[Renewal] Resolving DNS account information (ID=%d)...", cert.DNSAccountID)
|
||||
dnsAccount, err := repository.GetDNSAccountByID(ctx, cert.DNSAccountID)
|
||||
if err != nil {
|
||||
return updateCertError(ctx, cert, fmt.Sprintf("Failed to get DNS account: %v", err))
|
||||
}
|
||||
|
||||
task.AppendLog(ctx, "【续签任务】正在解密 DNS 账号凭据及 ACME 账户私钥...")
|
||||
task.AppendLog(ctx, "[Renewal] Decrypting DNS account credentials and ACME account private key...")
|
||||
dnsAuth, err := openSensitive(dnsAccount.Authorization)
|
||||
if err != nil {
|
||||
return updateCertError(ctx, cert, fmt.Sprintf("Failed to decrypt DNS credentials: %v", err))
|
||||
@@ -65,9 +65,9 @@ func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error {
|
||||
}
|
||||
|
||||
domains := splitAcmeDomains(cert.PrimaryDomain, cert.OtherDomains)
|
||||
task.AppendLog(ctx, "【续签任务】待申请的域名列表: %v", domains)
|
||||
task.AppendLog(ctx, "[Renewal] Requested domains: %v", domains)
|
||||
|
||||
task.AppendLog(ctx, "【续签任务】正在调用 ACME 客户端(通过 DNS-01 挑战)发起 SSL 证书签发请求,请稍候...")
|
||||
task.AppendLog(ctx, "[Renewal] Requesting SSL certificate issuance through the ACME DNS-01 challenge; please wait...")
|
||||
newAccountURL, newPrivateKeyPEM, result, err := acme.ObtainSSL(
|
||||
acmeAccount.Email,
|
||||
acmePrivateKey,
|
||||
@@ -82,7 +82,7 @@ func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error {
|
||||
domains,
|
||||
)
|
||||
|
||||
task.AppendLog(ctx, "【续签任务】正在保存 ACME 账户可能的变更...")
|
||||
task.AppendLog(ctx, "[Renewal] Saving ACME account changes...")
|
||||
if err := persistAcmeAccountUpdates(ctx, cert, acmeAccount, newAccountURL, newPrivateKeyPEM, acmePrivateKey); err != nil {
|
||||
return updateCertError(ctx, cert, err.Error())
|
||||
}
|
||||
@@ -91,11 +91,11 @@ func obtainCertificate(ctx context.Context, cert *model.TLSCertificate) error {
|
||||
return updateCertError(ctx, cert, err.Error())
|
||||
}
|
||||
|
||||
task.AppendLog(ctx, "【续签任务】证书签发成功,正在将证书内容与私钥安全写入数据库...")
|
||||
task.AppendLog(ctx, "[Renewal] Certificate issued; securely storing certificate and private key in the database...")
|
||||
if err := saveObtainedCertificate(ctx, cert, result); err != nil {
|
||||
return updateCertError(ctx, cert, err.Error())
|
||||
}
|
||||
task.AppendLog(ctx, "【续签任务】证书数据存储完成!")
|
||||
task.AppendLog(ctx, "[Renewal] Certificate data saved")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ func handleLogicError(c *gin.Context, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return apiutil.AbortNotFoundIfMissing(c, err, "记录不存在")
|
||||
return apiutil.AbortNotFoundIfMissing(c, err, "Record was not found")
|
||||
}
|
||||
|
||||
// GetCertificates 列出 TLS 证书。
|
||||
@@ -170,12 +170,12 @@ func ImportCertificateFile(c *gin.Context) {
|
||||
remark := c.PostForm("remark")
|
||||
certFile, err := c.FormFile("cert_file")
|
||||
if err != nil {
|
||||
response.AbortBadRequest(c, "缺少证书文件")
|
||||
response.AbortBadRequest(c, "Missing certificate file")
|
||||
return
|
||||
}
|
||||
keyFile, err := c.FormFile("key_file")
|
||||
if err != nil {
|
||||
response.AbortBadRequest(c, "缺少私钥文件")
|
||||
response.AbortBadRequest(c, "Missing private key file")
|
||||
return
|
||||
}
|
||||
certificate, err := CreateCertificateFromFiles(c.Request.Context(), name, certFile, keyFile, remark)
|
||||
|
||||
@@ -25,8 +25,8 @@ const (
|
||||
var SSLSingleRenewMeta = task.TaskMeta{
|
||||
Type: TaskTypeSSLSingleRenew,
|
||||
AsynqTask: SSLSingleRenewTask,
|
||||
Name: "OpenFlare 单证书 SSL 续期",
|
||||
Description: "对单个指定的 ACME 证书执行续期",
|
||||
Name: "OpenFlare single-certificate SSL renewal",
|
||||
Description: "Renew a single specified ACME certificate",
|
||||
SupportsTime: false,
|
||||
MaxRetry: task.DefaultMaxRetry,
|
||||
Queue: task.QueueDefault,
|
||||
@@ -34,11 +34,11 @@ var SSLSingleRenewMeta = task.TaskMeta{
|
||||
Params: []task.TaskParam{
|
||||
{
|
||||
Name: "id",
|
||||
Label: "证书 ID",
|
||||
Label: "Certificate ID",
|
||||
Type: "number",
|
||||
Required: true,
|
||||
Placeholder: "请输入证书 ID",
|
||||
Description: "待续期的 TLS 证书 ID",
|
||||
Placeholder: "Enter the certificate ID",
|
||||
Description: "ID of the TLS certificate to renew",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -54,16 +54,16 @@ type SSLSingleRenewHandler struct{}
|
||||
// ValidatePayload validates and normalizes the task payload.
|
||||
func (h *SSLSingleRenewHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, errors.New("任务参数不能为空")
|
||||
return nil, errors.New("task parameters must not be empty")
|
||||
}
|
||||
|
||||
var req SSLSingleRenewPayload
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
return nil, fmt.Errorf("无效的 JSON 格式: %w", err)
|
||||
return nil, fmt.Errorf("invalid JSON format: %w", err)
|
||||
}
|
||||
|
||||
if req.ID == 0 {
|
||||
return nil, errors.New("证书 ID 不能为空或零")
|
||||
return nil, errors.New("certificate ID must be nonzero")
|
||||
}
|
||||
|
||||
return json.Marshal(req)
|
||||
@@ -73,29 +73,29 @@ func (h *SSLSingleRenewHandler) ValidatePayload(payload []byte) ([]byte, error)
|
||||
func (h *SSLSingleRenewHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
||||
var req SSLSingleRenewPayload
|
||||
if err := json.Unmarshal(payload, &req); err != nil {
|
||||
return nil, fmt.Errorf("解析任务参数: %w", err)
|
||||
return nil, fmt.Errorf("parse task parameters: %w", err)
|
||||
}
|
||||
|
||||
task.AppendLog(ctx, "开始续期证书,ID: %d", req.ID)
|
||||
task.AppendLog(ctx, "Starting certificate renewal, ID: %d", req.ID)
|
||||
|
||||
cert, err := repository.GetTLSCertificateByID(ctx, req.ID)
|
||||
if err != nil {
|
||||
task.AppendLog(ctx, "获取证书记录失败 ID=%d: %v", req.ID, err)
|
||||
return nil, fmt.Errorf("获取证书记录失败: %w", err)
|
||||
task.AppendLog(ctx, "Failed to get certificate record ID=%d: %v", req.ID, err)
|
||||
return nil, fmt.Errorf("failed to get certificate record: %w", err)
|
||||
}
|
||||
|
||||
if cert.Provider != tlsProviderACME {
|
||||
task.AppendLog(ctx, "证书 %s (ID=%d) 不是 ACME 托管证书,无法自动续期 (Provider: %s)", cert.PrimaryDomain, req.ID, cert.Provider)
|
||||
return nil, fmt.Errorf("证书 %s 不是 ACME 托管证书,无法自动续期", cert.PrimaryDomain)
|
||||
task.AppendLog(ctx, "Certificate %s (ID=%d) is not ACME-managed and cannot be renewed automatically (provider: %s)", cert.PrimaryDomain, req.ID, cert.Provider)
|
||||
return nil, fmt.Errorf("certificate %s is not ACME-managed and cannot be renewed automatically", cert.PrimaryDomain)
|
||||
}
|
||||
|
||||
task.AppendLog(ctx, "准备为域名 [%s] 申请/续期证书", cert.PrimaryDomain)
|
||||
task.AppendLog(ctx, "Preparing to issue or renew a certificate for domains [%s]", cert.PrimaryDomain)
|
||||
if err := obtainTLSCertificate(ctx, cert); err != nil {
|
||||
task.AppendLog(ctx, "申请证书失败: %v", err)
|
||||
task.AppendLog(ctx, "Certificate issuance failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("证书 %s 续签成功", cert.PrimaryDomain)
|
||||
msg := fmt.Sprintf("Certificate %s renewed successfully", cert.PrimaryDomain)
|
||||
task.AppendLog(ctx, "%s", msg)
|
||||
return &task.TaskResult{Message: msg}, nil
|
||||
}
|
||||
|
||||
@@ -27,19 +27,19 @@ func TestSSLSingleRenewHandler_ValidatePayload(t *testing.T) {
|
||||
name: "empty payload",
|
||||
payload: nil,
|
||||
wantErr: true,
|
||||
errMsg: "任务参数不能为空",
|
||||
errMsg: "task parameters must not be empty",
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
payload: []byte(`{`),
|
||||
wantErr: true,
|
||||
errMsg: "无效的 JSON 格式",
|
||||
errMsg: "invalid JSON format",
|
||||
},
|
||||
{
|
||||
name: "zero ID",
|
||||
payload: []byte(`{"id":0}`),
|
||||
wantErr: true,
|
||||
errMsg: "证书 ID 不能为空或零",
|
||||
errMsg: "certificate ID must be nonzero",
|
||||
},
|
||||
{
|
||||
name: "valid ID",
|
||||
@@ -84,7 +84,7 @@ func TestSSLSingleRenewHandler_Execute(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
_, err = handler.Execute(ctx, payload)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "获取证书记录失败")
|
||||
assert.Contains(t, err.Error(), "failed to get certificate record")
|
||||
})
|
||||
|
||||
t.Run("certificate provider is not ACME", func(t *testing.T) {
|
||||
@@ -101,7 +101,7 @@ func TestSSLSingleRenewHandler_Execute(t *testing.T) {
|
||||
|
||||
_, err = handler.Execute(ctx, payload)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "不是 ACME 托管证书")
|
||||
assert.Contains(t, err.Error(), "is not ACME-managed")
|
||||
})
|
||||
|
||||
t.Run("successful renewal", func(t *testing.T) {
|
||||
@@ -125,7 +125,7 @@ func TestSSLSingleRenewHandler_Execute(t *testing.T) {
|
||||
|
||||
res, err := handler.Execute(ctx, payload)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, res.Message, "续签成功")
|
||||
assert.Contains(t, res.Message, "renewed successfully")
|
||||
|
||||
updated, err := repository.GetTLSCertificateByID(ctx, cert.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -32,32 +32,32 @@ func CompileRuleGraph(graph RuleGraph) (RuntimeRuleGraph, error) {
|
||||
for _, node := range graph.Nodes {
|
||||
config, err := compileRuleNodeConfig(node)
|
||||
if err != nil {
|
||||
return RuntimeRuleGraph{}, fmt.Errorf("节点 %s 的配置无法编译: %w", node.ID, err)
|
||||
return RuntimeRuleGraph{}, fmt.Errorf("unable to compile configuration for node %s: %w", node.ID, err)
|
||||
}
|
||||
if node.Type == RuleNodeStart {
|
||||
if runtime.Entry != "" {
|
||||
return RuntimeRuleGraph{}, errors.New("规则图包含多个开始节点")
|
||||
return RuntimeRuleGraph{}, errors.New("rule graph contains multiple start nodes")
|
||||
}
|
||||
runtime.Entry = node.ID
|
||||
}
|
||||
runtime.Nodes[node.ID] = RuntimeRuleNode{Type: node.Type, Config: config}
|
||||
}
|
||||
if runtime.Entry == "" {
|
||||
return RuntimeRuleGraph{}, errors.New("规则图缺少开始节点")
|
||||
return RuntimeRuleGraph{}, errors.New("rule graph is missing a start node")
|
||||
}
|
||||
for _, edge := range graph.Edges {
|
||||
node, ok := runtime.Nodes[edge.Source]
|
||||
if !ok {
|
||||
return RuntimeRuleGraph{}, fmt.Errorf("边 %s 的源节点 %s 不存在", edge.ID, edge.Source)
|
||||
return RuntimeRuleGraph{}, fmt.Errorf("edge %s references missing source node %s", edge.ID, edge.Source)
|
||||
}
|
||||
if _, ok := runtime.Nodes[edge.Target]; !ok {
|
||||
return RuntimeRuleGraph{}, fmt.Errorf("边 %s 的目标节点 %s 不存在", edge.ID, edge.Target)
|
||||
return RuntimeRuleGraph{}, fmt.Errorf("edge %s references missing target node %s", edge.ID, edge.Target)
|
||||
}
|
||||
if node.Next == nil {
|
||||
node.Next = make(map[string]string)
|
||||
}
|
||||
if _, exists := node.Next[edge.SourceHandle]; exists {
|
||||
return RuntimeRuleGraph{}, fmt.Errorf("节点 %s 的 %s 出口连接了多个目标", edge.Source, edge.SourceHandle)
|
||||
return RuntimeRuleGraph{}, fmt.Errorf("node %s output %s is connected to multiple targets", edge.Source, edge.SourceHandle)
|
||||
}
|
||||
node.Next[edge.SourceHandle] = edge.Target
|
||||
runtime.Nodes[edge.Source] = node
|
||||
@@ -109,7 +109,7 @@ func compileRuleNodeConfig(node RuleNode) (any, error) {
|
||||
var config BlockNodeConfig
|
||||
return config, decodeStrictConfig(node.Config, &config)
|
||||
default:
|
||||
return nil, fmt.Errorf("未知节点类型 %s", node.Type)
|
||||
return nil, fmt.Errorf("unknown node type %s", node.Type)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ func ValidateRuleGraph(ctx context.Context, graph RuleGraph, ipGroupExists func(
|
||||
return err
|
||||
}
|
||||
if hasRuleGraphCycle(nodes, outgoing, incoming) {
|
||||
return errors.New("规则图不能包含循环")
|
||||
return errors.New("rule graph must not contain cycles")
|
||||
}
|
||||
if err := validateRequiredHandles(graph.Nodes, handleTargets); err != nil {
|
||||
return err
|
||||
@@ -57,18 +57,18 @@ func ValidateRuleGraph(ctx context.Context, graph RuleGraph, ipGroupExists func(
|
||||
|
||||
func validateRuleGraphLimits(graph RuleGraph) error {
|
||||
if graph.SchemaVersion != RuleGraphSchemaVersion {
|
||||
return fmt.Errorf("规则图 schema_version 必须为 %d", RuleGraphSchemaVersion)
|
||||
return fmt.Errorf("rule graph schema_version must be %d", RuleGraphSchemaVersion)
|
||||
}
|
||||
if len(graph.Nodes) > maxRuleGraphNodes {
|
||||
return fmt.Errorf("规则图节点数不能超过 %d", maxRuleGraphNodes)
|
||||
return fmt.Errorf("rule graph must not contain more than %d nodes", maxRuleGraphNodes)
|
||||
}
|
||||
if len(graph.Edges) > maxRuleGraphEdges {
|
||||
return fmt.Errorf("规则图边数不能超过 %d", maxRuleGraphEdges)
|
||||
return fmt.Errorf("rule graph must not contain more than %d edges", maxRuleGraphEdges)
|
||||
}
|
||||
if raw, err := json.Marshal(graph); err != nil {
|
||||
return fmt.Errorf("规则图无法序列化: %w", err)
|
||||
return fmt.Errorf("unable to serialize rule graph: %w", err)
|
||||
} else if len(raw) > maxRuleGraphBytes {
|
||||
return errors.New("规则图大小不能超过 256 KiB")
|
||||
return errors.New("rule graph must not exceed 256 KiB")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -78,10 +78,10 @@ func validateRuleGraphNodes(ctx context.Context, graphNodes []RuleNode, ipGroupE
|
||||
startCount, allowCount, startID := 0, 0, ""
|
||||
for _, node := range graphNodes {
|
||||
if strings.TrimSpace(node.ID) == "" {
|
||||
return nil, "", errors.New("节点 ID 不能为空")
|
||||
return nil, "", errors.New("node ID must not be empty")
|
||||
}
|
||||
if _, exists := nodes[node.ID]; exists {
|
||||
return nil, "", fmt.Errorf("节点 ID %s 重复", node.ID)
|
||||
return nil, "", fmt.Errorf("duplicate node ID %s", node.ID)
|
||||
}
|
||||
nodes[node.ID] = node
|
||||
switch node.Type {
|
||||
@@ -92,17 +92,17 @@ func validateRuleGraphNodes(ctx context.Context, graphNodes []RuleNode, ipGroupE
|
||||
allowCount++
|
||||
case RuleNodeBlock, RuleNodeIPMatch, RuleNodeGeoMatch, RuleNodePoW, RuleNodeUACheck, RuleNodeSecurityCheck:
|
||||
default:
|
||||
return nil, "", fmt.Errorf("节点 %s 的类型 %s 未知", node.ID, node.Type)
|
||||
return nil, "", fmt.Errorf("node %s has unknown type %s", node.ID, node.Type)
|
||||
}
|
||||
if err := validateRuleNodeConfig(ctx, node, ipGroupExists); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
if startCount != 1 {
|
||||
return nil, "", errors.New("规则图必须恰好包含一个开始节点")
|
||||
return nil, "", errors.New("rule graph must contain exactly one start node")
|
||||
}
|
||||
if allowCount != 1 {
|
||||
return nil, "", errors.New("规则图必须恰好包含一个通过节点")
|
||||
return nil, "", errors.New("rule graph must contain exactly one allow node")
|
||||
}
|
||||
return nodes, startID, nil
|
||||
}
|
||||
@@ -114,26 +114,26 @@ func validateRuleGraphEdges(nodes map[string]RuleNode, graphEdges []RuleEdge) (m
|
||||
handleTargets := make(map[string]int)
|
||||
for _, edge := range graphEdges {
|
||||
if strings.TrimSpace(edge.ID) == "" {
|
||||
return nil, nil, nil, errors.New("边 ID 不能为空")
|
||||
return nil, nil, nil, errors.New("edge ID must not be empty")
|
||||
}
|
||||
if _, exists := edgeIDs[edge.ID]; exists {
|
||||
return nil, nil, nil, fmt.Errorf("边 ID %s 重复", edge.ID)
|
||||
return nil, nil, nil, fmt.Errorf("duplicate edge ID %s", edge.ID)
|
||||
}
|
||||
edgeIDs[edge.ID] = struct{}{}
|
||||
source, ok := nodes[edge.Source]
|
||||
if !ok {
|
||||
return nil, nil, nil, fmt.Errorf("边 %s 的源节点 %s 不存在", edge.ID, edge.Source)
|
||||
return nil, nil, nil, fmt.Errorf("edge %s references missing source node %s", edge.ID, edge.Source)
|
||||
}
|
||||
if _, ok := nodes[edge.Target]; !ok {
|
||||
return nil, nil, nil, fmt.Errorf("边 %s 的目标节点 %s 不存在", edge.ID, edge.Target)
|
||||
return nil, nil, nil, fmt.Errorf("edge %s references missing target node %s", edge.ID, edge.Target)
|
||||
}
|
||||
if !validSourceHandle(source.Type, edge.SourceHandle) {
|
||||
return nil, nil, nil, fmt.Errorf("边 %s 的源端口 %s 不适用于节点 %s", edge.ID, edge.SourceHandle, edge.Source)
|
||||
return nil, nil, nil, fmt.Errorf("edge %s source port %s is not valid for node %s", edge.ID, edge.SourceHandle, edge.Source)
|
||||
}
|
||||
key := edge.Source + "\x00" + edge.SourceHandle
|
||||
handleTargets[key]++
|
||||
if handleTargets[key] > 1 {
|
||||
return nil, nil, nil, fmt.Errorf("节点 %s 的 %s 出口连接了多个目标", edge.Source, edge.SourceHandle)
|
||||
return nil, nil, nil, fmt.Errorf("node %s output %s is connected to multiple targets", edge.Source, edge.SourceHandle)
|
||||
}
|
||||
outgoing[edge.Source] = append(outgoing[edge.Source], edge)
|
||||
incoming[edge.Target]++
|
||||
@@ -145,7 +145,7 @@ func validateRequiredHandles(nodes []RuleNode, handleTargets map[string]int) err
|
||||
for _, node := range nodes {
|
||||
for _, handle := range requiredHandles(node.Type) {
|
||||
if handleTargets[node.ID+"\x00"+handle] == 0 {
|
||||
return fmt.Errorf("节点 %s 的 %s 出口未连接", node.ID, handle)
|
||||
return fmt.Errorf("node %s output %s is not connected", node.ID, handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,18 +156,18 @@ func validateRuleGraphConnectivity(nodes []RuleNode, startID string, outgoing ma
|
||||
reachable := walkRuleGraph(startID, outgoing)
|
||||
for _, node := range nodes {
|
||||
if !reachable[node.ID] {
|
||||
return fmt.Errorf("节点 %s 无法从开始节点到达", node.ID)
|
||||
return fmt.Errorf("node %s is not reachable from the start node", node.ID)
|
||||
}
|
||||
}
|
||||
for _, node := range nodes {
|
||||
if node.Type == RuleNodeStart && incoming[node.ID] != 0 {
|
||||
return fmt.Errorf("开始节点 %s 不能有入边", node.ID)
|
||||
return fmt.Errorf("start node %s must not have incoming edges", node.ID)
|
||||
}
|
||||
if node.Type != RuleNodeStart && incoming[node.ID] == 0 {
|
||||
return fmt.Errorf("节点 %s 必须至少有一条入边", node.ID)
|
||||
return fmt.Errorf("node %s must have at least one incoming edge", node.ID)
|
||||
}
|
||||
if (node.Type == RuleNodeAllow || node.Type == RuleNodeBlock) && len(outgoing[node.ID]) != 0 {
|
||||
return fmt.Errorf("终止节点 %s 不能有出口", node.ID)
|
||||
return fmt.Errorf("terminal node %s must not have outgoing edges", node.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -205,12 +205,12 @@ func validateIPMatchNodeConfig(ctx context.Context, node RuleNode, exists func(c
|
||||
}
|
||||
for _, raw := range cfg.IPs {
|
||||
if _, err := netip.ParseAddr(raw); err != nil {
|
||||
return fmt.Errorf("节点 %s 的 IP %s 无效", node.ID, raw)
|
||||
return fmt.Errorf("node %s contains invalid IP %s", node.ID, raw)
|
||||
}
|
||||
}
|
||||
for _, raw := range cfg.CIDRs {
|
||||
if _, err := netip.ParsePrefix(raw); err != nil {
|
||||
return fmt.Errorf("节点 %s 的 CIDR %s 无效", node.ID, raw)
|
||||
return fmt.Errorf("node %s contains invalid CIDR %s", node.ID, raw)
|
||||
}
|
||||
}
|
||||
for _, id := range cfg.IPGroupIDs {
|
||||
@@ -223,17 +223,17 @@ func validateIPMatchNodeConfig(ctx context.Context, node RuleNode, exists func(c
|
||||
|
||||
func validateIPGroupReference(ctx context.Context, nodeID string, id uint, exists func(context.Context, uint) (bool, error)) error {
|
||||
if id == 0 {
|
||||
return fmt.Errorf("节点 %s 引用的 IP 组 ID 无效", nodeID)
|
||||
return fmt.Errorf("node %s references an invalid IP group ID", nodeID)
|
||||
}
|
||||
if exists == nil {
|
||||
return fmt.Errorf("节点 %s 无法校验 IP 组 %d", nodeID, id)
|
||||
return fmt.Errorf("node %s cannot validate IP group %d", nodeID, id)
|
||||
}
|
||||
ok, err := exists(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("节点 %s 校验 IP 组 %d 失败: %w", nodeID, id, err)
|
||||
return fmt.Errorf("node %s failed to validate IP group %d: %w", nodeID, id, err)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("节点 %s 引用的 IP 组 %d 不存在", nodeID, id)
|
||||
return fmt.Errorf("node %s references missing IP group %d", nodeID, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -245,12 +245,12 @@ func validateGeoMatchNodeConfig(node RuleNode) error {
|
||||
}
|
||||
for _, code := range cfg.Countries {
|
||||
if !countryCodePattern.MatchString(code) {
|
||||
return fmt.Errorf("节点 %s 的国家代码 %s 无效", node.ID, code)
|
||||
return fmt.Errorf("node %s contains invalid country code %s", node.ID, code)
|
||||
}
|
||||
}
|
||||
for _, code := range cfg.Regions {
|
||||
if !regionCodePattern.MatchString(code) {
|
||||
return fmt.Errorf("节点 %s 的地区代码 %s 无效", node.ID, code)
|
||||
return fmt.Errorf("node %s contains invalid region code %s", node.ID, code)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -262,16 +262,16 @@ func validatePoWNodeConfig(node RuleNode) error {
|
||||
return err
|
||||
}
|
||||
if cfg.Difficulty < 1 || cfg.Difficulty > 16 {
|
||||
return fmt.Errorf("节点 %s 的 PoW 难度必须在 1-16 之间", node.ID)
|
||||
return fmt.Errorf("PoW difficulty for node %s must be between 1 and 16", node.ID)
|
||||
}
|
||||
if cfg.Algorithm != powAlgorithmFast && cfg.Algorithm != powAlgorithmSlow {
|
||||
return fmt.Errorf("节点 %s 的 PoW 算法必须为 fast 或 slow", node.ID)
|
||||
return fmt.Errorf("PoW algorithm for node %s must be fast or slow", node.ID)
|
||||
}
|
||||
if cfg.SessionTTL < minPoWSessionTTLSeconds {
|
||||
return fmt.Errorf("节点 %s 的 PoW 会话 TTL 不能小于 60 秒", node.ID)
|
||||
return fmt.Errorf("PoW session TTL for node %s must be at least 60 seconds", node.ID)
|
||||
}
|
||||
if cfg.ChallengeTTL < minPoWChallengeTTLSeconds {
|
||||
return fmt.Errorf("节点 %s 的 PoW 挑战 TTL 不能小于 30 秒", node.ID)
|
||||
return fmt.Errorf("PoW challenge TTL for node %s must be at least 30 seconds", node.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -282,10 +282,10 @@ func validateBlockNodeConfig(node RuleNode) error {
|
||||
return err
|
||||
}
|
||||
if cfg.StatusCode < 400 || cfg.StatusCode > 599 {
|
||||
return fmt.Errorf("节点 %s 的阻止状态码必须在 400-599 之间", node.ID)
|
||||
return fmt.Errorf("block status code for node %s must be between 400 and 599", node.ID)
|
||||
}
|
||||
if len([]byte(cfg.ResponseBody)) > maxWAFBlockBodyBytes {
|
||||
return fmt.Errorf("节点 %s 的阻止响应体不能超过 %d 字节", node.ID, maxWAFBlockBodyBytes)
|
||||
return fmt.Errorf("block response body for node %s must not exceed %d bytes", node.ID, maxWAFBlockBodyBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -300,34 +300,34 @@ func validateUACheckNodeConfig(node RuleNode) error {
|
||||
mode = UACheckMatchModeOr
|
||||
}
|
||||
if mode != UACheckMatchModeAnd && mode != UACheckMatchModeOr {
|
||||
return fmt.Errorf("节点 %s 的匹配模式必须为 and 或 or", node.ID)
|
||||
return fmt.Errorf("match mode for node %s must be and or or", node.ID)
|
||||
}
|
||||
for _, label := range cfg.Browsers {
|
||||
if !uaBrowserLabels[label] {
|
||||
return fmt.Errorf("节点 %s 的浏览器标签 %s 无效", node.ID, label)
|
||||
return fmt.Errorf("node %s contains invalid browser tag %s", node.ID, label)
|
||||
}
|
||||
}
|
||||
for _, label := range cfg.OperatingSystems {
|
||||
if !uaOSLabels[label] {
|
||||
return fmt.Errorf("节点 %s 的操作系统标签 %s 无效", node.ID, label)
|
||||
return fmt.Errorf("node %s contains invalid operating system tag %s", node.ID, label)
|
||||
}
|
||||
}
|
||||
if len(cfg.CustomUAPatterns) > maxUACustomPatterns {
|
||||
return fmt.Errorf("节点 %s 的自定义 UA 正则不能超过 %d 条", node.ID, maxUACustomPatterns)
|
||||
return fmt.Errorf("node %s must not contain more than %d custom User-Agent patterns", node.ID, maxUACustomPatterns)
|
||||
}
|
||||
for _, pattern := range cfg.CustomUAPatterns {
|
||||
if strings.TrimSpace(pattern) == "" {
|
||||
return fmt.Errorf("节点 %s 的自定义 UA 正则不能为空", node.ID)
|
||||
return fmt.Errorf("custom User-Agent pattern in node %s must not be empty", node.ID)
|
||||
}
|
||||
if len(pattern) > maxUACustomPatternBytes {
|
||||
return fmt.Errorf("节点 %s 的自定义 UA 正则不能超过 %d 字节", node.ID, maxUACustomPatternBytes)
|
||||
return fmt.Errorf("custom User-Agent pattern in node %s must not exceed %d bytes", node.ID, maxUACustomPatternBytes)
|
||||
}
|
||||
if _, err := regexp.Compile(pattern); err != nil {
|
||||
return fmt.Errorf("节点 %s 的自定义 UA 正则无效: %s", node.ID, pattern)
|
||||
return fmt.Errorf("invalid custom User-Agent pattern in node %s: %s", node.ID, pattern)
|
||||
}
|
||||
}
|
||||
if cfg.BlockCustomUA && len(cfg.CustomUAPatterns) == 0 {
|
||||
return fmt.Errorf("节点 %s 开启屏蔽自定义 UA 时至少需要一条正则", node.ID)
|
||||
return fmt.Errorf("node %s requires at least one pattern when custom User-Agent blocking is enabled", node.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -350,7 +350,7 @@ var uaOSLabels = map[string]bool{
|
||||
|
||||
func decodeNodeConfig(node RuleNode, dst any) error {
|
||||
if err := decodeStrictConfig(node.Config, dst); err != nil {
|
||||
return fmt.Errorf("节点 %s 的配置无效: %w", node.ID, err)
|
||||
return fmt.Errorf("invalid configuration for node %s: %w", node.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -358,7 +358,7 @@ func decodeNodeConfig(node RuleNode, dst any) error {
|
||||
func decodeStrictConfig(raw json.RawMessage, dst any) error {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if bytes.Equal(trimmed, []byte("null")) {
|
||||
return errors.New("配置不能为 null")
|
||||
return errors.New("configuration must not be null")
|
||||
}
|
||||
if len(trimmed) == 0 {
|
||||
raw = json.RawMessage(`{}`)
|
||||
@@ -369,7 +369,7 @@ func decodeStrictConfig(raw json.RawMessage, dst any) error {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return errors.New("配置包含额外 JSON 值")
|
||||
return errors.New("configuration contains an additional JSON value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -451,7 +451,7 @@ func validateTerminalPaths(nodes []RuleNode, outgoing map[string][]RuleEdge) err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
if !terminating[node.ID] {
|
||||
return fmt.Errorf("节点 %s 不存在通往终止节点的路径", node.ID)
|
||||
return fmt.Errorf("node %s has no path to a terminal node", node.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user