From cb339ab0dc5d7fa689ccba98adcd4c66c9c54fed Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 30 Aug 2026 00:22:56 +0800 Subject: [PATCH 01/44] =?UTF-8?q?feat(core):=20RouterExtension=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=20HandleRaw=20=E4=B8=8E=20BasePath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle 经 cleanPath 归一化会剥掉尾部斜杠,插件无法同时声明 /resource 与 /resource/ 两条路由;部署关闭 gin 的 RedirectTrailingSlash 时,缺失的那条 直接 404。下游 OpenFlare 有 20 个历史列表接口依赖该行为。 - HandleRaw:与组前缀拼接但保留尾部斜杠,分配独立路由 ID; - BasePath:返回组的绝对前缀(根注册表为空串); - 作用域包装器为 HandleRaw 同样登记 OnDispose 反注册。 验证:go build 通过;go test ./... exit 0(48 包 ok); check_cordis_architecture.sh 0 violations;gofmt 干净。 --- backend/core/extpoints/router.go | 69 +++++++++++++++++++++++ backend/core/extpoints/router_raw_test.go | 52 +++++++++++++++++ backend/core/scoped_extpoints.go | 14 +++++ 3 files changed, 135 insertions(+) create mode 100644 backend/core/extpoints/router_raw_test.go diff --git a/backend/core/extpoints/router.go b/backend/core/extpoints/router.go index ac3266c3..f7c281c1 100644 --- a/backend/core/extpoints/router.go +++ b/backend/core/extpoints/router.go @@ -22,6 +22,11 @@ type RouterExtension interface { Use(middlewares ...any) Group(prefix string, middlewares ...any) RouterExtension Handle(method, path string, handlers ...any) RouteDefinition + // HandleRaw joins path with the group prefix but preserves a trailing slash, + // so `/resource` and `/resource/` can coexist as distinct routes. + HandleRaw(method, path string, handlers ...any) RouteDefinition + // BasePath reports this group's absolute prefix ("" for the root registry). + BasePath() string GET(path string, handlers ...any) RouteDefinition POST(path string, handlers ...any) RouteDefinition PUT(path string, handlers ...any) RouteDefinition @@ -503,3 +508,67 @@ func (w *PathWhitelist) Patterns() []string { } return res } + +// ─── Raw path registration ──────────────────────────────────────────────────── + +// ensureLeadingSlash normalises a path to start with exactly one "/" while +// preserving any trailing slash (unlike cleanPath). +func ensureLeadingSlash(p string) string { + if p == "" { + return "/" + } + if !strings.HasPrefix(p, "/") { + return "/" + p + } + return p +} + +// joinPathPreservingTrailing joins a group prefix and a relative path without +// stripping a trailing slash, so a group "/x" can serve both "/x" and "/x/". +func joinPathPreservingTrailing(base, relative string) string { + rel := ensureLeadingSlash(relative) + if base == "" || base == "/" { + return rel + } + return strings.TrimSuffix(cleanPath(base), "/") + rel +} + +// HandleRaw registers a route on the root registry, preserving a trailing slash. +func (r *RouterRegistry) HandleRaw(method, path string, handlers ...any) RouteDefinition { + r.mu.Lock() + defer r.mu.Unlock() + + r.nextID++ + rd := RouteDefinition{ + ID: r.nextID, + Method: strings.ToUpper(method), + Path: ensureLeadingSlash(path), + Handlers: handlers, + Middlewares: append([]any(nil), r.middlewares...), + } + r.routes = append(r.routes, rd) + return rd +} + +// BasePath returns "" because the root registry has no prefix. +func (r *RouterRegistry) BasePath() string { return "" } + +// HandleRaw registers a route under this group, preserving a trailing slash. +func (g *RouterGroup) HandleRaw(method, path string, handlers ...any) RouteDefinition { + g.registry.mu.Lock() + defer g.registry.mu.Unlock() + + g.registry.nextID++ + rd := RouteDefinition{ + ID: g.registry.nextID, + Method: strings.ToUpper(method), + Path: joinPathPreservingTrailing(g.prefix, path), + } + rd.Handlers = handlers + rd.Middlewares = append(g.registry.middlewares, g.middlewares...) + g.registry.routes = append(g.registry.routes, rd) + return rd +} + +// BasePath returns this group's absolute prefix. +func (g *RouterGroup) BasePath() string { return g.prefix } diff --git a/backend/core/extpoints/router_raw_test.go b/backend/core/extpoints/router_raw_test.go new file mode 100644 index 00000000..31b0f38f --- /dev/null +++ b/backend/core/extpoints/router_raw_test.go @@ -0,0 +1,52 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import "testing" + +// TestHandleRawPreservesTrailingSlash 验证 HandleRaw 能表达 /x 与 /x/ 两条不同路由, +// 而 Handle 会归一化掉尾部斜杠(server 插件的 list 端点历史行为依赖这一点)。 +func TestHandleRawPreservesTrailingSlash(t *testing.T) { + r := &RouterRegistry{} + g := r.Group("/api/v1/nodes") + + if got := g.BasePath(); got != "/api/v1/nodes" { + t.Fatalf("BasePath() = %q, want %q", got, "/api/v1/nodes") + } + slashless := g.Handle("GET", "") + slashed := g.HandleRaw("GET", "/") + + if slashless.Path != "/api/v1/nodes" { + t.Errorf("Handle(\"\") path = %q, want %q", slashless.Path, "/api/v1/nodes") + } + if slashed.Path != "/api/v1/nodes/" { + t.Errorf("HandleRaw(\"/\") path = %q, want %q", slashed.Path, "/api/v1/nodes/") + } + if slashed.ID == slashless.ID { + t.Error("HandleRaw must allocate its own route ID so scoped teardown can unregister both") + } + if got := len(r.Routes()); got != 2 { + t.Errorf("registry routes = %d, want 2", got) + } + if !r.UnregisterByID(slashed.ID) { + t.Error("UnregisterByID(HandleRaw route) = false, want true") + } + if got := len(r.Routes()); got != 1 { + t.Errorf("routes after unregister = %d, want 1", got) + } +} + +// TestRegistryHandleRawKeepsAbsolutePath 根注册表上 HandleRaw 只做绝对化处理。 +func TestRegistryHandleRawKeepsAbsolutePath(t *testing.T) { + r := &RouterRegistry{} + if got := r.HandleRaw("GET", "/health/").Path; got != "/health/" { + t.Errorf("path = %q, want %q", got, "/health/") + } + if got := r.HandleRaw("POST", "submit").Path; got != "/submit" { + t.Errorf("path = %q, want %q", got, "/submit") + } + if got := r.BasePath(); got != "" { + t.Errorf("registry BasePath() = %q, want empty", got) + } +} diff --git a/backend/core/scoped_extpoints.go b/backend/core/scoped_extpoints.go index 9bd4d923..f567544a 100644 --- a/backend/core/scoped_extpoints.go +++ b/backend/core/scoped_extpoints.go @@ -40,6 +40,20 @@ func (s *scopedRouterExtension) Handle(method, path string, handlers ...any) ext return rd } +// HandleRaw registers a trailing-slash-preserving route and tears it down with the scope. +func (s *scopedRouterExtension) HandleRaw(method, path string, handlers ...any) extpoints.RouteDefinition { + rd := s.underlying.HandleRaw(method, path, handlers...) + routeID := rd.ID + s.ctx.OnDispose(func() error { + s.underlying.UnregisterByID(routeID) + return nil + }) + return rd +} + +// BasePath delegates to the wrapped group prefix. +func (s *scopedRouterExtension) BasePath() string { return s.underlying.BasePath() } + func (s *scopedRouterExtension) GET(path string, handlers ...any) extpoints.RouteDefinition { return s.Handle("GET", path, handlers...) } From bad6fa785de5424f60f4117a7de115b9707d42f1 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 30 Aug 2026 00:26:01 +0800 Subject: [PATCH 02/44] =?UTF-8?q?refactor(core):=20Handle=20=E4=B8=8E=20Ha?= =?UTF-8?q?ndleRaw=20=E5=85=B1=E7=94=A8=20addRoute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消除注册逻辑重复,并修正 HandleRaw 里 append(g.registry.middlewares, ...) 复用底层数组的隐患:中间件快照统一在 addRoute 内构造为新切片。 验证:go build 通过;go test ./core/... 全绿;golangci-lint ./core/... 0 issues。 --- backend/core/extpoints/router.go | 42 ++++++++++---------------------- 1 file changed, 13 insertions(+), 29 deletions(-) diff --git a/backend/core/extpoints/router.go b/backend/core/extpoints/router.go index f7c281c1..44258028 100644 --- a/backend/core/extpoints/router.go +++ b/backend/core/extpoints/router.go @@ -85,6 +85,11 @@ func (r *RouterRegistry) Group(prefix string, middlewares ...any) RouterExtensio // Handle registers a route with a custom HTTP method and handlers. func (r *RouterRegistry) Handle(method, path string, handlers ...any) RouteDefinition { + return r.addRoute(method, cleanPath(path), handlers...) +} + +// addRoute appends a route whose path is already normalised. +func (r *RouterRegistry) addRoute(method, fullPath string, handlers ...any) RouteDefinition { r.mu.Lock() defer r.mu.Unlock() @@ -92,7 +97,7 @@ func (r *RouterRegistry) Handle(method, path string, handlers ...any) RouteDefin rd := RouteDefinition{ ID: r.nextID, Method: strings.ToUpper(method), - Path: cleanPath(path), + Path: fullPath, Handlers: handlers, Middlewares: append([]any(nil), r.middlewares...), } @@ -228,11 +233,14 @@ func (g *RouterGroup) Group(prefix string, middlewares ...any) RouterExtension { // Handle registers a route under this group. func (g *RouterGroup) Handle(method, path string, handlers ...any) RouteDefinition { + return g.addRoute(method, joinPaths(g.prefix, path), handlers...) +} + +// addRoute appends a route under this group whose path is already joined. +func (g *RouterGroup) addRoute(method, fullPath string, handlers ...any) RouteDefinition { g.registry.mu.Lock() defer g.registry.mu.Unlock() - fullPath := joinPaths(g.prefix, path) - allMiddlewares := make([]any, 0, len(g.registry.middlewares)+len(g.middlewares)) allMiddlewares = append(allMiddlewares, g.registry.middlewares...) allMiddlewares = append(allMiddlewares, g.middlewares...) @@ -535,19 +543,7 @@ func joinPathPreservingTrailing(base, relative string) string { // HandleRaw registers a route on the root registry, preserving a trailing slash. func (r *RouterRegistry) HandleRaw(method, path string, handlers ...any) RouteDefinition { - r.mu.Lock() - defer r.mu.Unlock() - - r.nextID++ - rd := RouteDefinition{ - ID: r.nextID, - Method: strings.ToUpper(method), - Path: ensureLeadingSlash(path), - Handlers: handlers, - Middlewares: append([]any(nil), r.middlewares...), - } - r.routes = append(r.routes, rd) - return rd + return r.addRoute(method, ensureLeadingSlash(path), handlers...) } // BasePath returns "" because the root registry has no prefix. @@ -555,19 +551,7 @@ func (r *RouterRegistry) BasePath() string { return "" } // HandleRaw registers a route under this group, preserving a trailing slash. func (g *RouterGroup) HandleRaw(method, path string, handlers ...any) RouteDefinition { - g.registry.mu.Lock() - defer g.registry.mu.Unlock() - - g.registry.nextID++ - rd := RouteDefinition{ - ID: g.registry.nextID, - Method: strings.ToUpper(method), - Path: joinPathPreservingTrailing(g.prefix, path), - } - rd.Handlers = handlers - rd.Middlewares = append(g.registry.middlewares, g.middlewares...) - g.registry.routes = append(g.registry.routes, rd) - return rd + return g.addRoute(method, joinPathPreservingTrailing(g.prefix, path), handlers...) } // BasePath returns this group's absolute prefix. From 8ff017b5e81934ae6f056358609f89f5105829f6 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 30 Aug 2026 00:36:13 +0800 Subject: [PATCH 03/44] =?UTF-8?q?feat(pkg/util):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E6=AF=94=E8=BE=83=E3=80=81=E7=BD=91=E7=BB=9C?= =?UTF-8?q?=E4=B8=8E=E6=A0=BC=E5=BC=8F=E5=8C=96=E9=80=9A=E7=94=A8=E5=8A=A9?= =?UTF-8?q?=E6=89=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下游 OpenFlare 的边缘守护进程与发布流程需要这些与业务无关的纯函数, 按上游/下游归属规约回流到平台层,避免下游在上游目录里长期携带本地文件: - version / version_compare:CompareVersions、ParseVersionInfo(版本区间比较) - network:GetIP、IsPrivateIPv4 - format / value / string / slice:Bytes2Size、Seconds2Time、Interface2String、 TrimStringFields、UniqueAndCleanStringSlice 与 IdentifiableTimeRecord 验证:go build 通过;go test ./... exit 0(48 包 ok); check_cordis_architecture.sh 0 violations;golangci-lint 0 issues;gofmt 干净。 --- backend/pkg/util/format.go | 70 +++++++++++++++ backend/pkg/util/format_test.go | 52 +++++++++++ backend/pkg/util/network.go | 45 ++++++++++ backend/pkg/util/network_test.go | 36 ++++++++ backend/pkg/util/slice.go | 79 +++++++++++++++++ backend/pkg/util/string.go | 15 ++++ backend/pkg/util/value.go | 22 +++++ backend/pkg/util/version.go | 129 ++++++++++++++++++++++++++++ backend/pkg/util/version_compare.go | 108 +++++++++++++++++++++++ 9 files changed, 556 insertions(+) create mode 100644 backend/pkg/util/format.go create mode 100644 backend/pkg/util/format_test.go create mode 100644 backend/pkg/util/network.go create mode 100644 backend/pkg/util/network_test.go create mode 100644 backend/pkg/util/slice.go create mode 100644 backend/pkg/util/string.go create mode 100644 backend/pkg/util/value.go create mode 100644 backend/pkg/util/version.go create mode 100644 backend/pkg/util/version_compare.go diff --git a/backend/pkg/util/format.go b/backend/pkg/util/format.go new file mode 100644 index 00000000..5b18daba --- /dev/null +++ b/backend/pkg/util/format.go @@ -0,0 +1,70 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package util provides shared formatting and string helper functions. +package util + +import ( + "fmt" + "strconv" +) + +const ( + secondsPerYear = 31104000 // 360 days + secondsPerMonth = 2592000 // 30 days + secondsPerDay = 86400 + secondsPerHour = 3600 + secondsPerMinute = 60 +) + +const ( + sizeKB = 1024 + sizeMB = sizeKB * 1024 + sizeGB = sizeMB * 1024 +) + +// Bytes2Size converts a byte count to a human-readable string with unit (B, KB, MB, GB). +func Bytes2Size(num int64) string { + var numStr string + unit := "B" + switch { + case num/int64(sizeGB) >= 1: + numStr = fmt.Sprintf("%.2f", float64(num)/float64(sizeGB)) + unit = "GB" + case num/int64(sizeMB) >= 1: + numStr = strconv.Itoa(int(float64(num) / float64(sizeMB))) + unit = "MB" + case num/int64(sizeKB) >= 1: + numStr = strconv.Itoa(int(float64(num) / float64(sizeKB))) + unit = "KB" + default: + numStr = strconv.FormatInt(num, 10) + } + return numStr + " " + unit +} + +// Seconds2Time converts a number of seconds to a human-readable Chinese duration string. +func Seconds2Time(num int) (time string) { + if num/secondsPerYear > 0 { + time += strconv.Itoa(num/secondsPerYear) + " 年 " + num %= secondsPerYear + } + if num/secondsPerMonth > 0 { + time += strconv.Itoa(num/secondsPerMonth) + " 个月 " + num %= secondsPerMonth + } + if num/secondsPerDay > 0 { + time += strconv.Itoa(num/secondsPerDay) + " 天 " + num %= secondsPerDay + } + if num/secondsPerHour > 0 { + time += strconv.Itoa(num/secondsPerHour) + " 小时 " + num %= secondsPerHour + } + if num/secondsPerMinute > 0 { + time += strconv.Itoa(num/secondsPerMinute) + " 分钟 " + num %= secondsPerMinute + } + time += strconv.Itoa(num) + " 秒" + return +} diff --git a/backend/pkg/util/format_test.go b/backend/pkg/util/format_test.go new file mode 100644 index 00000000..b2fd973f --- /dev/null +++ b/backend/pkg/util/format_test.go @@ -0,0 +1,52 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "testing" +) + +func TestBytes2Size(t *testing.T) { + tests := []struct { + input int64 + expected string + }{ + {0, "0 B"}, + {500, "500 B"}, + {1023, "1023 B"}, + {1024, "1 KB"}, + {2048, "2 KB"}, + {1024 * 1024, "1 MB"}, + {1024 * 1024 * 1024, "1.00 GB"}, + {1024 * 1024 * 1024 * 2, "2.00 GB"}, + } + + for _, tt := range tests { + result := Bytes2Size(tt.input) + if result != tt.expected { + t.Errorf("Bytes2Size(%d) = %q, expected %q", tt.input, result, tt.expected) + } + } +} + +func TestSeconds2Time(t *testing.T) { + tests := []struct { + input int + expected string + }{ + {0, "0 秒"}, + {30, "30 秒"}, + {60, "1 分钟 0 秒"}, + {125, "2 分钟 5 秒"}, + {3600, "1 小时 0 秒"}, + {86400, "1 天 0 秒"}, + } + + for _, tt := range tests { + result := Seconds2Time(tt.input) + if result != tt.expected { + t.Errorf("Seconds2Time(%d) = %q, expected %q", tt.input, result, tt.expected) + } + } +} diff --git a/backend/pkg/util/network.go b/backend/pkg/util/network.go new file mode 100644 index 00000000..d7b864d5 --- /dev/null +++ b/backend/pkg/util/network.go @@ -0,0 +1,45 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "log/slog" + "net" +) + +// GetIP returns the first private IPv4 address found on the local network interfaces. +func GetIP() (ip string) { + ips, err := net.InterfaceAddrs() + if err != nil { + slog.Error("get interface addresses failed", "error", err) + return ip + } + + for _, a := range ips { + if candidate, ok := privateIPv4FromAddr(a); ok { + return candidate + } + } + return +} + +func privateIPv4FromAddr(addr net.Addr) (string, bool) { + ipNet, ok := addr.(*net.IPNet) + if !ok || ipNet.IP.IsLoopback() || ipNet.IP.To4() == nil { + return "", false + } + ip := ipNet.IP.String() + if isPrivateIPv4(ip) { + return ip, true + } + return "", false +} + +func isPrivateIPv4(ip string) bool { + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return false + } + return parsedIP.IsPrivate() +} diff --git a/backend/pkg/util/network_test.go b/backend/pkg/util/network_test.go new file mode 100644 index 00000000..62a9bd21 --- /dev/null +++ b/backend/pkg/util/network_test.go @@ -0,0 +1,36 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "testing" +) + +func TestIsPrivateIPv4(t *testing.T) { + tests := []struct { + ip string + expected bool + }{ + {"127.0.0.1", false}, // Loopback is not in RFC 1918 private range + {"10.0.0.1", true}, + {"172.16.0.1", true}, + {"192.168.1.1", true}, + {"8.8.8.8", false}, + {"invalid-ip", false}, + } + + for _, tt := range tests { + result := isPrivateIPv4(tt.ip) + if result != tt.expected { + t.Errorf("isPrivateIPv4(%q) = %v, expected %v", tt.ip, result, tt.expected) + } + } +} + +func TestGetIP(t *testing.T) { + ip := GetIP() + // GetIP should return empty if no private IPv4 address is configured, or a valid IP. + // We just ensure it doesn't panic. + t.Logf("GetIP returned: %q", ip) +} diff --git a/backend/pkg/util/slice.go b/backend/pkg/util/slice.go new file mode 100644 index 00000000..74eb7139 --- /dev/null +++ b/backend/pkg/util/slice.go @@ -0,0 +1,79 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "sort" + "strings" + "time" +) + +// Unique returns a new slice containing only the unique elements of the input slice, +// preserving their original order. +func Unique[T comparable](slice []T) []T { + if slice == nil { + return nil + } + seen := make(map[T]struct{}) + result := make([]T, 0) + for _, item := range slice { + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + result = append(result, item) + } + return result +} + +// UniqueAndCleanStringSlice trims spaces, removes empty elements, and returns only the unique elements +// of the input string slice. It preserves order and returns nil if the resulting slice is empty. +func UniqueAndCleanStringSlice(slice []string) []string { + if slice == nil { + return nil + } + seen := make(map[string]struct{}) + result := make([]string, 0) + for _, item := range slice { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + if len(result) == 0 { + return nil + } + return result +} + +// IdentifiableTimeRecord represents a database record that has a unique ID and a primary timestamp field. +type IdentifiableTimeRecord interface { + GetID() uint + GetTime() time.Time +} + +// SortAndLimitRecords sorts a slice of IdentifiableTimeRecord descendingly by their timestamp (and ID as a tie-breaker), +// and limits the slice to the specified size if limit > 0. +func SortAndLimitRecords[T IdentifiableTimeRecord](rows []T, limit int) []T { + if len(rows) == 0 { + return rows + } + sort.Slice(rows, func(i, j int) bool { + ti := rows[i].GetTime() + tj := rows[j].GetTime() + if ti.Equal(tj) { + return rows[i].GetID() > rows[j].GetID() + } + return ti.After(tj) + }) + if limit > 0 && len(rows) > limit { + rows = rows[:limit] + } + return rows +} diff --git a/backend/pkg/util/string.go b/backend/pkg/util/string.go new file mode 100644 index 00000000..9ea84e8d --- /dev/null +++ b/backend/pkg/util/string.go @@ -0,0 +1,15 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import "strings" + +// TrimStringFields trims leading and trailing spaces from all provided string pointers. +func TrimStringFields(fields ...*string) { + for _, f := range fields { + if f != nil { + *f = strings.TrimSpace(*f) + } + } +} diff --git a/backend/pkg/util/value.go b/backend/pkg/util/value.go new file mode 100644 index 00000000..6473bd53 --- /dev/null +++ b/backend/pkg/util/value.go @@ -0,0 +1,22 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "fmt" + "strconv" +) + +// Interface2String converts a string, int, or float64 value to its string representation. +func Interface2String(inter any) string { + switch v := inter.(type) { + case string: + return v + case int: + return strconv.Itoa(v) + case float64: + return fmt.Sprintf("%f", v) + } + return "Not Implemented" +} diff --git a/backend/pkg/util/version.go b/backend/pkg/util/version.go new file mode 100644 index 00000000..96118b06 --- /dev/null +++ b/backend/pkg/util/version.go @@ -0,0 +1,129 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "strconv" + "strings" +) + +const gitDescribeMinIdentifiers = 2 + +// VersionInfo holds the parsed components of a semantic version string. +type VersionInfo struct { + Valid bool + IsDev bool + Numbers []int + Prerelease []string + GitDescribeDistance int + GitDescribeTail []string +} + +// ParseVersionInfo parses a version string into a structured VersionInfo. +func ParseVersionInfo(version string) VersionInfo { + normalized := strings.TrimSpace(strings.TrimPrefix(version, "v")) + if normalized == "" || normalized == "dev" { + return VersionInfo{IsDev: strings.EqualFold(normalized, "dev")} + } + base := normalized + prerelease := "" + if separator := strings.IndexRune(normalized, '-'); separator >= 0 { + base = normalized[:separator] + prerelease = normalized[separator+1:] + } + + segments := strings.Split(base, ".") + parts := make([]int, 0, len(segments)) + for _, segment := range segments { + segment = strings.TrimSpace(segment) + if segment == "" { + parts = append(parts, 0) + continue + } + + numeric := strings.Builder{} + for _, r := range segment { + if r < '0' || r > '9' { + break + } + numeric.WriteRune(r) + } + if numeric.Len() == 0 { + parts = append(parts, 0) + continue + } + value, err := strconv.Atoi(numeric.String()) + if err != nil { + return VersionInfo{} + } + parts = append(parts, value) + } + info := VersionInfo{Valid: len(parts) > 0, Numbers: parts} + if prerelease != "" { + identifiers := splitPrereleaseIdentifiers(prerelease) + if distance, tail, ok := parseGitDescribeIdentifiers(identifiers); ok { + info.GitDescribeDistance = distance + info.GitDescribeTail = tail + } else { + info.Prerelease = identifiers + } + } + return info +} + +func parseGitDescribeIdentifiers(identifiers []string) (int, []string, bool) { + if len(identifiers) < gitDescribeMinIdentifiers { + return 0, nil, false + } + distance, err := strconv.Atoi(strings.TrimSpace(identifiers[0])) + if err != nil || distance <= 0 { + return 0, nil, false + } + commitToken := strings.TrimSpace(identifiers[1]) + if commitToken == "" || !strings.HasPrefix(strings.ToLower(commitToken), "g") { + return 0, nil, false + } + return distance, identifiers[1:], true +} + +func splitPrereleaseIdentifiers(value string) []string { + parts := strings.FieldsFunc(strings.TrimSpace(value), func(r rune) bool { + return r == '.' || r == '-' + }) + filtered := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + filtered = append(filtered, part) + } + } + return filtered +} + +// CompareVersions compares two version strings. +// Returns -1 if left < right, 1 if left > right, and 0 if they are equal. +func CompareVersions(local, remote string) int { + left := ParseVersionInfo(local) + right := ParseVersionInfo(remote) + if left.IsDev { + if right.Valid { + return -1 + } + return 0 + } + if !left.Valid || !right.Valid { + return 0 + } + + if result := compareVersionNumbers(left, right); result != 0 { + return result + } + if result := compareGitDescribeDistance(left, right); result != 0 { + return result + } + if left.GitDescribeDistance > 0 || right.GitDescribeDistance > 0 { + return compareGitDescribeTails(left, right) + } + return comparePrereleaseIdentifiers(left, right) +} diff --git a/backend/pkg/util/version_compare.go b/backend/pkg/util/version_compare.go new file mode 100644 index 00000000..c7e3d014 --- /dev/null +++ b/backend/pkg/util/version_compare.go @@ -0,0 +1,108 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package util + +import "strconv" + +func compareVersionNumbers(left, right VersionInfo) int { + maxLen := max(len(right.Numbers), len(left.Numbers)) + for index := range maxLen { + leftValue := 0 + rightValue := 0 + if index < len(left.Numbers) { + leftValue = left.Numbers[index] + } + if index < len(right.Numbers) { + rightValue = right.Numbers[index] + } + if leftValue < rightValue { + return -1 + } + if leftValue > rightValue { + return 1 + } + } + return 0 +} + +func compareGitDescribeDistance(left, right VersionInfo) int { + if left.GitDescribeDistance == right.GitDescribeDistance { + return 0 + } + if left.GitDescribeDistance < right.GitDescribeDistance { + return -1 + } + return 1 +} + +func compareGitDescribeTails(left, right VersionInfo) int { + maxLen := max(len(right.GitDescribeTail), len(left.GitDescribeTail)) + for index := range maxLen { + if index >= len(left.GitDescribeTail) { + return -1 + } + if index >= len(right.GitDescribeTail) { + return 1 + } + if left.GitDescribeTail[index] < right.GitDescribeTail[index] { + return -1 + } + if left.GitDescribeTail[index] > right.GitDescribeTail[index] { + return 1 + } + } + return 0 +} + +func comparePrereleaseIdentifiers(left, right VersionInfo) int { + if len(left.Prerelease) == 0 && len(right.Prerelease) == 0 { + return 0 + } + if len(left.Prerelease) == 0 { + return 1 + } + if len(right.Prerelease) == 0 { + return -1 + } + + maxLen := max(len(right.Prerelease), len(left.Prerelease)) + for index := range maxLen { + if index >= len(left.Prerelease) { + return -1 + } + if index >= len(right.Prerelease) { + return 1 + } + if result := comparePrereleasePart(left.Prerelease[index], right.Prerelease[index]); result != 0 { + return result + } + } + return 0 +} + +func comparePrereleasePart(leftPart, rightPart string) int { + leftNumber, leftErr := strconv.Atoi(leftPart) + rightNumber, rightErr := strconv.Atoi(rightPart) + switch { + case leftErr == nil && rightErr == nil: + if leftNumber < rightNumber { + return -1 + } + if leftNumber > rightNumber { + return 1 + } + case leftErr == nil: + return -1 + case rightErr == nil: + return 1 + default: + if leftPart < rightPart { + return -1 + } + if leftPart > rightPart { + return 1 + } + } + return 0 +} From f3d85d51fb733c0a0e29f1ca542f93921fcf23de Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 30 Aug 2026 01:00:02 +0800 Subject: [PATCH 04/44] =?UTF-8?q?fix(pkg/cache/disk):=20LRU=20=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E7=B1=BB=E5=9E=8B=E6=96=AD=E8=A8=80=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=97=B6=E9=99=8D=E7=BA=A7=E8=80=8C=E9=9D=9E=20panic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit items 与 evictList 的不变量一旦被破坏,读、写、删除与淘汰路径上的裸类型断言 会直接崩掉进程。改为带 ok 检查:Get 退化为缓存未命中,Set 报告污染条目, deleteUnlocked 跳过容量回退,evict 移除坏节点后继续。 新增 cache_corruption_test.go 锁住该行为:去掉守卫后用例会以 「interface conversion: interface {} is string, not *disk.cacheItem」失败, 加上守卫后 4 个用例全通过。 验证:go build 通过;go test ./pkg/cache/disk/ 全绿(含原有 5 个用例); golangci-lint 0 issues;check_cordis_architecture.sh 0 violations。 --- backend/pkg/cache/disk/cache.go | 35 ++++++++--- .../pkg/cache/disk/cache_corruption_test.go | 63 +++++++++++++++++++ 2 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 backend/pkg/cache/disk/cache_corruption_test.go diff --git a/backend/pkg/cache/disk/cache.go b/backend/pkg/cache/disk/cache.go index a4514957..83b32117 100644 --- a/backend/pkg/cache/disk/cache.go +++ b/backend/pkg/cache/disk/cache.go @@ -143,7 +143,10 @@ func (c *Cache) Set(key string, value []byte, ttl time.Duration) error { // Update memory tracker if elem, ok := c.items[key]; ok { - item := elem.Value.(*cacheItem) + item, ok := elem.Value.(*cacheItem) + if !ok { + return fmt.Errorf("cache: evict list entry for %q has invalid type %T", key, elem.Value) + } c.currentSize += size - item.size item.size = size item.expiredAt = expiredAt @@ -174,7 +177,11 @@ func (c *Cache) Get(key string) ([]byte, error) { return nil, ErrCacheMiss } - item := elem.Value.(*cacheItem) + item, ok := elem.Value.(*cacheItem) + if !ok { + c.mu.RUnlock() + return nil, ErrCacheMiss + } if !item.expiredAt.IsZero() && time.Now().After(item.expiredAt) { c.mu.RUnlock() return c.getAndDeleteIfExpired(key) @@ -224,7 +231,11 @@ func (c *Cache) getAndDeleteIfExpired(key string) ([]byte, error) { return nil, ErrCacheMiss } - item := elem.Value.(*cacheItem) + item, ok := elem.Value.(*cacheItem) + if !ok { + _ = c.deleteUnlocked(key) + return nil, ErrCacheMiss + } if !item.expiredAt.IsZero() && time.Now().After(item.expiredAt) { _ = c.deleteUnlocked(key) return nil, ErrCacheMiss @@ -254,8 +265,9 @@ func (c *Cache) Delete(key string) error { func (c *Cache) deleteUnlocked(key string) error { if elem, ok := c.items[key]; ok { - item := elem.Value.(*cacheItem) - c.currentSize -= item.size + if item, ok := elem.Value.(*cacheItem); ok { + c.currentSize -= item.size + } c.evictList.Remove(elem) delete(c.items, key) } @@ -308,7 +320,11 @@ func (c *Cache) evict() { for c.currentSize > c.maxSize && c.evictList.Len() > 0 { elem := c.evictList.Back() - item := elem.Value.(*cacheItem) + item, ok := elem.Value.(*cacheItem) + if !ok { + c.evictList.Remove(elem) + continue + } c.currentSize -= item.size c.evictList.Remove(elem) delete(c.items, item.key) @@ -400,7 +416,12 @@ func (c *Cache) cleanExpired() { now := time.Now() for key, elem := range c.items { - item := elem.Value.(*cacheItem) + item, ok := elem.Value.(*cacheItem) + if !ok { + c.evictList.Remove(elem) + delete(c.items, key) + continue + } if !item.expiredAt.IsZero() && now.After(item.expiredAt) { c.currentSize -= item.size c.evictList.Remove(elem) diff --git a/backend/pkg/cache/disk/cache_corruption_test.go b/backend/pkg/cache/disk/cache_corruption_test.go new file mode 100644 index 00000000..379c005a --- /dev/null +++ b/backend/pkg/cache/disk/cache_corruption_test.go @@ -0,0 +1,63 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package disk + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// 这些用例锁住「LRU 链表节点被污染时不得 panic」的行为:一旦 items 与 evictList +// 的不变量被破坏(例如后续改动误写节点),缓存必须降级为未命中/跳过, +// 而不是在读、写、删除与淘汰路径上崩掉整个进程。 + +// corruptEntry 写入一个键后把其链表节点值换成非法类型,返回缓存。 +func corruptEntry(t *testing.T, key string) *Cache { + t.Helper() + + c := New(t.TempDir()) + require.NoError(t, c.Set(key, []byte("payload"), time.Minute)) + + elem, ok := c.items[key] + require.True(t, ok, "entry must be tracked after Set") + elem.Value = "not-a-cacheItem" + return c +} + +func TestGetToleratesCorruptEvictEntry(t *testing.T) { + c := corruptEntry(t, "k") + + got, err := c.Get("k") + require.ErrorIs(t, err, ErrCacheMiss) + require.Nil(t, got) +} + +func TestSetOverCorruptEvictEntryReportsError(t *testing.T) { + c := corruptEntry(t, "k") + + err := c.Set("k", []byte("second"), time.Minute) + require.Error(t, err, "Set must report the corrupted tracker entry instead of panicking") + require.Contains(t, err.Error(), "invalid type") +} + +func TestDeleteToleratesCorruptEvictEntry(t *testing.T) { + c := corruptEntry(t, "k") + + require.NotPanics(t, func() { _ = c.Delete("k") }) + require.NotContains(t, c.items, "k") +} + +func TestEvictToleratesCorruptEvictEntry(t *testing.T) { + c := corruptEntry(t, "k") + // 让任意写入都触发淘汰扫描:扫到被污染的节点必须跳过而非 panic。 + c.UpdatePolicy(0, 0, true) + + require.NotPanics(t, func() { + for i := range 4 { + _ = c.Set(string(rune('a'+i)), []byte("x"), time.Minute) + } + }) +} From 374289bfda5cac9af427319095226b80e0bff0b0 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 30 Aug 2026 17:53:42 +0800 Subject: [PATCH 05/44] fix(api): align upload permissions and mount robots and swagger routes --- backend/docs/docs.go | 2572 ++++++++++++++++- backend/docs/swagger.json | 2572 ++++++++++++++++- backend/docs/swagger.yaml | 1600 +++++++++- backend/plugins/domain/admin/plugin.go | 4 + backend/plugins/domain/admin/plugin_test.go | 8 + backend/plugins/domain/upload/plugin.go | 7 +- backend/plugins/domain/upload/plugin_test.go | 14 +- .../drivers/driver_http/engine_slash_test.go | 33 +- .../plugins/drivers/driver_http/frontend.go | 2 +- backend/plugins/drivers/driver_http/plugin.go | 16 + 10 files changed, 6718 insertions(+), 110 deletions(-) diff --git a/backend/docs/docs.go b/backend/docs/docs.go index e3b023ed..21314fc6 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -22,9 +22,55 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { - "/api/cap/challenge": { + "/api/v1/admin/auth-sources": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取认证源列表", + "responses": { + "200": { + "description": "认证源列表", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, "post": { - "description": "客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。", + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限", "consumes": [ "application/json" ], @@ -32,40 +78,41 @@ const docTemplate = `{ "application/json" ], "tags": [ - "cap" + "admin" ], - "summary": "生成人机验证难题", + "summary": "创建认证源", "parameters": [ { - "description": "可选范围限制参数", + "description": "创建认证源参数", "name": "request", "in": "body", + "required": true, "schema": { - "$ref": "#/definitions/cap.challengeRequest" + "$ref": "#/definitions/contracts.AuthSourceDTO" } } ], "responses": { "200": { - "description": "成功返回 PoW 难题", + "description": "创建成功,返回认证源信息", "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/cap.ChallengeResponse" - } - } - } - ] + "$ref": "#/definitions/response.Any" } }, - "500": { - "description": "内部服务错误", + "400": { + "description": "参数错误或验证失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", "schema": { "$ref": "#/definitions/response.Any" } @@ -73,9 +120,14 @@ const docTemplate = `{ } } }, - "/api/cap/redeem": { - "post": { - "description": "提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证", + "/api/v1/admin/auth-sources/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限", "consumes": [ "application/json" ], @@ -83,23 +135,88 @@ const docTemplate = `{ "application/json" ], "tags": [ - "cap" + "admin" ], - "summary": "校验人机验证解答", + "summary": "更新认证源", "parameters": [ { - "description": "难题 Token 与解答 solutions 数组", + "type": "integer", + "format": "int64", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新认证源参数", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/cap.redeemRequest" + "$ref": "#/definitions/contracts.AuthSourceDTO" } } ], "responses": { "200": { - "description": "核销成功,返回 X-Cap-Token", + "description": "更新成功,返回更新后的认证源信息", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误或验证失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除认证源", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", "schema": { "allOf": [ { @@ -109,7 +226,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/cap.RedeemResponse" + "type": "string" } } } @@ -117,13 +234,87 @@ const docTemplate = `{ } }, "400": { - "description": "参数错误或核销失败", + "description": "ID 无效或删除失败", "schema": { "$ref": "#/definitions/response.Any" } }, - "500": { - "description": "内部服务错误", + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/auth-sources/{id}/toggle": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "切换认证源启用状态", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "切换成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "验证失败或认证源不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", "schema": { "$ref": "#/definitions/response.Any" } @@ -1092,6 +1283,615 @@ const docTemplate = `{ } } }, + "/api/v1/admin/push/channels": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统配置的所有消息通道列表,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有消息通道", + "responses": { + "200": { + "description": "消息通道列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.PushChannel" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "新建一个消息通道配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "创建消息通道", + "parameters": [ + { + "description": "创建参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.CreatePushChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.PushChannel" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/channels/definitions": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统支持的所有消息通道类型的动态表单定义,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有消息通道配置字段定义", + "responses": { + "200": { + "description": "通道配置定义列表", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/push/channels/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "触发一次临时的或现有的通道连通性推送测试,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "测试通道连通性", + "parameters": [ + { + "description": "测试参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.TestPushChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "测试触发成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/push/channels/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "修改消息通道配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "更新消息通道", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "通道ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.UpdatePushChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.PushChannel" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据ID删除消息通道,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "删除消息通道", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "通道ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/push/events": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统配置的通知事件列表,包括预置和自定义事件,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有通知事件", + "responses": { + "200": { + "description": "通知事件列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.PushEvent" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "绑定系统内置事件或异步任务、推送渠道、接收目标并创建通知事件配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "创建通知事件", + "parameters": [ + { + "description": "创建参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.CreatePushEventRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.PushEvent" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/events/builtin": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统定义的所有内置通知事件元数据,供前端下拉框选择,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有内置通知事件", + "responses": { + "200": { + "description": "内置通知事件列表", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/push/events/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新已有通知事件的推送渠道、接收目标和内容模板,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "更新通知事件", + "parameters": [ + { + "type": "integer", + "description": "事件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.UpdatePushEventRequest" + } + } + ], + "responses": { + "200": { + "description": "修改成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除数据库中的特定通知事件配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "删除通知事件配置", + "parameters": [ + { + "type": "integer", + "description": "事件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/events/{id}/toggle": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "启用或禁用指定的通知事件", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "快捷切换通知事件启用状态", + "parameters": [ + { + "type": "integer", + "description": "事件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "切换成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/histories": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回分页的通知历史日志数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "分页获取通知推送历史", + "parameters": [ + { + "type": "integer", + "description": "当前页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "分页大小", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "过滤事件名称", + "name": "event_key", + "in": "query" + }, + { + "type": "string", + "description": "过滤发送状态", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "推送历史列表", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/push/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "接收临时通知渠道配置并在本地同步调用 Pusher.Send 发送测试消息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "测试推送通道发送", + "parameters": [ + { + "description": "测试请求体", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.TestPushRequest" + } + } + ], + "responses": { + "200": { + "description": "测试成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, "/api/v1/admin/status": { "get": { "security": [ @@ -2644,6 +3444,87 @@ const docTemplate = `{ } } }, + "/api/v1/admin/uploads": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页获取系统上传的文件列表,支持文件名关键词、业务类型、扩展名、上传用户ID过滤", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取文件列表", + "parameters": [ + { + "type": "integer", + "description": "页码(默认 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(默认 20,最大 100)", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "文件名关键词(模糊匹配)", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "description": "业务分类过滤", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "扩展名过滤", + "name": "extension", + "in": "query" + }, + { + "type": "integer", + "description": "上传用户 ID 过滤", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "查询成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handler.listFilesResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/admin/uploads/download/batch": { "post": { "security": [ @@ -2969,6 +3850,46 @@ const docTemplate = `{ } } }, + "/api/v1/admin/uploads/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将指定 ID 的文件状态置为 deleted(软删除)", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/admin/users": { "get": { "security": [ @@ -3457,6 +4378,164 @@ const docTemplate = `{ } } }, + "/api/v1/cap/challenge": { + "get": { + "description": "客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cap" + ], + "summary": "生成人机验证难题", + "parameters": [ + { + "description": "可选范围限制参数", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/cap.challengeRequest" + } + } + ], + "responses": { + "200": { + "description": "成功返回 PoW 难题", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cap.ChallengeResponse" + } + } + } + ] + } + }, + "500": { + "description": "内部服务错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "description": "客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cap" + ], + "summary": "生成人机验证难题", + "parameters": [ + { + "description": "可选范围限制参数", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/cap.challengeRequest" + } + } + ], + "responses": { + "200": { + "description": "成功返回 PoW 难题", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cap.ChallengeResponse" + } + } + } + ] + } + }, + "500": { + "description": "内部服务错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/cap/redeem": { + "post": { + "description": "提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cap" + ], + "summary": "校验人机验证解答", + "parameters": [ + { + "description": "难题 Token 与解答 solutions 数组", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cap.redeemRequest" + } + } + ], + "responses": { + "200": { + "description": "核销成功,返回 X-Cap-Token", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cap.RedeemResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或核销失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部服务错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/config/public": { "get": { "description": "返回系统配置表中 visibility 为 1 的配置键值集合", @@ -3679,6 +4758,398 @@ const docTemplate = `{ } } }, + "/api/v1/oauth/callback": { + "post": { + "description": "接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "OAuth 回调处理", + "parameters": [ + { + "description": "回调请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.CallbackRequest" + } + } + ], + "responses": { + "200": { + "description": "登录或绑定成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/auth.OAuthCallbackResult" + } + } + } + ] + } + }, + "400": { + "description": "state 无效、参数错误或认证源错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "绑定场景未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "OAuth 认证失败或内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/external-accounts": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取外部帐号列表", + "responses": { + "200": { + "description": "外部帐号列表", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/external-accounts/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "解除当前登录用户与指定外部帐号的绑定关系,需要登录", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "解除外部帐号绑定", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "外部帐号绑定记录 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "解除绑定成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "ID 无效或解除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/login": { + "get": { + "description": "根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取登录授权地址", + "parameters": [ + { + "type": "string", + "description": "认证源名称,为空使用第一个启用的认证源", + "name": "source", + "in": "query" + } + ], + "responses": { + "200": { + "description": "授权 URL", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/auth.OAuthAuthorizeResponse" + } + } + } + ] + } + }, + "400": { + "description": "认证源不存在或未配置", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "构造 URL 失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/logout": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "退出登录", + "responses": { + "200": { + "description": "退出成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Session 清除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/sources": { + "get": { + "description": "返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取可用登录源", + "responses": { + "200": { + "description": "登录源列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/auth.AuthSourceView" + } + } + } + } + ] + } + } + } + } + }, + "/api/v1/oauth/user-info": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的基本信息,需要登录。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取当前登录用户信息", + "responses": { + "200": { + "description": "用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/auth.BasicUserInfo" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/{source}/authorize": { + "get": { + "description": "根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "发起指定认证源授权", + "parameters": [ + { + "type": "string", + "description": "认证源名称", + "name": "source", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "授权目的:login(登录)或 bind(绑定账号),默认 login", + "name": "purpose", + "in": "query" + } + ], + "responses": { + "200": { + "description": "授权 URL", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/auth.OAuthAuthorizeResponse" + } + } + } + ] + } + }, + "400": { + "description": "认证源不存在或未启用", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "构造 URL 失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/upload": { "post": { "security": [ @@ -3947,6 +5418,529 @@ const docTemplate = `{ } } }, + "/api/v1/user-info": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的基本信息,需要登录。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取当前登录用户信息", + "responses": { + "200": { + "description": "用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/auth.BasicUserInfo" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/access-tokens": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的所有 active access tokens(脱敏后)", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "获取当前用户的 AccessToken 列表", + "responses": { + "200": { + "description": "令牌列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/user.AccessToken" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "创建一个新的 AccessToken", + "parameters": [ + { + "description": "令牌名称", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.createAccessTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "新建令牌成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误或超限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/access-tokens/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "撤销并删除一个属于当前用户的 API 访问令牌", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "删除一个 AccessToken", + "parameters": [ + { + "type": "string", + "description": "令牌ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/access-tokens/{id}/rotate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "轮换一个 AccessToken", + "parameters": [ + { + "type": "string", + "description": "令牌ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "令牌轮换成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/change-password": { + "post": { + "description": "修改当前登录用户的密码。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "修改用户密码", + "parameters": [ + { + "description": "修改密码请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.changePasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "修改密码成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "原密码错误或新密码不符合要求", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "请先登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/login": { + "post": { + "description": "使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户密码登录", + "parameters": [ + { + "description": "登录请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.loginRequest" + } + } + ], + "responses": { + "200": { + "description": "登录成功,返回用户信息", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "用户名或密码错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/logout": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清除用户登录 Session,完成退出", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户退出登录", + "responses": { + "200": { + "description": "退出成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Session 清除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/profile": { + "put": { + "description": "修改当前登录用户的昵称、头像、简介、电话、性别、个人网站和所在地。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "修改当前登录用户的个人资料", + "parameters": [ + { + "description": "更新请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.updateProfileRequest" + } + } + ], + "responses": { + "200": { + "description": "修改成功,返回更新后的用户信息", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/register": { + "post": { + "description": "使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户注册", + "parameters": [ + { + "description": "注册请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.registerRequest" + } + } + ], + "responses": { + "200": { + "description": "注册并登录成功,返回用户信息", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误、用户名已存在或注册已关闭", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/self": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的基本信息,需要登录。", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "获取当前登录用户信息", + "responses": { + "200": { + "description": "用户信息", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/send-email-code": { + "post": { + "description": "向指定邮箱发送验证码(用于注册场景)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "发送邮箱验证码", + "responses": { + "200": { + "description": "发送成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/f/{id}": { "get": { "description": "根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回", @@ -4028,6 +6022,107 @@ const docTemplate = `{ } }, "definitions": { + "auth.AuthSourceView": { + "type": "object", + "properties": { + "client_secret_configured": { + "type": "boolean" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "auth.BasicUserInfo": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "email": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "need_change_password": { + "type": "boolean" + }, + "nickname": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "username": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "auth.CallbackRequest": { + "type": "object", + "required": [ + "code", + "state" + ], + "properties": { + "code": { + "type": "string" + }, + "state": { + "type": "string" + } + } + }, + "auth.OAuthAuthorizeResponse": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string" + } + } + }, + "auth.OAuthCallbackResult": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/auth.BasicUserInfo" + } + } + }, "cap.ChallengeResponse": { "type": "object", "properties": { @@ -4100,6 +6195,48 @@ const docTemplate = `{ } } }, + "contracts.AuthSourceDTO": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "openid_discovery_url": { + "type": "string" + }, + "scopes": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "contracts.TaskMetaDTO": { "type": "object", "properties": { @@ -4509,6 +6646,65 @@ const docTemplate = `{ } } }, + "model.CreatePushChannelRequest": { + "type": "object", + "required": [ + "name", + "type" + ], + "properties": { + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "other": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "model.CreatePushEventRequest": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "event_key": { + "type": "string" + }, + "targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "task_type": { + "type": "string" + }, + "template": { + "type": "string" + } + } + }, "model.CreateScheduleRequest": { "type": "object", "required": [ @@ -4858,6 +7054,82 @@ const docTemplate = `{ } } }, + "model.PushChannel": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "other": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "model.PushEvent": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "event_key": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "task_type": { + "type": "string" + }, + "template": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "model.Schedule": { "type": "object", "properties": { @@ -5113,6 +7385,43 @@ const docTemplate = `{ } } }, + "model.TestPushChannelRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "other": { + "type": "string" + }, + "target": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "model.TestPushRequest": { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "$ref": "#/definitions/push.Config" + }, + "target": { + "type": "string" + } + } + }, "model.TestSMTPRequest": { "type": "object", "required": [ @@ -5229,6 +7538,58 @@ const docTemplate = `{ } } }, + "model.UpdatePushChannelRequest": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "other": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "model.UpdatePushEventRequest": { + "type": "object", + "required": [ + "template" + ], + "properties": { + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "string" + } + } + }, "model.UpdateScheduleRequest": { "type": "object", "required": [ @@ -5535,6 +7896,32 @@ const docTemplate = `{ "UploadStatusDeleted" ] }, + "push.Config": { + "type": "object", + "properties": { + "channel": { + "description": "渠道名称,例如 \"lark\", \"custom\", \"email\" 等,唯一标识", + "type": "string" + }, + "ext": { + "description": "预留拓展 JSON 配置", + "type": "object", + "additionalProperties": {} + }, + "key": { + "description": "AppID 或 SMTP 用户名", + "type": "string" + }, + "secret": { + "description": "签名密钥或 SMTP 密码/Token", + "type": "string" + }, + "url": { + "description": "Webhook 地址或 SMTP 地址", + "type": "string" + } + } + }, "response.Any": { "type": "object", "properties": { @@ -5568,6 +7955,123 @@ const docTemplate = `{ "Minute", "Hour" ] + }, + "user.AccessToken": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "masked_token": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "user.changePasswordRequest": { + "type": "object", + "required": [ + "new_password", + "old_password" + ], + "properties": { + "new_password": { + "type": "string" + }, + "old_password": { + "type": "string" + } + } + }, + "user.createAccessTokenRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "expires_at": { + "type": "string" + }, + "is_admin": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, + "user.loginRequest": { + "type": "object", + "required": [ + "password", + "username" + ], + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "user.registerRequest": { + "type": "object", + "required": [ + "password", + "username" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "user.updateProfileRequest": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "location": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "website": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index e87db4e5..84f27bc9 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -15,9 +15,55 @@ }, "basePath": "/", "paths": { - "/api/cap/challenge": { + "/api/v1/admin/auth-sources": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取认证源列表", + "responses": { + "200": { + "description": "认证源列表", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, "post": { - "description": "客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。", + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限", "consumes": [ "application/json" ], @@ -25,40 +71,41 @@ "application/json" ], "tags": [ - "cap" + "admin" ], - "summary": "生成人机验证难题", + "summary": "创建认证源", "parameters": [ { - "description": "可选范围限制参数", + "description": "创建认证源参数", "name": "request", "in": "body", + "required": true, "schema": { - "$ref": "#/definitions/cap.challengeRequest" + "$ref": "#/definitions/contracts.AuthSourceDTO" } } ], "responses": { "200": { - "description": "成功返回 PoW 难题", + "description": "创建成功,返回认证源信息", "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/cap.ChallengeResponse" - } - } - } - ] + "$ref": "#/definitions/response.Any" } }, - "500": { - "description": "内部服务错误", + "400": { + "description": "参数错误或验证失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", "schema": { "$ref": "#/definitions/response.Any" } @@ -66,9 +113,14 @@ } } }, - "/api/cap/redeem": { - "post": { - "description": "提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证", + "/api/v1/admin/auth-sources/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限", "consumes": [ "application/json" ], @@ -76,23 +128,88 @@ "application/json" ], "tags": [ - "cap" + "admin" ], - "summary": "校验人机验证解答", + "summary": "更新认证源", "parameters": [ { - "description": "难题 Token 与解答 solutions 数组", + "type": "integer", + "format": "int64", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新认证源参数", "name": "request", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/cap.redeemRequest" + "$ref": "#/definitions/contracts.AuthSourceDTO" } } ], "responses": { "200": { - "description": "核销成功,返回 X-Cap-Token", + "description": "更新成功,返回更新后的认证源信息", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误或验证失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除认证源", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", "schema": { "allOf": [ { @@ -102,7 +219,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/cap.RedeemResponse" + "type": "string" } } } @@ -110,13 +227,87 @@ } }, "400": { - "description": "参数错误或核销失败", + "description": "ID 无效或删除失败", "schema": { "$ref": "#/definitions/response.Any" } }, - "500": { - "description": "内部服务错误", + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/auth-sources/{id}/toggle": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "切换认证源启用状态", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "切换成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "验证失败或认证源不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "403": { + "description": "无管理员权限", "schema": { "$ref": "#/definitions/response.Any" } @@ -1085,6 +1276,615 @@ } } }, + "/api/v1/admin/push/channels": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统配置的所有消息通道列表,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有消息通道", + "responses": { + "200": { + "description": "消息通道列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.PushChannel" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "新建一个消息通道配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "创建消息通道", + "parameters": [ + { + "description": "创建参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.CreatePushChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.PushChannel" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/channels/definitions": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统支持的所有消息通道类型的动态表单定义,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有消息通道配置字段定义", + "responses": { + "200": { + "description": "通道配置定义列表", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/push/channels/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "触发一次临时的或现有的通道连通性推送测试,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "测试通道连通性", + "parameters": [ + { + "description": "测试参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.TestPushChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "测试触发成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/push/channels/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "修改消息通道配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "更新消息通道", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "通道ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.UpdatePushChannelRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.PushChannel" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据ID删除消息通道,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "删除消息通道", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "通道ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/push/events": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统配置的通知事件列表,包括预置和自定义事件,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有通知事件", + "responses": { + "200": { + "description": "通知事件列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.PushEvent" + } + } + } + } + ] + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "绑定系统内置事件或异步任务、推送渠道、接收目标并创建通知事件配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "创建通知事件", + "parameters": [ + { + "description": "创建参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.CreatePushEventRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.PushEvent" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/events/builtin": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统定义的所有内置通知事件元数据,供前端下拉框选择,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "获取所有内置通知事件", + "responses": { + "200": { + "description": "内置通知事件列表", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/push/events/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新已有通知事件的推送渠道、接收目标和内容模板,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "更新通知事件", + "parameters": [ + { + "type": "integer", + "description": "事件 ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.UpdatePushEventRequest" + } + } + ], + "responses": { + "200": { + "description": "修改成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除数据库中的特定通知事件配置,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "删除通知事件配置", + "parameters": [ + { + "type": "integer", + "description": "事件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/events/{id}/toggle": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "启用或禁用指定的通知事件", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "快捷切换通知事件启用状态", + "parameters": [ + { + "type": "integer", + "description": "事件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "切换成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, + "/api/v1/admin/push/histories": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回分页的通知历史日志数据,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "分页获取通知推送历史", + "parameters": [ + { + "type": "integer", + "description": "当前页码", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "分页大小", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "过滤事件名称", + "name": "event_key", + "in": "query" + }, + { + "type": "string", + "description": "过滤发送状态", + "name": "status", + "in": "query" + } + ], + "responses": { + "200": { + "description": "推送历史列表", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/admin/push/test": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "接收临时通知渠道配置并在本地同步调用 Pusher.Send 发送测试消息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin-push" + ], + "summary": "测试推送通道发送", + "parameters": [ + { + "description": "测试请求体", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/model.TestPushRequest" + } + } + ], + "responses": { + "200": { + "description": "测试成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + } + } + } + }, "/api/v1/admin/status": { "get": { "security": [ @@ -2637,6 +3437,87 @@ } } }, + "/api/v1/admin/uploads": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "分页获取系统上传的文件列表,支持文件名关键词、业务类型、扩展名、上传用户ID过滤", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取文件列表", + "parameters": [ + { + "type": "integer", + "description": "页码(默认 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "每页数量(默认 20,最大 100)", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "文件名关键词(模糊匹配)", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "description": "业务分类过滤", + "name": "type", + "in": "query" + }, + { + "type": "string", + "description": "扩展名过滤", + "name": "extension", + "in": "query" + }, + { + "type": "integer", + "description": "上传用户 ID 过滤", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "查询成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/handler.listFilesResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/admin/uploads/download/batch": { "post": { "security": [ @@ -2962,6 +3843,46 @@ } } }, + "/api/v1/admin/uploads/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "将指定 ID 的文件状态置为 deleted(软删除)", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/admin/users": { "get": { "security": [ @@ -3450,6 +4371,164 @@ } } }, + "/api/v1/cap/challenge": { + "get": { + "description": "客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cap" + ], + "summary": "生成人机验证难题", + "parameters": [ + { + "description": "可选范围限制参数", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/cap.challengeRequest" + } + } + ], + "responses": { + "200": { + "description": "成功返回 PoW 难题", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cap.ChallengeResponse" + } + } + } + ] + } + }, + "500": { + "description": "内部服务错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "description": "客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cap" + ], + "summary": "生成人机验证难题", + "parameters": [ + { + "description": "可选范围限制参数", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/cap.challengeRequest" + } + } + ], + "responses": { + "200": { + "description": "成功返回 PoW 难题", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cap.ChallengeResponse" + } + } + } + ] + } + }, + "500": { + "description": "内部服务错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/cap/redeem": { + "post": { + "description": "提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "cap" + ], + "summary": "校验人机验证解答", + "parameters": [ + { + "description": "难题 Token 与解答 solutions 数组", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cap.redeemRequest" + } + } + ], + "responses": { + "200": { + "description": "核销成功,返回 X-Cap-Token", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/cap.RedeemResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或核销失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部服务错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/config/public": { "get": { "description": "返回系统配置表中 visibility 为 1 的配置键值集合", @@ -3672,6 +4751,398 @@ } } }, + "/api/v1/oauth/callback": { + "post": { + "description": "接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "OAuth 回调处理", + "parameters": [ + { + "description": "回调请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.CallbackRequest" + } + } + ], + "responses": { + "200": { + "description": "登录或绑定成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/auth.OAuthCallbackResult" + } + } + } + ] + } + }, + "400": { + "description": "state 无效、参数错误或认证源错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "绑定场景未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "OAuth 认证失败或内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/external-accounts": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取外部帐号列表", + "responses": { + "200": { + "description": "外部帐号列表", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/external-accounts/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "解除当前登录用户与指定外部帐号的绑定关系,需要登录", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "解除外部帐号绑定", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "外部帐号绑定记录 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "解除绑定成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "ID 无效或解除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/login": { + "get": { + "description": "根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取登录授权地址", + "parameters": [ + { + "type": "string", + "description": "认证源名称,为空使用第一个启用的认证源", + "name": "source", + "in": "query" + } + ], + "responses": { + "200": { + "description": "授权 URL", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/auth.OAuthAuthorizeResponse" + } + } + } + ] + } + }, + "400": { + "description": "认证源不存在或未配置", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "构造 URL 失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/logout": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "退出登录", + "responses": { + "200": { + "description": "退出成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Session 清除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/sources": { + "get": { + "description": "返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取可用登录源", + "responses": { + "200": { + "description": "登录源列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/auth.AuthSourceView" + } + } + } + } + ] + } + } + } + } + }, + "/api/v1/oauth/user-info": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的基本信息,需要登录。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取当前登录用户信息", + "responses": { + "200": { + "description": "用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/auth.BasicUserInfo" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/oauth/{source}/authorize": { + "get": { + "description": "根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "发起指定认证源授权", + "parameters": [ + { + "type": "string", + "description": "认证源名称", + "name": "source", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "授权目的:login(登录)或 bind(绑定账号),默认 login", + "name": "purpose", + "in": "query" + } + ], + "responses": { + "200": { + "description": "授权 URL", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/auth.OAuthAuthorizeResponse" + } + } + } + ] + } + }, + "400": { + "description": "认证源不存在或未启用", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "构造 URL 失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/api/v1/upload": { "post": { "security": [ @@ -3940,6 +5411,529 @@ } } }, + "/api/v1/user-info": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的基本信息,需要登录。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取当前登录用户信息", + "responses": { + "200": { + "description": "用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/auth.BasicUserInfo" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/access-tokens": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的所有 active access tokens(脱敏后)", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "获取当前用户的 AccessToken 列表", + "responses": { + "200": { + "description": "令牌列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/user.AccessToken" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "创建一个新的 AccessToken", + "parameters": [ + { + "description": "令牌名称", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.createAccessTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "新建令牌成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误或超限", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/access-tokens/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "撤销并删除一个属于当前用户的 API 访问令牌", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "删除一个 AccessToken", + "parameters": [ + { + "type": "string", + "description": "令牌ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/access-tokens/{id}/rotate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "轮换一个 AccessToken", + "parameters": [ + { + "type": "string", + "description": "令牌ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "令牌轮换成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/change-password": { + "post": { + "description": "修改当前登录用户的密码。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "修改用户密码", + "parameters": [ + { + "description": "修改密码请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.changePasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "修改密码成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "原密码错误或新密码不符合要求", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "请先登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/login": { + "post": { + "description": "使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户密码登录", + "parameters": [ + { + "description": "登录请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.loginRequest" + } + } + ], + "responses": { + "200": { + "description": "登录成功,返回用户信息", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "用户名或密码错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/logout": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清除用户登录 Session,完成退出", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户退出登录", + "responses": { + "200": { + "description": "退出成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.Any" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Session 清除失败", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/profile": { + "put": { + "description": "修改当前登录用户的昵称、头像、简介、电话、性别、个人网站和所在地。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "修改当前登录用户的个人资料", + "parameters": [ + { + "description": "更新请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.updateProfileRequest" + } + } + ], + "responses": { + "200": { + "description": "修改成功,返回更新后的用户信息", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/register": { + "post": { + "description": "使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户注册", + "parameters": [ + { + "description": "注册请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.registerRequest" + } + } + ], + "responses": { + "200": { + "description": "注册并登录成功,返回用户信息", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误、用户名已存在或注册已关闭", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/self": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的基本信息,需要登录。", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "获取当前登录用户信息", + "responses": { + "200": { + "description": "用户信息", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, + "/api/v1/user/send-email-code": { + "post": { + "description": "向指定邮箱发送验证码(用于注册场景)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "发送邮箱验证码", + "responses": { + "200": { + "description": "发送成功", + "schema": { + "$ref": "#/definitions/response.Any" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/response.Any" + } + } + } + } + }, "/f/{id}": { "get": { "description": "根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回", @@ -4021,6 +6015,107 @@ } }, "definitions": { + "auth.AuthSourceView": { + "type": "object", + "properties": { + "client_secret_configured": { + "type": "boolean" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "auth.BasicUserInfo": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "email": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "need_change_password": { + "type": "boolean" + }, + "nickname": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "username": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "auth.CallbackRequest": { + "type": "object", + "required": [ + "code", + "state" + ], + "properties": { + "code": { + "type": "string" + }, + "state": { + "type": "string" + } + } + }, + "auth.OAuthAuthorizeResponse": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string" + } + } + }, + "auth.OAuthCallbackResult": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/auth.BasicUserInfo" + } + } + }, "cap.ChallengeResponse": { "type": "object", "properties": { @@ -4093,6 +6188,48 @@ } } }, + "contracts.AuthSourceDTO": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "openid_discovery_url": { + "type": "string" + }, + "scopes": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "contracts.TaskMetaDTO": { "type": "object", "properties": { @@ -4502,6 +6639,65 @@ } } }, + "model.CreatePushChannelRequest": { + "type": "object", + "required": [ + "name", + "type" + ], + "properties": { + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "other": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "model.CreatePushEventRequest": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "event_key": { + "type": "string" + }, + "targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "task_type": { + "type": "string" + }, + "template": { + "type": "string" + } + } + }, "model.CreateScheduleRequest": { "type": "object", "required": [ @@ -4851,6 +7047,82 @@ } } }, + "model.PushChannel": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "other": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "model.PushEvent": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "created_at": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "event_key": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "task_type": { + "type": "string" + }, + "template": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "model.Schedule": { "type": "object", "properties": { @@ -5106,6 +7378,43 @@ } } }, + "model.TestPushChannelRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "other": { + "type": "string" + }, + "target": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "model.TestPushRequest": { + "type": "object", + "required": [ + "config" + ], + "properties": { + "config": { + "$ref": "#/definitions/push.Config" + }, + "target": { + "type": "string" + } + } + }, "model.TestSMTPRequest": { "type": "object", "required": [ @@ -5222,6 +7531,58 @@ } } }, + "model.UpdatePushChannelRequest": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "other": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "model.UpdatePushEventRequest": { + "type": "object", + "required": [ + "template" + ], + "properties": { + "channels": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "targets": { + "type": "array", + "items": { + "type": "string" + } + }, + "template": { + "type": "string" + } + } + }, "model.UpdateScheduleRequest": { "type": "object", "required": [ @@ -5528,6 +7889,32 @@ "UploadStatusDeleted" ] }, + "push.Config": { + "type": "object", + "properties": { + "channel": { + "description": "渠道名称,例如 \"lark\", \"custom\", \"email\" 等,唯一标识", + "type": "string" + }, + "ext": { + "description": "预留拓展 JSON 配置", + "type": "object", + "additionalProperties": {} + }, + "key": { + "description": "AppID 或 SMTP 用户名", + "type": "string" + }, + "secret": { + "description": "签名密钥或 SMTP 密码/Token", + "type": "string" + }, + "url": { + "description": "Webhook 地址或 SMTP 地址", + "type": "string" + } + } + }, "response.Any": { "type": "object", "properties": { @@ -5561,6 +7948,123 @@ "Minute", "Hour" ] + }, + "user.AccessToken": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "masked_token": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "user.changePasswordRequest": { + "type": "object", + "required": [ + "new_password", + "old_password" + ], + "properties": { + "new_password": { + "type": "string" + }, + "old_password": { + "type": "string" + } + } + }, + "user.createAccessTokenRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "expires_at": { + "type": "string" + }, + "is_admin": { + "type": "boolean" + }, + "name": { + "type": "string" + } + } + }, + "user.loginRequest": { + "type": "object", + "required": [ + "password", + "username" + ], + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "user.registerRequest": { + "type": "object", + "required": [ + "password", + "username" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "user.updateProfileRequest": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "location": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "website": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 119fe1c1..555b8a9c 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1,5 +1,71 @@ basePath: / definitions: + auth.AuthSourceView: + properties: + client_secret_configured: + type: boolean + display_name: + type: string + icon_url: + type: string + id: + type: integer + is_active: + type: boolean + name: + type: string + type: + type: string + type: object + auth.BasicUserInfo: + properties: + avatar_url: + type: string + bio: + type: string + email: + type: string + gender: + type: string + id: + type: integer + is_admin: + type: boolean + location: + type: string + need_change_password: + type: boolean + nickname: + type: string + phone: + type: string + username: + type: string + website: + type: string + type: object + auth.CallbackRequest: + properties: + code: + type: string + state: + type: string + required: + - code + - state + type: object + auth.OAuthAuthorizeResponse: + properties: + authorize_url: + type: string + type: object + auth.OAuthCallbackResult: + properties: + status: + type: string + user: + $ref: '#/definitions/auth.BasicUserInfo' + type: object cap.ChallengeResponse: properties: challenge: @@ -47,6 +113,34 @@ definitions: - solutions - token type: object + contracts.AuthSourceDTO: + properties: + client_id: + type: string + client_secret: + type: string + created_at: + type: string + display_name: + type: string + icon_url: + type: string + id: + example: "0" + type: string + is_active: + type: boolean + name: + type: string + openid_discovery_url: + type: string + scopes: + type: string + type: + type: string + updated_at: + type: string + type: object contracts.TaskMetaDTO: properties: asynq_task: @@ -318,6 +412,45 @@ definitions: type: type: string type: object + model.CreatePushChannelRequest: + properties: + description: + type: string + enabled: + type: boolean + name: + type: string + other: + type: string + token: + type: string + type: + type: string + url: + type: string + required: + - name + - type + type: object + model.CreatePushEventRequest: + properties: + channels: + items: + type: string + type: array + enabled: + type: boolean + event_key: + type: string + targets: + items: + type: string + type: array + task_type: + type: string + template: + type: string + type: object model.CreateScheduleRequest: properties: cron: @@ -555,6 +688,56 @@ definitions: type: type: string type: object + model.PushChannel: + properties: + created_at: + type: string + description: + type: string + enabled: + type: boolean + id: + type: integer + name: + type: string + other: + type: string + token: + type: string + type: + type: string + updated_at: + type: string + url: + type: string + type: object + model.PushEvent: + properties: + channels: + items: + type: string + type: array + created_at: + type: string + enabled: + type: boolean + event_key: + type: string + id: + type: integer + name: + type: string + targets: + items: + type: string + type: array + task_type: + type: string + template: + type: string + updated_at: + type: string + type: object model.Schedule: properties: created_at: @@ -726,6 +909,30 @@ definitions: updated_at: type: string type: object + model.TestPushChannelRequest: + properties: + name: + type: string + other: + type: string + target: + type: string + token: + type: string + type: + type: string + url: + type: string + type: object + model.TestPushRequest: + properties: + config: + $ref: '#/definitions/push.Config' + target: + type: string + required: + - config + type: object model.TestSMTPRequest: properties: smtp_host: @@ -805,6 +1012,40 @@ definitions: name: type: string type: object + model.UpdatePushChannelRequest: + properties: + description: + type: string + enabled: + type: boolean + other: + type: string + token: + type: string + type: + type: string + url: + type: string + required: + - type + type: object + model.UpdatePushEventRequest: + properties: + channels: + items: + type: string + type: array + enabled: + type: boolean + targets: + items: + type: string + type: array + template: + type: string + required: + - template + type: object model.UpdateScheduleRequest: properties: cron: @@ -1015,6 +1256,25 @@ definitions: - UploadStatusPending - UploadStatusUsed - UploadStatusDeleted + push.Config: + properties: + channel: + description: 渠道名称,例如 "lark", "custom", "email" 等,唯一标识 + type: string + ext: + additionalProperties: {} + description: 预留拓展 JSON 配置 + type: object + key: + description: AppID 或 SMTP 用户名 + type: string + secret: + description: 签名密钥或 SMTP 密码/Token + type: string + url: + description: Webhook 地址或 SMTP 地址 + type: string + type: object response.Any: properties: data: {} @@ -1043,6 +1303,83 @@ definitions: - Second - Minute - Hour + user.AccessToken: + properties: + created_at: + type: string + id: + type: integer + is_admin: + type: boolean + masked_token: + type: string + name: + type: string + updated_at: + type: string + user_id: + type: integer + type: object + user.changePasswordRequest: + properties: + new_password: + type: string + old_password: + type: string + required: + - new_password + - old_password + type: object + user.createAccessTokenRequest: + properties: + expires_at: + type: string + is_admin: + type: boolean + name: + type: string + required: + - name + type: object + user.loginRequest: + properties: + password: + type: string + username: + type: string + required: + - password + - username + type: object + user.registerRequest: + properties: + email: + type: string + password: + type: string + username: + type: string + required: + - password + - username + type: object + user.updateProfileRequest: + properties: + avatar_url: + type: string + bio: + type: string + gender: + type: string + location: + type: string + nickname: + type: string + phone: + type: string + website: + type: string + type: object info: contact: name: Wavelet @@ -1054,71 +1391,193 @@ info: title: Wavelet API version: 1.0.0 paths: - /api/cap/challenge: - post: - consumes: - - application/json - description: 客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。 - parameters: - - description: 可选范围限制参数 - in: body - name: request - schema: - $ref: '#/definitions/cap.challengeRequest' + /api/v1/admin/auth-sources: + get: + description: 返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限 produces: - application/json responses: "200": - description: 成功返回 PoW 难题 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/cap.ChallengeResponse' - type: object - "500": - description: 内部服务错误 + description: 认证源列表 schema: $ref: '#/definitions/response.Any' - summary: 生成人机验证难题 + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取认证源列表 tags: - - cap - /api/cap/redeem: + - admin post: consumes: - application/json - description: 提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证 + description: 创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限 parameters: - - description: 难题 Token 与解答 solutions 数组 + - description: 创建认证源参数 in: body name: request required: true schema: - $ref: '#/definitions/cap.redeemRequest' + $ref: '#/definitions/contracts.AuthSourceDTO' produces: - application/json responses: "200": - description: 核销成功,返回 X-Cap-Token + description: 创建成功,返回认证源信息 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误或验证失败 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建认证源 + tags: + - admin + /api/v1/admin/auth-sources/{id}: + delete: + description: 删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限 + parameters: + - description: 认证源 ID 或名称 + format: int64 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 schema: allOf: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/cap.RedeemResponse' + type: string type: object "400": - description: 参数错误或核销失败 + description: ID 无效或删除失败 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除认证源 + tags: + - admin + put: + consumes: + - application/json + description: 更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限 + parameters: + - description: 认证源 ID 或名称 + format: int64 + in: path + name: id + required: true + type: integer + - description: 更新认证源参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/contracts.AuthSourceDTO' + produces: + - application/json + responses: + "200": + description: 更新成功,返回更新后的认证源信息 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误或验证失败 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 schema: $ref: '#/definitions/response.Any' "500": - description: 内部服务错误 + description: 内部错误 schema: $ref: '#/definitions/response.Any' - summary: 校验人机验证解答 + security: + - SessionCookie: [] + summary: 更新认证源 tags: - - cap + - admin + /api/v1/admin/auth-sources/{id}/toggle: + put: + consumes: + - application/json + description: 启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限 + parameters: + - description: 认证源 ID 或名称 + format: int64 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 切换成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 验证失败或认证源不存在 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 切换认证源启用状态 + tags: + - admin /api/v1/admin/cache/clear: post: description: 清除系统磁盘缓存目录中的所有临时文件,并重置缓存容量和 Key 追踪数据 @@ -1696,6 +2155,362 @@ paths: summary: List message gateway channel definitions tags: - admin-message-gateway + /api/v1/admin/push/channels: + get: + description: 返回系统配置的所有消息通道列表,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 消息通道列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.PushChannel' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取所有消息通道 + tags: + - admin-push + post: + consumes: + - application/json + description: 新建一个消息通道配置,需要管理员权限 + parameters: + - description: 创建参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/model.CreatePushChannelRequest' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.PushChannel' + type: object + security: + - SessionCookie: [] + summary: 创建消息通道 + tags: + - admin-push + /api/v1/admin/push/channels/{id}: + delete: + description: 根据ID删除消息通道,需要管理员权限 + parameters: + - description: 通道ID + format: int64 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除消息通道 + tags: + - admin-push + put: + consumes: + - application/json + description: 修改消息通道配置,需要管理员权限 + parameters: + - description: 通道ID + format: int64 + in: path + name: id + required: true + type: integer + - description: 更新参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/model.UpdatePushChannelRequest' + produces: + - application/json + responses: + "200": + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.PushChannel' + type: object + security: + - SessionCookie: [] + summary: 更新消息通道 + tags: + - admin-push + /api/v1/admin/push/channels/definitions: + get: + description: 返回系统支持的所有消息通道类型的动态表单定义,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 通道配置定义列表 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取所有消息通道配置字段定义 + tags: + - admin-push + /api/v1/admin/push/channels/test: + post: + consumes: + - application/json + description: 触发一次临时的或现有的通道连通性推送测试,需要管理员权限 + parameters: + - description: 测试参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/model.TestPushChannelRequest' + produces: + - application/json + responses: + "200": + description: 测试触发成功 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 测试通道连通性 + tags: + - admin-push + /api/v1/admin/push/events: + get: + description: 返回系统配置的通知事件列表,包括预置和自定义事件,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 通知事件列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/model.PushEvent' + type: array + type: object + security: + - SessionCookie: [] + summary: 获取所有通知事件 + tags: + - admin-push + post: + consumes: + - application/json + description: 绑定系统内置事件或异步任务、推送渠道、接收目标并创建通知事件配置,需要管理员权限 + parameters: + - description: 创建参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/model.CreatePushEventRequest' + produces: + - application/json + responses: + "200": + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/model.PushEvent' + type: object + security: + - SessionCookie: [] + summary: 创建通知事件 + tags: + - admin-push + /api/v1/admin/push/events/{id}: + delete: + description: 删除数据库中的特定通知事件配置,需要管理员权限 + parameters: + - description: 事件 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + security: + - SessionCookie: [] + summary: 删除通知事件配置 + tags: + - admin-push + put: + consumes: + - application/json + description: 更新已有通知事件的推送渠道、接收目标和内容模板,需要管理员权限 + parameters: + - description: 事件 ID + in: path + name: id + required: true + type: integer + - description: 更新参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/model.UpdatePushEventRequest' + produces: + - application/json + responses: + "200": + description: 修改成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + security: + - SessionCookie: [] + summary: 更新通知事件 + tags: + - admin-push + /api/v1/admin/push/events/{id}/toggle: + post: + description: 启用或禁用指定的通知事件 + parameters: + - description: 事件 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 切换成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + security: + - SessionCookie: [] + summary: 快捷切换通知事件启用状态 + tags: + - admin-push + /api/v1/admin/push/events/builtin: + get: + description: 返回系统定义的所有内置通知事件元数据,供前端下拉框选择,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 内置通知事件列表 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取所有内置通知事件 + tags: + - admin-push + /api/v1/admin/push/histories: + get: + description: 返回分页的通知历史日志数据,需要管理员权限 + parameters: + - description: 当前页码 + in: query + name: page + type: integer + - description: 分页大小 + in: query + name: page_size + type: integer + - description: 过滤事件名称 + in: query + name: event_key + type: string + - description: 过滤发送状态 + in: query + name: status + type: string + produces: + - application/json + responses: + "200": + description: 推送历史列表 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 分页获取通知推送历史 + tags: + - admin-push + /api/v1/admin/push/test: + post: + consumes: + - application/json + description: 接收临时通知渠道配置并在本地同步调用 Pusher.Send 发送测试消息 + parameters: + - description: 测试请求体 + in: body + name: request + required: true + schema: + $ref: '#/definitions/model.TestPushRequest' + produces: + - application/json + responses: + "200": + description: 测试成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + security: + - SessionCookie: [] + summary: 测试推送通道发送 + tags: + - admin-push /api/v1/admin/status: get: description: 获取后端服务运行状态、Goroutine、内存指标等详细统计数据,需要管理员权限 @@ -2627,6 +3442,80 @@ paths: summary: 下载并应用应用更新 tags: - admin + /api/v1/admin/uploads: + get: + description: 分页获取系统上传的文件列表,支持文件名关键词、业务类型、扩展名、上传用户ID过滤 + parameters: + - description: 页码(默认 1) + in: query + name: page + type: integer + - description: 每页数量(默认 20,最大 100) + in: query + name: page_size + type: integer + - description: 文件名关键词(模糊匹配) + in: query + name: keyword + type: string + - description: 业务分类过滤 + in: query + name: type + type: string + - description: 扩展名过滤 + in: query + name: extension + type: string + - description: 上传用户 ID 过滤 + in: query + name: user_id + type: integer + produces: + - application/json + responses: + "200": + description: 查询成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/handler.listFilesResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取文件列表 + tags: + - admin + /api/v1/admin/uploads/{id}: + delete: + description: 将指定 ID 的文件状态置为 deleted(软删除) + parameters: + - description: 文件 ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + $ref: '#/definitions/response.Any' + "404": + description: 文件不存在 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除文件 + tags: + - admin /api/v1/admin/uploads/download/{id}: get: description: 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载 @@ -3121,6 +4010,100 @@ paths: summary: 更新用户状态 tags: - admin + /api/v1/cap/challenge: + get: + consumes: + - application/json + description: 客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。 + parameters: + - description: 可选范围限制参数 + in: body + name: request + schema: + $ref: '#/definitions/cap.challengeRequest' + produces: + - application/json + responses: + "200": + description: 成功返回 PoW 难题 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cap.ChallengeResponse' + type: object + "500": + description: 内部服务错误 + schema: + $ref: '#/definitions/response.Any' + summary: 生成人机验证难题 + tags: + - cap + post: + consumes: + - application/json + description: 客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。 + parameters: + - description: 可选范围限制参数 + in: body + name: request + schema: + $ref: '#/definitions/cap.challengeRequest' + produces: + - application/json + responses: + "200": + description: 成功返回 PoW 难题 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cap.ChallengeResponse' + type: object + "500": + description: 内部服务错误 + schema: + $ref: '#/definitions/response.Any' + summary: 生成人机验证难题 + tags: + - cap + /api/v1/cap/redeem: + post: + consumes: + - application/json + description: 提交 PoW 解答进行核销,成功后返回一次性 X-Cap-Token 凭证 + parameters: + - description: 难题 Token 与解答 solutions 数组 + in: body + name: request + required: true + schema: + $ref: '#/definitions/cap.redeemRequest' + produces: + - application/json + responses: + "200": + description: 核销成功,返回 X-Cap-Token + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/cap.RedeemResponse' + type: object + "400": + description: 参数错误或核销失败 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部服务错误 + schema: + $ref: '#/definitions/response.Any' + summary: 校验人机验证解答 + tags: + - cap /api/v1/config/public: get: consumes: @@ -3254,6 +4237,238 @@ paths: summary: List enabled messaging channels tags: - message-gateway + /api/v1/oauth/{source}/authorize: + get: + description: 根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。 + parameters: + - description: 认证源名称 + in: path + name: source + required: true + type: string + - description: 授权目的:login(登录)或 bind(绑定账号),默认 login + in: query + name: purpose + type: string + produces: + - application/json + responses: + "200": + description: 授权 URL + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/auth.OAuthAuthorizeResponse' + type: object + "400": + description: 认证源不存在或未启用 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 构造 URL 失败 + schema: + $ref: '#/definitions/response.Any' + summary: 发起指定认证源授权 + tags: + - oauth + /api/v1/oauth/callback: + post: + consumes: + - application/json + description: 接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。 + parameters: + - description: 回调请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth.CallbackRequest' + produces: + - application/json + responses: + "200": + description: 登录或绑定成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/auth.OAuthCallbackResult' + type: object + "400": + description: state 无效、参数错误或认证源错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 绑定场景未登录 + schema: + $ref: '#/definitions/response.Any' + "500": + description: OAuth 认证失败或内部错误 + schema: + $ref: '#/definitions/response.Any' + summary: OAuth 回调处理 + tags: + - oauth + /api/v1/oauth/external-accounts: + get: + description: 返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录 + produces: + - application/json + responses: + "200": + description: 外部帐号列表 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取外部帐号列表 + tags: + - oauth + /api/v1/oauth/external-accounts/{id}/delete: + post: + description: 解除当前登录用户与指定外部帐号的绑定关系,需要登录 + parameters: + - description: 外部帐号绑定记录 ID + format: int64 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 解除绑定成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: ID 无效或解除失败 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 解除外部帐号绑定 + tags: + - oauth + /api/v1/oauth/login: + get: + description: 根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。 + parameters: + - description: 认证源名称,为空使用第一个启用的认证源 + in: query + name: source + type: string + produces: + - application/json + responses: + "200": + description: 授权 URL + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/auth.OAuthAuthorizeResponse' + type: object + "400": + description: 认证源不存在或未配置 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 构造 URL 失败 + schema: + $ref: '#/definitions/response.Any' + summary: 获取登录授权地址 + tags: + - oauth + /api/v1/oauth/logout: + get: + description: 清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。 + produces: + - application/json + responses: + "200": + description: 退出成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "500": + description: Session 清除失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 退出登录 + tags: + - oauth + /api/v1/oauth/sources: + get: + description: 返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用 + produces: + - application/json + responses: + "200": + description: 登录源列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/auth.AuthSourceView' + type: array + type: object + summary: 获取可用登录源 + tags: + - oauth + /api/v1/oauth/user-info: + get: + description: 返回当前登录用户的基本信息,需要登录。 + produces: + - application/json + responses: + "200": + description: 用户信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/auth.BasicUserInfo' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取当前登录用户信息 + tags: + - oauth /api/v1/upload: post: consumes: @@ -3417,6 +4632,325 @@ paths: summary: 获取我的文件列表 tags: - upload + /api/v1/user-info: + get: + description: 返回当前登录用户的基本信息,需要登录。 + produces: + - application/json + responses: + "200": + description: 用户信息 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + $ref: '#/definitions/auth.BasicUserInfo' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取当前登录用户信息 + tags: + - oauth + /api/v1/user/access-tokens: + get: + description: 返回当前登录用户的所有 active access tokens(脱敏后) + produces: + - application/json + responses: + "200": + description: 令牌列表 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + items: + $ref: '#/definitions/user.AccessToken' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取当前用户的 AccessToken 列表 + tags: + - user + post: + consumes: + - application/json + description: 为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。 + parameters: + - description: 令牌名称 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.createAccessTokenRequest' + produces: + - application/json + responses: + "200": + description: 新建令牌成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误或超限 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 创建一个新的 AccessToken + tags: + - user + /api/v1/user/access-tokens/{id}: + delete: + description: 撤销并删除一个属于当前用户的 API 访问令牌 + parameters: + - description: 令牌ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 删除一个 AccessToken + tags: + - user + /api/v1/user/access-tokens/{id}/rotate: + post: + description: 轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效 + parameters: + - description: 令牌ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: 令牌轮换成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 轮换一个 AccessToken + tags: + - user + /api/v1/user/change-password: + post: + consumes: + - application/json + description: 修改当前登录用户的密码。 + parameters: + - description: 修改密码请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.changePasswordRequest' + produces: + - application/json + responses: + "200": + description: 修改密码成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "400": + description: 原密码错误或新密码不符合要求 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 请先登录 + schema: + $ref: '#/definitions/response.Any' + summary: 修改用户密码 + tags: + - user + /api/v1/user/login: + post: + consumes: + - application/json + description: 使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。 + parameters: + - description: 登录请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.loginRequest' + produces: + - application/json + responses: + "200": + description: 登录成功,返回用户信息 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 用户名或密码错误 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/response.Any' + summary: 用户密码登录 + tags: + - user + /api/v1/user/logout: + get: + description: 清除用户登录 Session,完成退出 + produces: + - application/json + responses: + "200": + description: 退出成功 + schema: + allOf: + - $ref: '#/definitions/response.Any' + - properties: + data: + type: string + type: object + "500": + description: Session 清除失败 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 用户退出登录 + tags: + - user + /api/v1/user/profile: + put: + consumes: + - application/json + description: 修改当前登录用户的昵称、头像、简介、电话、性别、个人网站和所在地。 + parameters: + - description: 更新请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.updateProfileRequest' + produces: + - application/json + responses: + "200": + description: 修改成功,返回更新后的用户信息 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + summary: 修改当前登录用户的个人资料 + tags: + - user + /api/v1/user/register: + post: + consumes: + - application/json + description: 使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。 + parameters: + - description: 注册请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.registerRequest' + produces: + - application/json + responses: + "200": + description: 注册并登录成功,返回用户信息 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误、用户名已存在或注册已关闭 + schema: + $ref: '#/definitions/response.Any' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/response.Any' + summary: 用户注册 + tags: + - user + /api/v1/user/self: + get: + description: 返回当前登录用户的基本信息,需要登录。 + produces: + - application/json + responses: + "200": + description: 用户信息 + schema: + $ref: '#/definitions/response.Any' + "401": + description: 未登录 + schema: + $ref: '#/definitions/response.Any' + security: + - SessionCookie: [] + summary: 获取当前登录用户信息 + tags: + - user + /api/v1/user/send-email-code: + post: + consumes: + - application/json + description: 向指定邮箱发送验证码(用于注册场景) + produces: + - application/json + responses: + "200": + description: 发送成功 + schema: + $ref: '#/definitions/response.Any' + "400": + description: 参数错误 + schema: + $ref: '#/definitions/response.Any' + summary: 发送邮箱验证码 + tags: + - user /f/{id}: get: description: 根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回 diff --git a/backend/plugins/domain/admin/plugin.go b/backend/plugins/domain/admin/plugin.go index ecbb30b4..3e195233 100644 --- a/backend/plugins/domain/admin/plugin.go +++ b/backend/plugins/domain/admin/plugin.go @@ -170,6 +170,10 @@ func (p *Plugin) Apply(ctx *core.Context) error { adminRouter := ctx.Router().Group("/api/v1/admin", loginMW, adminMW) handler.RegisterRoutes(adminRouter) + // Register robots.txt public route + ctx.Router().GET("/robots.txt", handler.GetRobotsTXT) + ctx.Router().RegisterWhitelist("/robots.txt") + // 2. Register Background Tasks logSwitchHandler := &service.LogDBSwitchHandler{} ctx.Task().Register(service.LogDBSwitchTask, func(c context.Context, payload []byte) error { diff --git a/backend/plugins/domain/admin/plugin_test.go b/backend/plugins/domain/admin/plugin_test.go index b0509906..5a3a1632 100644 --- a/backend/plugins/domain/admin/plugin_test.go +++ b/backend/plugins/domain/admin/plugin_test.go @@ -23,6 +23,14 @@ func TestAdminPluginUnit(t *testing.T) { // Verify routes routes := ctx.Router().Routes() assert.NotEmpty(t, routes) + var hasRobots bool + for _, r := range routes { + if r.Path == "/robots.txt" && r.Method == "GET" { + hasRobots = true + break + } + } + assert.True(t, hasRobots, "admin plugin must register /robots.txt") // Verify tasks _, ok := ctx.Tasks().Get("admin:system_cleanup") diff --git a/backend/plugins/domain/upload/plugin.go b/backend/plugins/domain/upload/plugin.go index 3fa08eaf..5b4cc281 100644 --- a/backend/plugins/domain/upload/plugin.go +++ b/backend/plugins/domain/upload/plugin.go @@ -112,6 +112,7 @@ func (p *Plugin) Apply(ctx *core.Context) error { return err } loginMW := authSvc.RequireAuthMiddleware().(gin.HandlerFunc) + adminMW := authSvc.RequireAdminMiddleware().(gin.HandlerFunc) // 0a. Register migrations ctx.Migrations().Register("upload", uploadMigrations) @@ -123,16 +124,14 @@ func (p *Plugin) Apply(ctx *core.Context) error { uploadGroup := ctx.Router().Group("/api/v1/upload", loginMW) { uploadGroup.POST("", handler.UploadFile) - uploadGroup.GET("", handler.ListFiles) - uploadGroup.DELETE("/:id", handler.DeleteFile) - uploadGroup.POST("/batch-download", handler.BatchDownloadFiles) + uploadGroup.DELETE("/:id", handler.DeleteMyFile) uploadGroup.GET("/my", handler.ListMyFiles) uploadGroup.PUT("/:id", handler.UpdateMyFile) uploadGroup.GET("/download/:id", handler.DownloadFile) uploadGroup.POST("/download/batch", handler.BatchDownloadFiles) } - adminUploadGroup := ctx.Router().Group("/api/v1/admin/uploads", loginMW) + adminUploadGroup := ctx.Router().Group("/api/v1/admin/uploads", loginMW, adminMW) { adminUploadGroup.GET("", handler.ListFiles) adminUploadGroup.GET("/stats", handler.GetFileStats) diff --git a/backend/plugins/domain/upload/plugin_test.go b/backend/plugins/domain/upload/plugin_test.go index 39de4d5a..b93059e4 100644 --- a/backend/plugins/domain/upload/plugin_test.go +++ b/backend/plugins/domain/upload/plugin_test.go @@ -23,6 +23,10 @@ func (stubAuthService) RequireAuthMiddleware() any { return gin.HandlerFunc(func(c *gin.Context) { c.Next() }) } +func (stubAuthService) RequireAdminMiddleware() any { + return gin.HandlerFunc(func(c *gin.Context) { c.Next() }) +} + func TestUserUploadRoutes(t *testing.T) { gin.SetMode(gin.TestMode) ctx := core.NewContext(context.Background()) @@ -34,12 +38,18 @@ func TestUserUploadRoutes(t *testing.T) { } want := []string{ + "POST /api/v1/upload", + "DELETE /api/v1/upload/:id", "GET /api/v1/upload/my", "PUT /api/v1/upload/:id", "GET /api/v1/upload/download/:id", "POST /api/v1/upload/download/batch", - "GET /api/v1/upload", - "POST /api/v1/upload/batch-download", + "GET /api/v1/admin/uploads", + "GET /api/v1/admin/uploads/stats", + "DELETE /api/v1/admin/uploads/:id", + "GET /api/v1/admin/uploads/download/:id", + "POST /api/v1/admin/uploads/download/batch", + "GET /api/v1/admin/uploads/types", } found := make(map[string]bool, len(want)) for _, rd := range ctx.Router().Routes() { diff --git a/backend/plugins/drivers/driver_http/engine_slash_test.go b/backend/plugins/drivers/driver_http/engine_slash_test.go index e58f588d..efbfa2f2 100644 --- a/backend/plugins/drivers/driver_http/engine_slash_test.go +++ b/backend/plugins/drivers/driver_http/engine_slash_test.go @@ -4,9 +4,12 @@ package driver_http import ( - "testing" - + "Wavelet/core" "Wavelet/core/extpoints" + "context" + "net/http" + "net/http/httptest" + "testing" ) func TestBuildEngineDefaultRedirectsTrailingSlash(t *testing.T) { @@ -111,3 +114,29 @@ func bindAppConfig(t *testing.T, values map[string]any, env map[string]string) h } func boolPtr(v bool) *bool { return &v } + +func TestDriverHTTPSwaggerMount(t *testing.T) { + ctx := core.NewContext(t.Context()) + ctx.Config().SetSource(core.NewMapSource(map[string]any{"app.env": "development"})) + if err := ctx.Config().Resolve(); err != nil { + t.Fatal(err) + } + p := New(WithAddr("127.0.0.1:0")) + if err := p.Apply(ctx); err != nil { + t.Fatal(err) + } + startCtx, cancel := context.WithCancel(t.Context()) + defer cancel() + if err := p.Start(startCtx); err != nil { + t.Fatal(err) + } + defer func() { _ = p.Stop(t.Context()) }() + + w := httptest.NewRecorder() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/swagger/index.html", nil) + req.RequestURI = "/swagger/index.html" + p.Engine().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for /swagger/index.html, got %d (body: %s)", w.Code, w.Body.String()) + } +} diff --git a/backend/plugins/drivers/driver_http/frontend.go b/backend/plugins/drivers/driver_http/frontend.go index 0aa99c33..73b4dfd4 100644 --- a/backend/plugins/drivers/driver_http/frontend.go +++ b/backend/plugins/drivers/driver_http/frontend.go @@ -17,7 +17,7 @@ const indexFile = "index.html" // serverOwnedPrefixes are backend-owned namespaces. A miss there must keep Gin's default // 404 instead of silently returning the frontend shell, which would mask broken API links. -var serverOwnedPrefixes = []string{"/api/", "/f/"} +var serverOwnedPrefixes = []string{"/api/", "/f/", "/swagger/"} // registerFrontend mounts assets as the NoRoute fallback so client-side routes resolve. // It is a no-op when assets is nil, i.e. the binary was built without the embed_frontend tag. diff --git a/backend/plugins/drivers/driver_http/plugin.go b/backend/plugins/drivers/driver_http/plugin.go index 83d933bc..72ebaa34 100644 --- a/backend/plugins/drivers/driver_http/plugin.go +++ b/backend/plugins/drivers/driver_http/plugin.go @@ -7,6 +7,7 @@ package driver_http import ( "Wavelet/core" "Wavelet/core/contracts" + _ "Wavelet/docs" // swagger documentation registration "Wavelet/pkg/util" "context" "errors" @@ -17,6 +18,8 @@ import ( "time" "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" ) const ( @@ -207,6 +210,19 @@ func (p *Plugin) Start(ctx context.Context) error { } } + // Mount Swagger in non-production environments + if p.coreCtx != nil { + var appCfg httpAppConfig + _ = p.coreCtx.Config().Bind("app", &appCfg) + if appCfg.Env != "production" && appCfg.Env != "prod" { + swaggerHandler := ginSwagger.WrapHandler(swaggerFiles.Handler) + p.engine.GET("/swagger/*any", swaggerHandler) + if appCfg.APIPrefix != "" { + p.engine.GET(appCfg.APIPrefix+"/swagger/*any", swaggerHandler) + } + } + } + registerFrontend(p.engine, frontendAssets()) p.server = &http.Server{ From 33f28ad671d424ff17131289245831719434a812 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 2 Sep 2026 15:37:06 +0800 Subject: [PATCH 06/44] fix: sql --- .../plugins/domain/user/migrations/postgres/00001_initial.sql | 4 ++-- .../plugins/domain/user/migrations/sqlite/00001_initial.sql | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/plugins/domain/user/migrations/postgres/00001_initial.sql b/backend/plugins/domain/user/migrations/postgres/00001_initial.sql index e183f348..16f3b952 100644 --- a/backend/plugins/domain/user/migrations/postgres/00001_initial.sql +++ b/backend/plugins/domain/user/migrations/postgres/00001_initial.sql @@ -26,12 +26,12 @@ CREATE INDEX IF NOT EXISTS idx_w_users_created_at ON w_users (created_at); -- Seed system user INSERT INTO w_users (id, username, password, nickname, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at) VALUES (999, 'system', '*', '系统', '', TRUE, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -ON CONFLICT (username) DO NOTHING; +ON CONFLICT DO NOTHING; -- Seed default administrator user (username: admin, password: 12345678) INSERT INTO w_users (id, username, password, nickname, email, is_active, is_admin, last_login_at, created_at, updated_at) VALUES (1, 'admin', '12345678', '管理员', 'admin@wavelet.local', TRUE, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -ON CONFLICT (username) DO NOTHING; +ON CONFLICT DO NOTHING; -- +goose StatementEnd -- +goose Down diff --git a/backend/plugins/domain/user/migrations/sqlite/00001_initial.sql b/backend/plugins/domain/user/migrations/sqlite/00001_initial.sql index 20b7f7a0..4540ed00 100644 --- a/backend/plugins/domain/user/migrations/sqlite/00001_initial.sql +++ b/backend/plugins/domain/user/migrations/sqlite/00001_initial.sql @@ -26,12 +26,12 @@ CREATE INDEX IF NOT EXISTS idx_w_users_created_at ON w_users (created_at); -- Seed system user INSERT INTO w_users (id, username, password, nickname, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at) VALUES (999, 'system', '*', '系统', '', 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -ON CONFLICT (username) DO NOTHING; +ON CONFLICT DO NOTHING; -- Seed default administrator user (username: admin, password: 12345678) INSERT INTO w_users (id, username, password, nickname, email, is_active, is_admin, last_login_at, created_at, updated_at) VALUES (1, 'admin', '12345678', '管理员', 'admin@wavelet.local', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) -ON CONFLICT (username) DO NOTHING; +ON CONFLICT DO NOTHING; -- +goose StatementEnd -- +goose Down From 30bbe965bf6479bd11ae13cff7f7c6a2ca9208b6 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 2 Sep 2026 16:11:45 +0800 Subject: [PATCH 07/44] fix(task): execute dispatched jobs and persist run records Asynq func handlers now go through ProcessTask so admin execution rows leave pending. The in-process worker resolves admin type identifiers and writes the same w_task_executions table. Remove the no-op admin system_cleanup that shadowed the upload handler. --- backend/core/extpoints/extpoints_test.go | 4 + backend/core/extpoints/task.go | 13 +- backend/plugins/domain/admin/plugin.go | 17 +- backend/plugins/domain/admin/plugin_test.go | 7 +- backend/plugins/domain/domain_test.go | 9 +- .../drivers/driver_asynq_worker/executor.go | 17 +- .../drivers/driver_asynq_worker/plugin.go | 40 +++- .../drivers/driver_inproc_worker/db_helper.go | 39 ++++ .../drivers/driver_inproc_worker/executor.go | 188 +++++++++++++++--- .../drivers/driver_inproc_worker/plugin.go | 16 +- .../driver_inproc_worker/plugin_test.go | 63 ++++++ .../driver_inproc_worker/task_service.go | 88 +++++++- .../drivers/driver_inproc_worker/types.go | 44 ++++ backend/plugins/drivers/drivers_test.go | 67 +++++++ 14 files changed, 530 insertions(+), 82 deletions(-) create mode 100644 backend/plugins/drivers/driver_inproc_worker/db_helper.go create mode 100644 backend/plugins/drivers/driver_inproc_worker/types.go diff --git a/backend/core/extpoints/extpoints_test.go b/backend/core/extpoints/extpoints_test.go index 6d51b999..d0c438f4 100644 --- a/backend/core/extpoints/extpoints_test.go +++ b/backend/core/extpoints/extpoints_test.go @@ -221,6 +221,10 @@ func TestTaskExtension(t *testing.T) { assert.True(t, ok) assert.Equal(t, "order:cancel_timeout", task.Pattern) + byType, ok := tr.Get("cancel_timeout") + assert.True(t, ok, "Get should resolve admin type identifier") + assert.Equal(t, "order:cancel_timeout", byType.Pattern) + _, ok = tr.Get("unknown") assert.False(t, ok) } diff --git a/backend/core/extpoints/task.go b/backend/core/extpoints/task.go index 1217ed64..09270158 100644 --- a/backend/core/extpoints/task.go +++ b/backend/core/extpoints/task.go @@ -276,10 +276,17 @@ func (t *TaskRegistry) Tasks() []TaskDefinition { return res } -// Get retrieves a task definition by its pattern. +// Get retrieves a task definition by its pattern or admin type identifier. func (t *TaskRegistry) Get(pattern string) (TaskDefinition, bool) { t.mu.RLock() defer t.mu.RUnlock() - td, ok := t.lookup[pattern] - return td, ok + if td, ok := t.lookup[pattern]; ok { + return td, true + } + for _, td := range t.tasks { + if td.Type == pattern { + return td, true + } + } + return TaskDefinition{}, false } diff --git a/backend/plugins/domain/admin/plugin.go b/backend/plugins/domain/admin/plugin.go index 3e195233..835e2e80 100644 --- a/backend/plugins/domain/admin/plugin.go +++ b/backend/plugins/domain/admin/plugin.go @@ -181,22 +181,7 @@ func (p *Plugin) Apply(ctx *core.Context) error { return err }, extpoints.WithTaskMeta(service.LogDBSwitchMeta)) - ctx.Task().Register("admin:system_cleanup", func(_ context.Context, _ []byte) error { - return nil - }, - extpoints.WithTaskType("system_cleanup"), - extpoints.WithTaskName("系统垃圾清理"), - extpoints.WithTaskDescription("定期清理未使用上传文件、历史推送记录和过期任务执行日志"), - extpoints.WithTaskCategory("maintenance"), - extpoints.WithTaskRetry(1), - extpoints.WithTaskQueue("default"), - extpoints.WithTaskRetryable(true), - ) - - // 3. Register Cron Schedules - ctx.Schedule().RegisterCron("0 4 * * *", "admin:system_cleanup", map[string]string{"type": "daily"}) - - // 4. Register Settings Schemas + // 3. Register Settings Schemas ctx.Settings().Register(extpoints.SettingSchema{ Key: "admin.system_cleanup_cron", Default: "0 4 * * *", diff --git a/backend/plugins/domain/admin/plugin_test.go b/backend/plugins/domain/admin/plugin_test.go index 5a3a1632..451897be 100644 --- a/backend/plugins/domain/admin/plugin_test.go +++ b/backend/plugins/domain/admin/plugin_test.go @@ -33,14 +33,9 @@ func TestAdminPluginUnit(t *testing.T) { assert.True(t, hasRobots, "admin plugin must register /robots.txt") // Verify tasks - _, ok := ctx.Tasks().Get("admin:system_cleanup") + _, ok := ctx.Tasks().Get("logs:db_switch") require.True(t, ok) - // Verify schedules - sched, ok := ctx.Schedules().Get("admin:system_cleanup") - require.True(t, ok) - assert.Equal(t, "0 4 * * *", sched.Spec) - // Verify settings setting, ok := ctx.Settings().Get("admin.system_cleanup_cron") require.True(t, ok) diff --git a/backend/plugins/domain/domain_test.go b/backend/plugins/domain/domain_test.go index 9f3f1b6a..efa54eb4 100644 --- a/backend/plugins/domain/domain_test.go +++ b/backend/plugins/domain/domain_test.go @@ -380,12 +380,9 @@ func TestAdminPlugin(t *testing.T) { assert.True(t, hasTasks) assert.True(t, hasConfigs) - // 2. Task & Schedule - _, ok := ctx.Tasks().Get("admin:system_cleanup") + // 2. Task + _, ok := ctx.Tasks().Get("logs:db_switch") require.True(t, ok) - sched, ok := ctx.Schedules().Get("admin:system_cleanup") - require.True(t, ok) - assert.Equal(t, "0 4 * * *", sched.Spec) // 3. Settings schema, ok := ctx.Settings().Get("admin.system_cleanup_cron") @@ -455,7 +452,7 @@ func TestAllDomainPluginsCombined(t *testing.T) { // Verify total schedules registered allSchedules := ctx.Schedules().Schedules() - assert.GreaterOrEqual(t, len(allSchedules), 2) + assert.GreaterOrEqual(t, len(allSchedules), 1) // 每个调度指向的任务类型都必须已注册 Handler,否则触发时会投递到无人处理的 // 任务类型,预期的清理逻辑静默失效。 diff --git a/backend/plugins/drivers/driver_asynq_worker/executor.go b/backend/plugins/drivers/driver_asynq_worker/executor.go index fc8459f5..7da4c364 100644 --- a/backend/plugins/drivers/driver_asynq_worker/executor.go +++ b/backend/plugins/drivers/driver_asynq_worker/executor.go @@ -265,8 +265,9 @@ func ProcessTask(ctx context.Context, t *asynq.Task) error { taskID := t.ResultWriter().TaskID() - // 注入 taskID 到 context + // 注入 taskID 到 context,并把原始 asynq.Task 交给适配器 Handler。 ctx = withTaskID(ctx, taskID) + ctx = context.WithValue(ctx, asynqTaskCtxKey{}, t) // 查找处理器 handler, ok := getHandler(t.Type()) @@ -277,18 +278,20 @@ func ProcessTask(ctx context.Context, t *asynq.Task) error { return err } - // 加载或动态创建执行记录 + // 加载或动态创建执行记录。无 DB 时仍执行业务 Handler,避免测试/精简拓扑 panic。 + var execution *TaskExecution now := time.Now() - execution, err := getOrCreateTaskExecution(ctx, taskID, t, taskPayload, now) - if err == nil { - updateExecutionOnStart(ctx, execution, now) + if getDB(ctx) != nil { + var err error + execution, err = getOrCreateTaskExecution(ctx, taskID, t, taskPayload, now) + if err == nil { + updateExecutionOnStart(ctx, execution, now) + } } if execution != nil { AppendLog(ctx, "[系统] 开始执行异步任务 [名称: %s, 类型: %s],重试次数: %d/%d", execution.TaskName, t.Type(), execution.RetryCount, execution.MaxRetry) - } else { - AppendLog(ctx, "[系统] 开始执行异步任务 [类型: %s]", t.Type()) } // 开始计时 diff --git a/backend/plugins/drivers/driver_asynq_worker/plugin.go b/backend/plugins/drivers/driver_asynq_worker/plugin.go index 05905182..d16b1a6c 100644 --- a/backend/plugins/drivers/driver_asynq_worker/plugin.go +++ b/backend/plugins/drivers/driver_asynq_worker/plugin.go @@ -192,7 +192,7 @@ func (p *Plugin) Start(_ context.Context) error { if p.coreCtx != nil && p.coreCtx.Tasks() != nil { for _, td := range p.coreCtx.Tasks().Tasks() { - handler, err := toAsynqHandler(td.Handler) + handler, err := toAsynqHandler(td.Pattern, td.Handler) if err != nil { return fmt.Errorf("driver_asynq_worker: invalid handler for task pattern %q: %w", td.Pattern, err) } @@ -276,21 +276,30 @@ func (p *Plugin) Mux() *asynq.ServeMux { return p.mux } -func toAsynqHandler(h any) (asynq.Handler, error) { +func toAsynqHandler(pattern string, h any) (asynq.Handler, error) { if h == nil { return nil, errors.New("nil handler") } + if th, ok := h.(TaskHandler); ok { + RegisterHandler(pattern, th) + return asynq.HandlerFunc(ProcessTask), nil + } + + inner, err := toRawAsynqHandler(h) + if err != nil { + return nil, err + } + RegisterHandler(pattern, &asynqHandlerAdapter{inner: inner}) + return asynq.HandlerFunc(ProcessTask), nil +} + +func toRawAsynqHandler(h any) (asynq.Handler, error) { switch fn := h.(type) { case asynq.HandlerFunc: return fn, nil case asynq.Handler: return fn, nil - case TaskHandler: - return asynq.HandlerFunc(func(c context.Context, t *asynq.Task) error { - RegisterHandler(t.Type(), fn) - return ProcessTask(c, t) - }), nil case func(context.Context, *asynq.Task) error: return asynq.HandlerFunc(fn), nil case func(context.Context, []byte) error: @@ -316,6 +325,23 @@ func toAsynqHandler(h any) (asynq.Handler, error) { } } +type asynqTaskCtxKey struct{} + +type asynqHandlerAdapter struct { + inner asynq.Handler +} + +func (a *asynqHandlerAdapter) Execute(ctx context.Context, payload []byte) (*TaskResult, error) { + t, _ := ctx.Value(asynqTaskCtxKey{}).(*asynq.Task) + if t == nil { + t = asynq.NewTask("", payload) + } + if err := a.inner.ProcessTask(ctx, t); err != nil { + return nil, err + } + return &TaskResult{Message: "ok"}, nil +} + type taskServiceImpl struct{} func (s *taskServiceImpl) Dispatch(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) { diff --git a/backend/plugins/drivers/driver_inproc_worker/db_helper.go b/backend/plugins/drivers/driver_inproc_worker/db_helper.go new file mode 100644 index 00000000..d564c491 --- /dev/null +++ b/backend/plugins/drivers/driver_inproc_worker/db_helper.go @@ -0,0 +1,39 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_inproc_worker + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "context" + "sync" + + "gorm.io/gorm" +) + +var ( + dbMu sync.RWMutex + dbSvc contracts.DBService +) + +func setDBService(s contracts.DBService) { + dbMu.Lock() + defer dbMu.Unlock() + dbSvc = s +} + +func getDB(ctx context.Context) *gorm.DB { + if c, ok := ctx.(*core.Context); ok && c != nil { + if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { + return s.DB(ctx) + } + } + dbMu.RLock() + s := dbSvc + dbMu.RUnlock() + if s != nil { + return s.DB(ctx) + } + return nil +} diff --git a/backend/plugins/drivers/driver_inproc_worker/executor.go b/backend/plugins/drivers/driver_inproc_worker/executor.go index 7fcc9f2f..fe211612 100644 --- a/backend/plugins/drivers/driver_inproc_worker/executor.go +++ b/backend/plugins/drivers/driver_inproc_worker/executor.go @@ -6,6 +6,7 @@ package driver_inproc_worker import ( "Wavelet/core/extpoints" "Wavelet/pkg/idgen" + "Wavelet/pkg/logger" "Wavelet/pkg/util" "context" "errors" @@ -59,34 +60,56 @@ func NewInprocQueue(concurrency, queueCap int, taskReg extpoints.TaskExtension) } // Enqueue puts a new task into the in-process queue. -func (q *InprocQueue) Enqueue(taskType string, payload []byte, source string) (string, error) { +// taskType may be the registration pattern or the admin type identifier. +func (q *InprocQueue) Enqueue(ctx context.Context, taskType string, payload []byte, source string) (string, error) { if !q.running.Load() { return "", errors.New("driver_inproc_worker: queue is not running") } - taskID := fmt.Sprintf("inproc_%d", idgen.NextUint64ID()) + td, ok := q.lookupTask(taskType) + if !ok { + return "", fmt.Errorf("driver_inproc_worker: unknown task type %q", taskType) + } + + if source == "" { + source = "manual" + } + idType := td.Type + if idType == "" { + idType = td.Pattern + } + taskID := fmt.Sprintf("%s_%s_%d", source, idType, idgen.NextUint64ID()) msg := TaskMessage{ ID: taskID, - TaskType: taskType, + TaskType: td.Pattern, Payload: payload, Source: source, CreatedAt: time.Now(), + RetryLeft: td.Retry, } - if q.taskReg != nil { - if td, ok := q.taskReg.Get(taskType); ok { - msg.RetryLeft = td.Retry - } + if err := q.createExecution(ctx, msg, td); err != nil { + return "", err } + q.appendExecutionLog(ctx, taskID, fmt.Sprintf("[系统] 任务已成功入队,等待调度执行 (最大重试次数: %d)", td.Retry)) + select { case q.queue <- msg: return taskID, nil default: + q.failExecution(ctx, msg, errors.New("queue is full"), 0) return "", errors.New("driver_inproc_worker: queue is full") } } +func (q *InprocQueue) lookupTask(taskType string) (extpoints.TaskDefinition, bool) { + if q.taskReg == nil { + return extpoints.TaskDefinition{}, false + } + return q.taskReg.Get(taskType) +} + // Start begins processing tasks with the worker pool. ctx is the app-lifetime // context used as the parent for per-task execution contexts. func (q *InprocQueue) Start(ctx context.Context) { @@ -145,12 +168,10 @@ func (q *InprocQueue) workerLoop(ctx context.Context) { } func (q *InprocQueue) executeTask(ctx context.Context, msg TaskMessage) { - if q.taskReg == nil { - return - } - - td, ok := q.taskReg.Get(msg.TaskType) + td, ok := q.lookupTask(msg.TaskType) if !ok { + logger.ErrorF(ctx, "driver_inproc_worker: no handler for task %q", msg.TaskType) + q.failExecution(ctx, msg, fmt.Errorf("unregistered task handler: %s", msg.TaskType), 0) return } @@ -162,26 +183,34 @@ func (q *InprocQueue) executeTask(ctx context.Context, msg TaskMessage) { taskCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + q.markRunning(ctx, msg) + start := time.Now() err := invokeHandler(taskCtx, td.Handler, msg.Payload) - if err != nil && msg.RetryLeft > 0 { - msg.RetryLeft-- - // Retry with backoff - util.Go(func() { - select { - case <-time.After(defaultRetryBackoff): - case <-q.stopCh: - return - case <-ctx.Done(): - return - } - if q.running.Load() { + duration := time.Since(start) + + if err != nil { + q.failExecution(ctx, msg, err, duration) + if msg.RetryLeft > 0 { + msg.RetryLeft-- + util.Go(func() { select { - case q.queue <- msg: - default: + case <-time.After(defaultRetryBackoff): + case <-q.stopCh: + return + case <-ctx.Done(): + return } - } - }) + if q.running.Load() { + select { + case q.queue <- msg: + default: + } + } + }) + } + return } + q.succeedExecution(ctx, msg, duration) } func invokeHandler(ctx context.Context, handler any, payload []byte) error { @@ -202,3 +231,106 @@ func invokeHandler(ctx context.Context, handler any, payload []byte) error { return fmt.Errorf("unsupported handler type: %T", handler) } } + +func (q *InprocQueue) createExecution(ctx context.Context, msg TaskMessage, td extpoints.TaskDefinition) error { + db := getDB(ctx) + if db == nil { + return nil + } + + name := td.Name + if name == "" { + name = td.DisplayName + } + if name == "" { + name = td.Pattern + } + exec := &taskExecution{ + ID: idgen.NextUint64ID(), + TaskID: msg.ID, + TaskType: td.Pattern, + TaskName: name, + Status: taskExecutionStatusPending, + Retryable: td.Retryable || td.Retry > 0, + MaxRetry: td.Retry, + RetryCount: 0, + Payload: string(msg.Payload), + TriggeredBy: msg.Source, + } + if err := db.Create(exec).Error; err != nil { + return fmt.Errorf("driver_inproc_worker: create task execution: %w", err) + } + return nil +} + +func (q *InprocQueue) markRunning(ctx context.Context, msg TaskMessage) { + db := getDB(ctx) + if db == nil { + return + } + now := time.Now() + updates := map[string]any{ + taskExecutionColStatus: taskExecutionStatusRunning, + "started_at": now, + } + if err := db.Model(&taskExecution{}).Where("task_id = ?", msg.ID).Updates(updates).Error; err != nil { + logger.ErrorF(ctx, "driver_inproc_worker: mark running failed taskID=%s: %v", msg.ID, err) + return + } + q.appendExecutionLog(ctx, msg.ID, fmt.Sprintf("[系统] 开始执行异步任务 [类型: %s]", msg.TaskType)) +} + +func (q *InprocQueue) succeedExecution(ctx context.Context, msg TaskMessage, duration time.Duration) { + db := getDB(ctx) + if db == nil { + return + } + now := time.Now() + updates := map[string]any{ + taskExecutionColStatus: taskExecutionStatusSucceeded, + "error_message": "", + "result": "ok", + "finished_at": now, + "duration": duration.Milliseconds(), + } + if err := db.Model(&taskExecution{}).Where("task_id = ?", msg.ID).Updates(updates).Error; err != nil { + logger.ErrorF(ctx, "driver_inproc_worker: mark succeeded failed taskID=%s: %v", msg.ID, err) + return + } + q.appendExecutionLog(ctx, msg.ID, fmt.Sprintf("[系统] 任务执行成功,耗时: %d ms", duration.Milliseconds())) +} + +func (q *InprocQueue) failExecution(ctx context.Context, msg TaskMessage, execErr error, duration time.Duration) { + db := getDB(ctx) + if db == nil { + return + } + now := time.Now() + updates := map[string]any{ + taskExecutionColStatus: taskExecutionStatusFailed, + "error_message": execErr.Error(), + "finished_at": now, + "duration": duration.Milliseconds(), + } + if err := db.Model(&taskExecution{}).Where("task_id = ?", msg.ID).Updates(updates).Error; err != nil { + logger.ErrorF(ctx, "driver_inproc_worker: mark failed failed taskID=%s: %v", msg.ID, err) + return + } + q.appendExecutionLog(ctx, msg.ID, fmt.Sprintf("[系统] 任务执行失败,耗时: %d ms,错误原因: %v", duration.Milliseconds(), execErr)) +} + +func (q *InprocQueue) appendExecutionLog(ctx context.Context, taskID, logLine string) { + db := getDB(ctx) + if db == nil { + return + } + now := time.Now().Format("15:04:05") + line := fmt.Sprintf("[%s] %s\n", now, logLine) + var exec taskExecution + if err := db.Where("task_id = ?", taskID).First(&exec).Error; err != nil { + return + } + if err := db.Model(&exec).Update("log", exec.Log+line).Error; err != nil { + logger.ErrorF(ctx, "driver_inproc_worker: append log failed taskID=%s: %v", taskID, err) + } +} diff --git a/backend/plugins/drivers/driver_inproc_worker/plugin.go b/backend/plugins/drivers/driver_inproc_worker/plugin.go index cd004818..2525955a 100644 --- a/backend/plugins/drivers/driver_inproc_worker/plugin.go +++ b/backend/plugins/drivers/driver_inproc_worker/plugin.go @@ -8,6 +8,7 @@ import ( "Wavelet/core" "Wavelet/core/contracts" "context" + "errors" "sync" "time" ) @@ -24,15 +25,15 @@ var ( ) // DispatchTask enqueues a background task to the active in-process worker queue. -func DispatchTask(_ context.Context, taskType string, payload []byte, source string) (string, error) { +func DispatchTask(ctx context.Context, taskType string, payload []byte, source string) (string, error) { globalMu.RLock() q := globalQueue globalMu.RUnlock() if q == nil { - return "", nil + return "", errors.New("driver_inproc_worker: queue is not running") } - return q.Enqueue(taskType, payload, source) + return q.Enqueue(ctx, taskType, payload, source) } // Option configures the in-process worker driver plugin. @@ -118,10 +119,19 @@ func (p *Plugin) ConfigEnabled(view core.ConfigView) bool { func (p *Plugin) Apply(ctx *core.Context) error { p.coreCtx = ctx + if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { + setDBService(db) + } else { + core.When[contracts.DBService](ctx, func(db contracts.DBService) { + setDBService(db) + }) + } + taskSvc := newInprocTaskService(ctx.Tasks()) core.Provide[contracts.TaskService](ctx, taskSvc) ctx.OnDispose(func() error { + setDBService(nil) shutdownCtx, cancel := context.WithTimeout(context.Background(), p.shutdownTimeout) defer cancel() return p.Stop(shutdownCtx) diff --git a/backend/plugins/drivers/driver_inproc_worker/plugin_test.go b/backend/plugins/drivers/driver_inproc_worker/plugin_test.go index 1107e433..2555fd76 100644 --- a/backend/plugins/drivers/driver_inproc_worker/plugin_test.go +++ b/backend/plugins/drivers/driver_inproc_worker/plugin_test.go @@ -5,8 +5,10 @@ package driver_inproc_worker_test import ( "Wavelet/core" + "Wavelet/core/contracts" "Wavelet/core/extpoints" "Wavelet/pkg/idgen" + "Wavelet/pkg/testhelper" "Wavelet/plugins/drivers/driver_inproc_worker" "context" "sync/atomic" @@ -15,8 +17,19 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) +type testDBService struct { + db *gorm.DB +} + +func (m *testDBService) GORM() *gorm.DB { return m.db } + +func (m *testDBService) DB(ctx context.Context) *gorm.DB { return m.db.WithContext(ctx) } + +func (m *testDBService) Named(_ string) *gorm.DB { return m.db } + func TestInprocWorkerPlugin(t *testing.T) { require.NoError(t, idgen.Init(1)) ctx := core.NewContext(context.Background()) @@ -51,3 +64,53 @@ func TestInprocWorkerPlugin(t *testing.T) { // Stop driver require.NoError(t, p.Stop(context.Background())) } + +func TestInprocWorkerDispatchByTypeTracksExecution(t *testing.T) { + require.NoError(t, idgen.Init(1)) + testDB, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + ctx := core.NewContext(context.Background()) + core.Provide[contracts.DBService](ctx, &testDBService{db: testDB}) + + p := driver_inproc_worker.New( + driver_inproc_worker.WithConcurrency(2), + driver_inproc_worker.WithShutdownTimeout(time.Second), + ) + require.NoError(t, p.Apply(ctx)) + + var executedCount atomic.Int32 + ctx.Tasks().Register("system:cleanup", func(_ context.Context, _ []byte) error { + executedCount.Add(1) + return nil + }, + extpoints.WithTaskType("system_cleanup"), + extpoints.WithTaskName("系统垃圾清理"), + extpoints.WithTaskRetry(1), + extpoints.WithTaskRetryable(true), + ) + + require.NoError(t, p.Start(context.Background())) + t.Cleanup(func() { + _ = p.Stop(context.Background()) + }) + + taskID, err := driver_inproc_worker.DispatchTask(context.Background(), "system_cleanup", []byte("payload"), "manual") + require.NoError(t, err) + assert.NotEmpty(t, taskID) + + require.Eventually(t, func() bool { + return executedCount.Load() == 1 + }, 2*time.Second, 20*time.Millisecond, "inproc worker should execute task dispatched by admin type") + + taskSvc, err := core.Inject[contracts.TaskService](ctx) + require.NoError(t, err) + + require.Eventually(t, func() bool { + execs, total, listErr := taskSvc.ListExecutions(context.Background(), "", "", 1, 10) + if listErr != nil || total == 0 || len(execs) == 0 { + return false + } + return execs[0].TaskID == taskID && execs[0].Status == "succeeded" && execs[0].TaskName == "系统垃圾清理" + }, 2*time.Second, 20*time.Millisecond, "inproc worker should persist a succeeded execution record") +} diff --git a/backend/plugins/drivers/driver_inproc_worker/task_service.go b/backend/plugins/drivers/driver_inproc_worker/task_service.go index 0f3e7d83..a268b444 100644 --- a/backend/plugins/drivers/driver_inproc_worker/task_service.go +++ b/backend/plugins/drivers/driver_inproc_worker/task_service.go @@ -25,8 +25,22 @@ func (s *inprocTaskService) Dispatch(ctx context.Context, taskType string, paylo return DispatchTask(ctx, taskType, payload, triggeredBy) } -func (s *inprocTaskService) Retry(_ context.Context, id uint64) (string, error) { - return fmt.Sprintf("inproc_retry_%d", id), nil +func (s *inprocTaskService) Retry(ctx context.Context, id uint64) (string, error) { + db := getDB(ctx) + if db == nil { + return "", errors.New("driver_inproc_worker: db not initialized") + } + var exec taskExecution + if err := db.Where("id = ?", id).First(&exec).Error; err != nil { + return "", fmt.Errorf("driver_inproc_worker: task execution not found: %w", err) + } + if exec.Status != taskExecutionStatusFailed { + return "", fmt.Errorf("driver_inproc_worker: only failed tasks can be retried, current status: %s", exec.Status) + } + if !exec.Retryable { + return "", errors.New("driver_inproc_worker: task is not retryable") + } + return DispatchTask(ctx, exec.TaskType, []byte(exec.Payload), "retry") } func (s *inprocTaskService) ListTasks() []contracts.TaskMetaDTO { @@ -64,10 +78,72 @@ func (s *inprocTaskService) ReloadScheduler() error { func (s *inprocTaskService) AppendLog(_ context.Context, _ string, _ ...any) { } -func (s *inprocTaskService) ListExecutions(_ context.Context, _, _ string, _, _ int) ([]contracts.TaskExecutionDTO, int64, error) { - return []contracts.TaskExecutionDTO{}, 0, nil +func (s *inprocTaskService) ListExecutions(ctx context.Context, taskType, status string, page, pageSize int) ([]contracts.TaskExecutionDTO, int64, error) { + db := getDB(ctx) + if db == nil { + return []contracts.TaskExecutionDTO{}, 0, nil + } + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + query := db.Model(&taskExecution{}) + if taskType != "" { + query = query.Where("task_type = ?", taskType) + } + if status != "" { + query = query.Where("status = ?", status) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []taskExecution + offset := (page - 1) * pageSize + if err := query.Order("id DESC").Offset(offset).Limit(pageSize).Find(&rows).Error; err != nil { + return nil, 0, err + } + res := make([]contracts.TaskExecutionDTO, 0, len(rows)) + for i := range rows { + res = append(res, toExecutionDTO(&rows[i])) + } + return res, total, nil } -func (s *inprocTaskService) GetExecution(_ context.Context, _ uint64) (*contracts.TaskExecutionDTO, error) { - return nil, errors.New("driver_inproc_worker: task executions are not tracked") +func (s *inprocTaskService) GetExecution(ctx context.Context, id uint64) (*contracts.TaskExecutionDTO, error) { + db := getDB(ctx) + if db == nil { + return nil, errors.New("driver_inproc_worker: db not initialized") + } + var exec taskExecution + if err := db.Where("id = ?", id).First(&exec).Error; err != nil { + return nil, err + } + dto := toExecutionDTO(&exec) + return &dto, nil +} + +func toExecutionDTO(exec *taskExecution) contracts.TaskExecutionDTO { + return contracts.TaskExecutionDTO{ + ID: exec.ID, + TaskID: exec.TaskID, + TaskType: exec.TaskType, + TaskName: exec.TaskName, + Status: string(exec.Status), + Retryable: exec.Retryable, + MaxRetry: exec.MaxRetry, + RetryCount: exec.RetryCount, + Log: exec.Log, + ErrorMessage: exec.ErrorMessage, + Result: exec.Result, + StartedAt: exec.StartedAt, + FinishedAt: exec.FinishedAt, + Duration: exec.Duration, + Payload: exec.Payload, + TriggeredBy: exec.TriggeredBy, + CreatedAt: exec.CreatedAt, + UpdatedAt: exec.UpdatedAt, + } } diff --git a/backend/plugins/drivers/driver_inproc_worker/types.go b/backend/plugins/drivers/driver_inproc_worker/types.go new file mode 100644 index 00000000..c3e5f84f --- /dev/null +++ b/backend/plugins/drivers/driver_inproc_worker/types.go @@ -0,0 +1,44 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_inproc_worker + +import "time" + +type taskExecutionStatus string + +const ( + taskExecutionStatusPending taskExecutionStatus = "pending" + taskExecutionStatusRunning taskExecutionStatus = "running" + taskExecutionStatusSucceeded taskExecutionStatus = "succeeded" + taskExecutionStatusFailed taskExecutionStatus = "failed" + + taskExecutionColStatus = "status" +) + +// taskExecution maps to the admin-owned w_task_executions table so the +// console can list in-process runs the same way it lists Asynq runs. +type taskExecution struct { + ID uint64 `gorm:"primaryKey"` + TaskID string `gorm:"size:128;uniqueIndex;not null"` + TaskType string `gorm:"size:64;index;not null"` + TaskName string `gorm:"size:128"` + Status taskExecutionStatus `gorm:"size:32;index;not null"` + Retryable bool `gorm:"not null;default:false"` + MaxRetry int `gorm:"not null;default:0"` + RetryCount int `gorm:"not null;default:0"` + Log string `gorm:"type:text"` + ErrorMessage string `gorm:"type:text"` + Result string `gorm:"type:text"` + StartedAt *time.Time `gorm:"index"` + FinishedAt *time.Time + Duration int64 `gorm:"comment:耗时毫秒"` + Payload string `gorm:"type:text"` + TriggeredBy string `gorm:"size:32;not null;default:system"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (taskExecution) TableName() string { + return "w_task_executions" +} diff --git a/backend/plugins/drivers/drivers_test.go b/backend/plugins/drivers/drivers_test.go index 2fc891d2..2a323e83 100644 --- a/backend/plugins/drivers/drivers_test.go +++ b/backend/plugins/drivers/drivers_test.go @@ -5,7 +5,10 @@ package drivers_test import ( "Wavelet/core" + "Wavelet/core/contracts" "Wavelet/core/extpoints" + "Wavelet/pkg/idgen" + "Wavelet/pkg/testhelper" "Wavelet/plugins/drivers/driver_asynq_cron" "Wavelet/plugins/drivers/driver_asynq_worker" "Wavelet/plugins/drivers/driver_http" @@ -24,8 +27,19 @@ import ( "github.com/hibiken/asynq" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) +type testDBService struct { + db *gorm.DB +} + +func (m *testDBService) GORM() *gorm.DB { return m.db } + +func (m *testDBService) DB(ctx context.Context) *gorm.DB { return m.db.WithContext(ctx) } + +func (m *testDBService) Named(_ string) *gorm.DB { return m.db } + func init() { gin.SetMode(gin.TestMode) } @@ -214,6 +228,59 @@ func TestAsynqWorkerDriverLifecycle(t *testing.T) { require.NoError(t, err) } +func TestAsynqWorkerDispatchTracksExecution(t *testing.T) { + _ = idgen.Init(1) + testDB, mr, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) + core.Provide[contracts.DBService](ctx, &testDBService{db: testDB}) + + var processed atomic.Bool + ctx.Tasks().Register("system:cleanup", func(_ context.Context, _ []byte) error { + processed.Store(true) + return nil + }, + extpoints.WithTaskType("system_cleanup"), + extpoints.WithTaskName("系统垃圾清理"), + extpoints.WithTaskQueue("default"), + extpoints.WithTaskRetry(1), + extpoints.WithTaskRetryable(true), + ) + + workerPlugin := driver_asynq_worker.New( + driver_asynq_worker.WithRedisOpt(asynq.RedisClientOpt{Addr: mr.Addr()}), + driver_asynq_worker.WithConcurrency(2), + driver_asynq_worker.WithShutdownTimeout(2*time.Second), + ) + require.NoError(t, workerPlugin.Apply(ctx)) + require.NoError(t, workerPlugin.Start(context.Background())) + t.Cleanup(func() { + _ = workerPlugin.Stop(context.Background()) + }) + + taskSvc, err := core.Inject[contracts.TaskService](ctx) + require.NoError(t, err) + + taskID, err := taskSvc.Dispatch(context.Background(), "system_cleanup", []byte(`{}`), "manual") + require.NoError(t, err) + require.NotEmpty(t, taskID) + + require.Eventually(t, func() bool { + return processed.Load() + }, 5*time.Second, 50*time.Millisecond, "asynq worker should execute dispatched func handler") + + require.Eventually(t, func() bool { + execs, _, listErr := taskSvc.ListExecutions(context.Background(), "", "", 1, 10) + if listErr != nil || len(execs) == 0 { + return false + } + return execs[0].TaskID == taskID && execs[0].Status == "succeeded" + }, 5*time.Second, 50*time.Millisecond, "task execution should become succeeded after worker runs") +} + func TestAsynqCronDriverLifecycle(t *testing.T) { mr, err := miniredis.Run() require.NoError(t, err) From 4f50f6a8f9291ba78f26c68ba6a8531a088fa161 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 2 Sep 2026 16:59:00 +0800 Subject: [PATCH 08/44] feat(core): bind request services and implement registered tasks Wire plugin services through Bind/InjectFrom and AppContext so HTTP and workers resolve dependencies after Apply. Register TaskHandler objects with persisted results, and implement send_email_code, mail:send, cleanup_inactive_users, and dispatch_bot_msg. --- backend/core/appctx.go | 43 ++ backend/core/container.go | 12 +- backend/core/context_test.go | 40 ++ backend/core/contracts/task.go | 6 + backend/core/extpoints/extpoints_test.go | 16 + backend/core/extpoints/task.go | 72 +++- backend/docs/docs.go | 28 ++ backend/docs/swagger.json | 28 ++ backend/docs/swagger.yaml | 18 + backend/plugins/domain/admin/handler/tasks.go | 2 +- backend/plugins/domain/admin/plugin.go | 64 +-- .../domain/admin/repository/repository.go | 9 +- backend/plugins/domain/admin/service/log.go | 4 +- .../domain/admin/service/log_switch.go | 4 +- .../plugins/domain/admin/service/service.go | 38 +- .../plugins/domain/admin/service/status.go | 2 +- backend/plugins/domain/admin/service/task.go | 74 +++- backend/plugins/domain/auth/plugin.go | 17 +- backend/plugins/domain/cap/plugin.go | 9 +- backend/plugins/domain/domain_test.go | 2 +- .../domain/message_gateway/errs/errs.go | 16 +- .../plugins/domain/message_gateway/plugin.go | 50 +-- .../message_gateway/repository/repository.go | 9 + .../domain/message_gateway/service/admin.go | 6 +- .../message_gateway/service/dispatch.go | 190 +++++++++ .../message_gateway/service/dispatch_test.go | 47 +++ .../domain/message_gateway/service/push.go | 16 +- .../domain/message_gateway/service/service.go | 17 +- backend/plugins/domain/risk_control/plugin.go | 9 +- backend/plugins/domain/upload/plugin.go | 79 +--- .../domain/upload/shared/context_services.go | 29 +- backend/plugins/domain/user/errs.go | 8 + backend/plugins/domain/user/handlers.go | 27 ++ backend/plugins/domain/user/models.go | 4 + backend/plugins/domain/user/plugin.go | 109 +---- backend/plugins/domain/user/repository.go | 28 +- backend/plugins/domain/user/task.go | 385 ++++++++++++++++++ backend/plugins/domain/user/task_test.go | 109 +++++ .../drivers/driver_asynq_cron/plugin.go | 19 +- .../drivers/driver_asynq_worker/db_helper.go | 6 +- .../drivers/driver_asynq_worker/plugin.go | 75 +++- backend/plugins/drivers/driver_http/plugin.go | 79 ++-- .../drivers/driver_inproc_cron/plugin.go | 6 +- .../drivers/driver_inproc_worker/db_helper.go | 6 +- .../drivers/driver_inproc_worker/executor.go | 45 +- .../drivers/driver_inproc_worker/plugin.go | 11 +- .../driver_inproc_worker/plugin_test.go | 6 +- backend/plugins/drivers/drivers_test.go | 6 +- backend/plugins/infra/storage/plugin.go | 20 +- 49 files changed, 1406 insertions(+), 499 deletions(-) create mode 100644 backend/core/appctx.go create mode 100644 backend/plugins/domain/message_gateway/service/dispatch.go create mode 100644 backend/plugins/domain/message_gateway/service/dispatch_test.go create mode 100644 backend/plugins/domain/user/task.go create mode 100644 backend/plugins/domain/user/task_test.go diff --git a/backend/core/appctx.go b/backend/core/appctx.go new file mode 100644 index 00000000..1c1e2201 --- /dev/null +++ b/backend/core/appctx.go @@ -0,0 +1,43 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core + +import "context" + +type appContextKey struct{} + +// WithAppContext attaches the micro-kernel Context to a standard context.Context +// so request and worker handlers can Inject services without package-level setters. +func WithAppContext(ctx context.Context, app *Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + if app == nil { + return ctx + } + return context.WithValue(ctx, appContextKey{}, app.Root()) +} + +// AppContext extracts the micro-kernel Context from ctx, if present. +func AppContext(ctx context.Context) *Context { + if ctx == nil { + return nil + } + if c, ok := ctx.(*Context); ok { + return c + } + app, _ := ctx.Value(appContextKey{}).(*Context) + return app +} + +// InjectFrom resolves T from ctx when it carries a micro-kernel Context +// (*Context itself, or a value attached by WithAppContext). +func InjectFrom[T any](ctx context.Context) (T, error) { + var zero T + app := AppContext(ctx) + if app == nil { + return zero, ErrNilContext + } + return Inject[T](app) +} diff --git a/backend/core/container.go b/backend/core/container.go index ed783572..64d8c030 100644 --- a/backend/core/container.go +++ b/backend/core/container.go @@ -201,13 +201,17 @@ func Using3[T1, T2, T3 any](ctx *Context, fn func(s1 T1, s2 T2, s3 T3)) error { // When registers a reactive hook that is called immediately if T is already provided, // or called as soon as T is provided in the future. +// +// Listeners are stored on the root container so they observe core.Provide, which +// always writes to the root. Registering on a Fiber child container would miss +// services provided by plugins that load later. func When[T any](ctx *Context, fn func(s T)) { if ctx == nil { panic("core: nil context provided to When") } targetType := reflect.TypeFor[T]() - c := ctx.Container() + c := ctx.Root().Container() // If already ready, execute immediately if s, err := Inject[T](ctx); err == nil { @@ -223,3 +227,9 @@ func When[T any](ctx *Context, fn func(s T)) { } }) } + +// Bind is When with a name that matches plugin wiring: fill a dependency as +// soon as the root container provides it. +func Bind[T any](ctx *Context, fn func(s T)) { + When(ctx, fn) +} diff --git a/backend/core/context_test.go b/backend/core/context_test.go index d6c66bc6..9a9bce93 100644 --- a/backend/core/context_test.go +++ b/backend/core/context_test.go @@ -313,6 +313,46 @@ func TestContextReactiveWhen(t *testing.T) { assert.True(t, immediateCalled) } +func TestWhenObservesProvideFromForkedFiberContext(t *testing.T) { + root := core.NewContext(context.Background()) + adminFiber := root.Fork() + lateFiber := root.Fork() + + var got atomic.Bool + core.When[SampleService](adminFiber, func(s SampleService) { + if s != nil { + got.Store(true) + } + }) + assert.False(t, got.Load()) + + core.Provide[SampleService](lateFiber, &sampleServiceImpl{}) + assert.True(t, got.Load(), "When on a Fiber child must observe Provide on the root") +} + +func TestBindIsWhen(t *testing.T) { + ctx := core.NewContext(context.Background()) + var called atomic.Bool + core.Bind[SampleService](ctx, func(s SampleService) { + called.Store(true) + }) + core.Provide[SampleService](ctx, &sampleServiceImpl{}) + assert.True(t, called.Load()) +} + +func TestInjectFromAppContext(t *testing.T) { + app := core.NewContext(context.Background()) + core.Provide[SampleService](app, &sampleServiceImpl{prefix: "Hi:"}) + + req := core.WithAppContext(context.Background(), app) + svc, err := core.InjectFrom[SampleService](req) + require.NoError(t, err) + assert.Equal(t, "Hi: Ada", svc.Greet("Ada")) + + _, err = core.InjectFrom[SampleService](context.Background()) + assert.ErrorIs(t, err, core.ErrNilContext) +} + func TestContextDisposerLifecycle(t *testing.T) { parent := core.NewContext(context.Background()) child := parent.Fork() diff --git a/backend/core/contracts/task.go b/backend/core/contracts/task.go index 3fe1c01c..838d6afa 100644 --- a/backend/core/contracts/task.go +++ b/backend/core/contracts/task.go @@ -43,6 +43,12 @@ type TaskResultDTO struct { Detail any `json:"detail,omitempty"` } +// TaskHandler is the preferred background task handler. Drivers invoke Execute +// and persist Message/Detail onto the execution record. +type TaskHandler interface { + Execute(ctx context.Context, payload []byte) (*TaskResultDTO, error) +} + // TaskExecutionDTO represents a single task execution record. type TaskExecutionDTO struct { ID uint64 `json:"id,string"` diff --git a/backend/core/extpoints/extpoints_test.go b/backend/core/extpoints/extpoints_test.go index d0c438f4..fe684d75 100644 --- a/backend/core/extpoints/extpoints_test.go +++ b/backend/core/extpoints/extpoints_test.go @@ -229,6 +229,22 @@ func TestTaskExtension(t *testing.T) { assert.False(t, ok) } +func TestTaskRegisterRejectsNilHandler(t *testing.T) { + tr := extpoints.NewTaskRegistry() + assert.Panics(t, func() { + tr.Register("broken:task", nil) + }) +} + +func TestTaskRegisterRejectsDuplicateType(t *testing.T) { + tr := extpoints.NewTaskRegistry() + handler := func(ctx context.Context, payload []byte) error { return nil } + tr.Register("system:cleanup", handler, extpoints.WithTaskType("system_cleanup")) + assert.Panics(t, func() { + tr.Register("admin:system_cleanup", handler, extpoints.WithTaskType("system_cleanup")) + }) +} + func TestScheduleExtension(t *testing.T) { sr := extpoints.NewScheduleRegistry() require.NotNil(t, sr) diff --git a/backend/core/extpoints/task.go b/backend/core/extpoints/task.go index 09270158..3e22b1e3 100644 --- a/backend/core/extpoints/task.go +++ b/backend/core/extpoints/task.go @@ -5,6 +5,8 @@ package extpoints import ( "Wavelet/core/contracts" + "fmt" + "reflect" "sync" "time" ) @@ -230,10 +232,15 @@ func NewTaskRegistry() *TaskRegistry { } // Register registers a task pattern and its handler with optional configuration. +// A nil handler panics. A non-empty Type that is already used by another pattern panics. func (t *TaskRegistry) Register(pattern string, handler any, opts ...TaskOption) { t.mu.Lock() defer t.mu.Unlock() + if isNilTaskHandler(handler) { + panic(fmt.Sprintf("extpoints: nil handler for task pattern %q", pattern)) + } + td := TaskDefinition{ Pattern: pattern, Handler: handler, @@ -245,8 +252,23 @@ func (t *TaskRegistry) Register(pattern string, handler any, opts ...TaskOption) opt(&td) } } + if td.Type == "" { + td.Type = pattern + } - if _, exists := t.lookup[pattern]; exists { + for _, item := range t.tasks { + if item.Pattern == pattern { + continue + } + if item.Type == td.Type { + panic(fmt.Sprintf("extpoints: duplicate task type %q (patterns %q and %q)", td.Type, item.Pattern, pattern)) + } + } + + if existing, exists := t.lookup[pattern]; exists { + if existing.Type != "" && existing.Type != pattern { + delete(t.lookup, existing.Type) + } for i, item := range t.tasks { if item.Pattern == pattern { t.tasks[i] = td @@ -258,13 +280,44 @@ func (t *TaskRegistry) Register(pattern string, handler any, opts ...TaskOption) } t.lookup[pattern] = td + if td.Type != pattern { + t.lookup[td.Type] = td + } +} + +func isNilTaskHandler(handler any) bool { + if handler == nil { + return true + } + v := reflect.ValueOf(handler) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.UnsafePointer, reflect.Interface, reflect.Slice: + return v.IsNil() + default: + return false + } } // Unregister removes a registered task definition by its pattern. func (t *TaskRegistry) Unregister(pattern string) bool { - return unregisterEntry(&t.mu, t.lookup, &t.tasks, pattern, func(item TaskDefinition) bool { - return item.Pattern == pattern - }) + t.mu.Lock() + defer t.mu.Unlock() + td, ok := t.lookup[pattern] + if !ok { + return false + } + delete(t.lookup, td.Pattern) + if td.Type != "" && td.Type != td.Pattern { + delete(t.lookup, td.Type) + } + filtered := t.tasks[:0] + for _, item := range t.tasks { + if item.Pattern != td.Pattern { + filtered = append(filtered, item) + } + } + t.tasks = filtered + return true } // Tasks returns a copy of all registered TaskDefinitions. @@ -280,13 +333,6 @@ func (t *TaskRegistry) Tasks() []TaskDefinition { func (t *TaskRegistry) Get(pattern string) (TaskDefinition, bool) { t.mu.RLock() defer t.mu.RUnlock() - if td, ok := t.lookup[pattern]; ok { - return td, true - } - for _, td := range t.tasks { - if td.Type == pattern { - return td, true - } - } - return TaskDefinition{}, false + td, ok := t.lookup[pattern] + return td, ok } diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 21314fc6..028c21b1 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -5925,6 +5925,17 @@ const docTemplate = `{ "user" ], "summary": "发送邮箱验证码", + "parameters": [ + { + "description": "目标邮箱", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.sendEmailCodeRequest" + } + } + ], "responses": { "200": { "description": "发送成功", @@ -5937,6 +5948,12 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/response.Any" } + }, + "500": { + "description": "发送失败", + "schema": { + "$ref": "#/definitions/response.Any" + } } } } @@ -8047,6 +8064,17 @@ const docTemplate = `{ } } }, + "user.sendEmailCodeRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, "user.updateProfileRequest": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 84f27bc9..a01fa772 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -5918,6 +5918,17 @@ "user" ], "summary": "发送邮箱验证码", + "parameters": [ + { + "description": "目标邮箱", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.sendEmailCodeRequest" + } + } + ], "responses": { "200": { "description": "发送成功", @@ -5930,6 +5941,12 @@ "schema": { "$ref": "#/definitions/response.Any" } + }, + "500": { + "description": "发送失败", + "schema": { + "$ref": "#/definitions/response.Any" + } } } } @@ -8040,6 +8057,17 @@ } } }, + "user.sendEmailCodeRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, "user.updateProfileRequest": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 555b8a9c..52dbb9ce 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1363,6 +1363,13 @@ definitions: - password - username type: object + user.sendEmailCodeRequest: + properties: + email: + type: string + required: + - email + type: object user.updateProfileRequest: properties: avatar_url: @@ -4937,6 +4944,13 @@ paths: consumes: - application/json description: 向指定邮箱发送验证码(用于注册场景) + parameters: + - description: 目标邮箱 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.sendEmailCodeRequest' produces: - application/json responses: @@ -4948,6 +4962,10 @@ paths: description: 参数错误 schema: $ref: '#/definitions/response.Any' + "500": + description: 发送失败 + schema: + $ref: '#/definitions/response.Any' summary: 发送邮箱验证码 tags: - user diff --git a/backend/plugins/domain/admin/handler/tasks.go b/backend/plugins/domain/admin/handler/tasks.go index 438cae3f..2e396c67 100644 --- a/backend/plugins/domain/admin/handler/tasks.go +++ b/backend/plugins/domain/admin/handler/tasks.go @@ -48,7 +48,7 @@ func abortTaskLogicError(c *gin.Context, err error) bool { // @Failure 403 {object} response.Any "无管理员权限" // @Router /api/v1/admin/tasks/types [get] func ListTaskTypes(c *gin.Context) { - c.JSON(http.StatusOK, response.OK(service.ListTaskTypes())) + c.JSON(http.StatusOK, response.OK(service.ListTaskTypes(c.Request.Context()))) } // DispatchTask 下发任务 diff --git a/backend/plugins/domain/admin/plugin.go b/backend/plugins/domain/admin/plugin.go index 835e2e80..418f1d11 100644 --- a/backend/plugins/domain/admin/plugin.go +++ b/backend/plugins/domain/admin/plugin.go @@ -12,7 +12,6 @@ import ( "Wavelet/plugins/domain/admin/handler" "Wavelet/plugins/domain/admin/model" "Wavelet/plugins/domain/admin/service" - "context" "embed" "reflect" @@ -85,56 +84,13 @@ func (p *Plugin) Apply(ctx *core.Context) error { _ = ctx.Config().Bind("clickhouse", &chCfg) service.SetClickHouseConfig(chCfg) - // 0. Bind Services reactively - if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { - service.SetDBService(db) - } else { - core.When[contracts.DBService](ctx, func(db contracts.DBService) { - service.SetDBService(db) - }) - } - if cache, err := core.Inject[contracts.CacheService](ctx); err == nil && cache != nil { - service.SetCacheService(cache) - } else { - core.When[contracts.CacheService](ctx, func(cache contracts.CacheService) { - service.SetCacheService(cache) - }) - } - if user, err := core.Inject[contracts.UserService](ctx); err == nil && user != nil { - service.SetUserService(user) - } else { - core.When[contracts.UserService](ctx, func(user contracts.UserService) { - service.SetUserService(user) - }) - } - if auth, err := core.Inject[contracts.AuthService](ctx); err == nil && auth != nil { - service.SetAuthService(auth) - } else { - core.When[contracts.AuthService](ctx, func(auth contracts.AuthService) { - service.SetAuthService(auth) - }) - } - if task, err := core.Inject[contracts.TaskService](ctx); err == nil && task != nil { - service.SetTaskService(task) - } else { - core.When[contracts.TaskService](ctx, func(task contracts.TaskService) { - service.SetTaskService(task) - }) - } - if storage, err := core.Inject[contracts.StorageService](ctx); err == nil && storage != nil { - service.SetStorageService(storage) - } else { - core.When[contracts.StorageService](ctx, func(storage contracts.StorageService) { - service.SetStorageService(storage) - }) - } - if rc, err := core.Inject[contracts.RiskControlService](ctx); err == nil && rc != nil { - service.SetRiskControlService(rc) - } else { - core.When[contracts.RiskControlService](ctx, func(rc contracts.RiskControlService) { - service.SetRiskControlService(rc) - }) - } + core.Bind[contracts.DBService](ctx, service.SetDBService) + core.Bind[contracts.CacheService](ctx, service.SetCacheService) + core.Bind[contracts.UserService](ctx, service.SetUserService) + core.Bind[contracts.AuthService](ctx, service.SetAuthService) + core.Bind[contracts.TaskService](ctx, service.SetTaskService) + core.Bind[contracts.StorageService](ctx, service.SetStorageService) + core.Bind[contracts.RiskControlService](ctx, service.SetRiskControlService) service.SetEventEmitter(ctx.Events().Emit) ctx.OnDispose(func() error { @@ -175,11 +131,7 @@ func (p *Plugin) Apply(ctx *core.Context) error { ctx.Router().RegisterWhitelist("/robots.txt") // 2. Register Background Tasks - logSwitchHandler := &service.LogDBSwitchHandler{} - ctx.Task().Register(service.LogDBSwitchTask, func(c context.Context, payload []byte) error { - _, err := logSwitchHandler.Execute(c, payload) - return err - }, extpoints.WithTaskMeta(service.LogDBSwitchMeta)) + ctx.Task().Register(service.LogDBSwitchTask, &service.LogDBSwitchHandler{}, extpoints.WithTaskMeta(service.LogDBSwitchMeta)) // 3. Register Settings Schemas ctx.Settings().Register(extpoints.SettingSchema{ diff --git a/backend/plugins/domain/admin/repository/repository.go b/backend/plugins/domain/admin/repository/repository.go index 6118614a..c0c2331a 100644 --- a/backend/plugins/domain/admin/repository/repository.go +++ b/backend/plugins/domain/admin/repository/repository.go @@ -5,6 +5,7 @@ package repository import ( + "Wavelet/core" "Wavelet/core/contracts" "Wavelet/pkg/cache/ram" "Wavelet/pkg/logger" @@ -56,6 +57,9 @@ func ResetServices() { // GetDB returns the GORM DB instance bound to the context if available. func GetDB(ctx context.Context) *gorm.DB { + if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil { + return s.DB(ctx) + } repoMu.RLock() defer repoMu.RUnlock() if dbService == nil { @@ -65,7 +69,10 @@ func GetDB(ctx context.Context) *gorm.DB { } // GetCache returns the unified CacheService instance. -func GetCache(_ context.Context) contracts.CacheService { +func GetCache(ctx context.Context) contracts.CacheService { + if s, err := core.InjectFrom[contracts.CacheService](ctx); err == nil && s != nil { + return s + } repoMu.RLock() defer repoMu.RUnlock() return cacheService diff --git a/backend/plugins/domain/admin/service/log.go b/backend/plugins/domain/admin/service/log.go index 82a6dd84..e0cca4a6 100644 --- a/backend/plugins/domain/admin/service/log.go +++ b/backend/plugins/domain/admin/service/log.go @@ -75,7 +75,7 @@ func IsAllowedLogOrigin(ctx context.Context, origin, host string) bool { // AccessLogs queries the analytical access log store and decorates rows with user names. func AccessLogs(ctx context.Context, q model.AccessLogQuery) (model.AccessLogsResponse, error) { - rc := GetRiskControlService() + rc := GetRiskControlService(ctx) if rc == nil { return model.AccessLogsResponse{}, errs.ErrLogStoreUnavailable } @@ -117,7 +117,7 @@ func AccessLogs(ctx context.Context, q model.AccessLogQuery) (model.AccessLogsRe // AccessLogAnalytics aggregates the daily trend of the access log store. func AccessLogAnalytics(ctx context.Context) (model.LogsAnalyticsResponse, error) { - rc := GetRiskControlService() + rc := GetRiskControlService(ctx) if rc == nil { return model.LogsAnalyticsResponse{}, errs.ErrLogStoreUnavailable } diff --git a/backend/plugins/domain/admin/service/log_switch.go b/backend/plugins/domain/admin/service/log_switch.go index 792eb95f..a6ae1091 100644 --- a/backend/plugins/domain/admin/service/log_switch.go +++ b/backend/plugins/domain/admin/service/log_switch.go @@ -109,7 +109,7 @@ func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*cont return nil, err } - taskSvc := GetTaskService() + taskSvc := GetTaskService(ctx) if taskSvc != nil { taskSvc.AppendLog(ctx, "开始切换日志数据库:%s -> %s", source, p.Target) } @@ -123,7 +123,7 @@ func (h *LogDBSwitchHandler) Execute(ctx context.Context, payload []byte) (*cont } }() - rc := GetRiskControlService() + rc := GetRiskControlService(ctx) if rc != nil { if err := rc.SwitchLogEngine(ctx, p.Target); err != nil { return nil, err diff --git a/backend/plugins/domain/admin/service/service.go b/backend/plugins/domain/admin/service/service.go index ba4a2e98..db474f87 100644 --- a/backend/plugins/domain/admin/service/service.go +++ b/backend/plugins/domain/admin/service/service.go @@ -5,6 +5,7 @@ package service import ( + "Wavelet/core" "Wavelet/core/contracts" "Wavelet/plugins/domain/admin/errs" "Wavelet/plugins/domain/admin/repository" @@ -112,6 +113,9 @@ func ResetServices() { // GetDB returns the GORM DB instance bound to the context if available. func GetDB(ctx context.Context) *gorm.DB { + if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil { + return s.DB(ctx) + } servicesMu.RLock() defer servicesMu.RUnlock() if dbService == nil { @@ -121,42 +125,60 @@ func GetDB(ctx context.Context) *gorm.DB { } // GetCache returns the unified CacheService instance. -func GetCache(_ context.Context) contracts.CacheService { +func GetCache(ctx context.Context) contracts.CacheService { + if s, err := core.InjectFrom[contracts.CacheService](ctx); err == nil && s != nil { + return s + } servicesMu.RLock() defer servicesMu.RUnlock() return cacheService } // GetUserService returns the UserService instance. -func GetUserService(_ context.Context) contracts.UserService { +func GetUserService(ctx context.Context) contracts.UserService { + if s, err := core.InjectFrom[contracts.UserService](ctx); err == nil && s != nil { + return s + } servicesMu.RLock() defer servicesMu.RUnlock() return userService } // GetAuthService returns the AuthService instance. -func GetAuthService(_ context.Context) contracts.AuthService { +func GetAuthService(ctx context.Context) contracts.AuthService { + if s, err := core.InjectFrom[contracts.AuthService](ctx); err == nil && s != nil { + return s + } servicesMu.RLock() defer servicesMu.RUnlock() return authService } // GetTaskService returns the TaskService instance. -func GetTaskService() contracts.TaskService { +func GetTaskService(ctx context.Context) contracts.TaskService { + if s, err := core.InjectFrom[contracts.TaskService](ctx); err == nil && s != nil { + return s + } servicesMu.RLock() defer servicesMu.RUnlock() return taskService } // GetStorageService returns the StorageService instance. -func GetStorageService() contracts.StorageService { +func GetStorageService(ctx context.Context) contracts.StorageService { + if s, err := core.InjectFrom[contracts.StorageService](ctx); err == nil && s != nil { + return s + } servicesMu.RLock() defer servicesMu.RUnlock() return storageSvc } // GetRiskControlService returns the RiskControlService instance. -func GetRiskControlService() contracts.RiskControlService { +func GetRiskControlService(ctx context.Context) contracts.RiskControlService { + if s, err := core.InjectFrom[contracts.RiskControlService](ctx); err == nil && s != nil { + return s + } servicesMu.RLock() defer servicesMu.RUnlock() return riskControlService @@ -195,8 +217,8 @@ func requireAuthService(ctx context.Context) (contracts.AuthService, error) { } // requireTaskService resolves the injected task contract service. -func requireTaskService() (contracts.TaskService, error) { - taskSvc := GetTaskService() +func requireTaskService(ctx context.Context) (contracts.TaskService, error) { + taskSvc := GetTaskService(ctx) if taskSvc == nil { return nil, errs.ErrTaskServiceUnavailable } diff --git a/backend/plugins/domain/admin/service/status.go b/backend/plugins/domain/admin/service/status.go index dd3ef455..77c25647 100644 --- a/backend/plugins/domain/admin/service/status.go +++ b/backend/plugins/domain/admin/service/status.go @@ -117,7 +117,7 @@ func formatDuration(d time.Duration) string { func LogDatabaseStatus(ctx context.Context) model.LogDatabaseStatus { activeDB := logDBNameSQLite migration := logMigrationIdle - if rc := GetRiskControlService(); rc != nil { + if rc := GetRiskControlService(ctx); rc != nil { activeDB = rc.ActiveLogEngine(ctx) if rc.IsLogEngineMigrating(ctx) { migration = logMigrationInProgress diff --git a/backend/plugins/domain/admin/service/task.go b/backend/plugins/domain/admin/service/task.go index 2543ee85..3226c31f 100644 --- a/backend/plugins/domain/admin/service/task.go +++ b/backend/plugins/domain/admin/service/task.go @@ -17,8 +17,8 @@ import ( ) // ListTaskTypes returns every dispatchable task type declared in the task registry. -func ListTaskTypes() []contracts.TaskMetaDTO { - taskSvc := GetTaskService() +func ListTaskTypes(ctx context.Context) []contracts.TaskMetaDTO { + taskSvc := GetTaskService(ctx) if taskSvc == nil { return []contracts.TaskMetaDTO{} } @@ -27,7 +27,7 @@ func ListTaskTypes() []contracts.TaskMetaDTO { // DispatchTask validates and enqueues a manual task run, returning the new task id. func DispatchTask(ctx context.Context, req model.DispatchTaskRequest) (string, error) { - taskSvc, err := requireTaskService() + taskSvc, err := requireTaskService(ctx) if err != nil { return "", err } @@ -67,29 +67,75 @@ func ListTaskExecutions( ctx context.Context, req model.ListTaskExecutionsRequest, ) ([]model.TaskExecution, int64, error) { - if req.TaskType != "" { - if taskSvc := GetTaskService(); taskSvc != nil { - if meta, ok := taskSvc.GetTaskMeta(req.TaskType); ok { - req.TaskType = meta.Name - } + taskSvc, err := requireTaskService(ctx) + if err != nil { + return nil, 0, err + } + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 20 + } + + filterType := req.TaskType + if filterType != "" { + if meta, ok := taskSvc.GetTaskMeta(filterType); ok { + filterType = meta.AsynqTask } } - executions, total, err := repository.ListTaskExecutionRecords(ctx, req) + rows, total, err := taskSvc.ListExecutions(ctx, filterType, req.Status, req.Page, req.PageSize) if err != nil { return nil, 0, err } + executions := make([]model.TaskExecution, 0, len(rows)) + for i := range rows { + executions = append(executions, executionFromDTO(rows[i])) + } return executions, total, nil } // TaskExecution loads a single execution record including its buffered log. func TaskExecution(ctx context.Context, id uint64) (*model.TaskExecution, error) { - return repository.GetTaskExecutionByID(ctx, id) + taskSvc, err := requireTaskService(ctx) + if err != nil { + return nil, err + } + dto, err := taskSvc.GetExecution(ctx, id) + if err != nil || dto == nil { + return nil, err + } + row := executionFromDTO(*dto) + return &row, nil +} + +func executionFromDTO(dto contracts.TaskExecutionDTO) model.TaskExecution { + return model.TaskExecution{ + ID: dto.ID, + TaskID: dto.TaskID, + TaskType: dto.TaskType, + TaskName: dto.TaskName, + Status: model.TaskExecutionStatus(dto.Status), + Retryable: dto.Retryable, + MaxRetry: dto.MaxRetry, + RetryCount: dto.RetryCount, + Log: dto.Log, + ErrorMessage: dto.ErrorMessage, + Result: dto.Result, + StartedAt: dto.StartedAt, + FinishedAt: dto.FinishedAt, + Duration: dto.Duration, + Payload: dto.Payload, + TriggeredBy: dto.TriggeredBy, + CreatedAt: dto.CreatedAt, + UpdatedAt: dto.UpdatedAt, + } } // RetryTask re-dispatches a failed execution as a new task run. func RetryTask(ctx context.Context, id uint64) (string, error) { - taskSvc, err := requireTaskService() + taskSvc, err := requireTaskService(ctx) if err != nil { return "", err } @@ -126,7 +172,7 @@ func CreateSchedule(ctx context.Context, req model.CreateScheduleRequest) (*mode return nil, errs.ErrInvalidCronExpression } - taskSvc, err := requireTaskService() + taskSvc, err := requireTaskService(ctx) if err != nil { return nil, err } @@ -168,7 +214,7 @@ func UpdateSchedule(ctx context.Context, id uint64, req model.UpdateScheduleRequ return nil, errs.ErrInvalidCronExpression } - taskSvc, err := requireTaskService() + taskSvc, err := requireTaskService(ctx) if err != nil { return nil, err } @@ -203,7 +249,7 @@ func DeleteSchedule(ctx context.Context, id uint64) error { return fmt.Errorf("%s: %w", errs.ScheduleDeleteFailed, err) } - if taskSvc := GetTaskService(); taskSvc != nil { + if taskSvc := GetTaskService(ctx); taskSvc != nil { reloadScheduler(ctx, taskSvc) } return nil diff --git a/backend/plugins/domain/auth/plugin.go b/backend/plugins/domain/auth/plugin.go index b836bb1d..eff4d138 100644 --- a/backend/plugins/domain/auth/plugin.go +++ b/backend/plugins/domain/auth/plugin.go @@ -87,21 +87,8 @@ func (p *Plugin) Apply(ctx *core.Context) error { SetSessionConfig(cfg) } - // 0. Bind DBService & CacheService from Context - if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { - setDBService(db) - } else { - core.When[contracts.DBService](ctx, func(db contracts.DBService) { - setDBService(db) - }) - } - if cache, err := core.Inject[contracts.CacheService](ctx); err == nil && cache != nil { - setCacheService(cache) - } else { - core.When[contracts.CacheService](ctx, func(cache contracts.CacheService) { - setCacheService(cache) - }) - } + core.Bind[contracts.DBService](ctx, setDBService) + core.Bind[contracts.CacheService](ctx, setCacheService) ctx.OnDispose(func() error { setDBService(nil) setCacheService(nil) diff --git a/backend/plugins/domain/cap/plugin.go b/backend/plugins/domain/cap/plugin.go index 2c4703dc..148b02ed 100644 --- a/backend/plugins/domain/cap/plugin.go +++ b/backend/plugins/domain/cap/plugin.go @@ -59,14 +59,7 @@ func (p *Plugin) Apply(ctx *core.Context) error { SetSecret([]byte(cfg.SessionSecret)) } - // 0. Bind DBService from Context - if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { - setDBService(db) - } else { - core.When[contracts.DBService](ctx, func(db contracts.DBService) { - setDBService(db) - }) - } + core.Bind[contracts.DBService](ctx, setDBService) ctx.OnDispose(func() error { setDBService(nil) return nil diff --git a/backend/plugins/domain/domain_test.go b/backend/plugins/domain/domain_test.go index efa54eb4..32a031a1 100644 --- a/backend/plugins/domain/domain_test.go +++ b/backend/plugins/domain/domain_test.go @@ -236,7 +236,7 @@ func TestUserPlugin(t *testing.T) { assert.Len(t, list, 1) assert.Equal(t, "bob", list[0].Username) - // 9. Tasks & Schedules + // 9. Tasks taskDef, ok := ctx.Tasks().Get("user:send_email_code") require.True(t, ok) assert.Equal(t, 3, taskDef.Retry) diff --git a/backend/plugins/domain/message_gateway/errs/errs.go b/backend/plugins/domain/message_gateway/errs/errs.go index e6fa4280..6d4c1538 100644 --- a/backend/plugins/domain/message_gateway/errs/errs.go +++ b/backend/plugins/domain/message_gateway/errs/errs.go @@ -28,13 +28,15 @@ var ( // User-facing validation and error message constants. const ( - ErrNameRequired = "name is required" - ErrTypeInvalid = "type must be telegram or qq" - ErrTelegramTokenRequired = "telegram bot secret is required" //nolint:gosec // user-facing validation text - ErrQQCredentialsRequired = "qq app id and secret are required" //nolint:gosec // user-facing validation text - ErrChannelNotFound = "channel not found" - ErrChannelProbeFailed = "channel probe failed" - MaskedSecret = "********" + ErrNameRequired = "name is required" + ErrTypeInvalid = "type must be telegram or qq" + ErrTelegramTokenRequired = "telegram bot secret is required" //nolint:gosec // user-facing validation text + ErrQQCredentialsRequired = "qq app id and secret are required" //nolint:gosec // user-facing validation text + ErrChannelNotFound = "channel not found" + ErrChannelProbeFailed = "channel probe failed" + ErrBotDispatchTextRequired = "message text is required" + ErrBotChannelNotRegistered = "channel adapter is not registered" + MaskedSecret = "********" ErrLoginRequired = "login required" ErrInvalidBindingID = "invalid binding id" diff --git a/backend/plugins/domain/message_gateway/plugin.go b/backend/plugins/domain/message_gateway/plugin.go index 482a6c26..038e5aa3 100644 --- a/backend/plugins/domain/message_gateway/plugin.go +++ b/backend/plugins/domain/message_gateway/plugin.go @@ -10,6 +10,8 @@ import ( "Wavelet/core/extpoints" "Wavelet/pkg/ginutil" "Wavelet/pkg/util" + "Wavelet/plugins/domain/message_gateway/channels/qq" + "Wavelet/plugins/domain/message_gateway/channels/telegram" "Wavelet/plugins/domain/message_gateway/handler" "Wavelet/plugins/domain/message_gateway/model" "Wavelet/plugins/domain/message_gateway/repository" @@ -94,37 +96,13 @@ func (p *Plugin) Apply(ctx *core.Context) error { if err := ctx.Config().Bind("app", &cfg); err == nil && cfg.SessionSecret != "" { service.SetCredentialSecret(cfg.SessionSecret) } - // 0. Bind DBService, CacheService, TaskService, UserService - if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { - repository.SetDBService(db) - } else { - core.When[contracts.DBService](ctx, func(db contracts.DBService) { - repository.SetDBService(db) - }) - } - if cache, err := core.Inject[contracts.CacheService](ctx); err == nil && cache != nil { + core.Bind[contracts.DBService](ctx, repository.SetDBService) + core.Bind[contracts.CacheService](ctx, func(cache contracts.CacheService) { repository.SetCacheService(cache) service.SetCacheService(cache) - } else { - core.When[contracts.CacheService](ctx, func(cache contracts.CacheService) { - repository.SetCacheService(cache) - service.SetCacheService(cache) - }) - } - if taskSvc, err := core.Inject[contracts.TaskService](ctx); err == nil && taskSvc != nil { - service.SetTaskService(taskSvc) - } else { - core.When[contracts.TaskService](ctx, func(taskSvc contracts.TaskService) { - service.SetTaskService(taskSvc) - }) - } - if uSvc, err := core.Inject[contracts.UserService](ctx); err == nil && uSvc != nil { - service.SetUserService(uSvc) - } else { - core.When[contracts.UserService](ctx, func(uSvc contracts.UserService) { - service.SetUserService(uSvc) - }) - } + }) + core.Bind[contracts.TaskService](ctx, service.SetTaskService) + core.Bind[contracts.UserService](ctx, service.SetUserService) ctx.OnDispose(func() error { repository.SetDBService(nil) repository.SetCacheService(nil) @@ -159,6 +137,9 @@ func (p *Plugin) Apply(ctx *core.Context) error { // 4. Register Admin Push HTTP Routes handler.RegisterAdminPushRoutes(ctx.Router().Group("/api/v1/admin"), loginMW, adminMW) + service.Register(model.MessageChannelTypeTelegram, telegram.New) + service.Register(model.MessageChannelTypeQQ, qq.New) + const defaultTaskRetry = 3 pushHandler := &service.PushHandler{} @@ -179,15 +160,8 @@ func (p *Plugin) Apply(ctx *core.Context) error { return pushHandler.Execute(c, payload) }, extpoints.WithTaskMeta(service.SendNotificationMeta), extpoints.WithTaskRetry(defaultTaskRetry)) - ctx.Task().Register("message_gateway:dispatch_bot_msg", func(_ context.Context, _ []byte) error { - return nil - }, - extpoints.WithTaskType("dispatch_bot_msg"), - extpoints.WithTaskName("分发 Bot 消息"), - extpoints.WithTaskDescription("异步处理与分发 Bot 下行消息"), - extpoints.WithTaskCategory("messaging"), - extpoints.WithTaskQueue("default"), - ) + ctx.Task().Register(service.TaskDispatchBotMsg, &service.BotDispatchHandler{}, + extpoints.WithTaskMeta(service.BotDispatchMeta)) ctx.Task().Register("message_gateway:cleanup_pairing_codes", func(c context.Context, _ []byte) error { return repository.DeleteExpiredPairingCodes(c) diff --git a/backend/plugins/domain/message_gateway/repository/repository.go b/backend/plugins/domain/message_gateway/repository/repository.go index b6e5302a..52a78eab 100644 --- a/backend/plugins/domain/message_gateway/repository/repository.go +++ b/backend/plugins/domain/message_gateway/repository/repository.go @@ -132,6 +132,15 @@ func ListBindingsByUser(ctx context.Context, userID uint64) ([]model.MessageBind return rows, nil } +// ListBindingsByChannel lists bindings on one messaging channel. +func ListBindingsByChannel(ctx context.Context, channelID uint64) ([]model.MessageBinding, error) { + var rows []model.MessageBinding + if err := GetDB(ctx).Where("channel_id = ?", channelID).Order("id DESC").Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + // GetMessageBinding loads a binding by id. func GetMessageBinding(ctx context.Context, id uint64) (*model.MessageBinding, error) { var b model.MessageBinding diff --git a/backend/plugins/domain/message_gateway/service/admin.go b/backend/plugins/domain/message_gateway/service/admin.go index 50633dea..d785d3a9 100644 --- a/backend/plugins/domain/message_gateway/service/admin.go +++ b/backend/plugins/domain/message_gateway/service/admin.go @@ -27,14 +27,14 @@ func ListDefinitions() []model.Definition { { Type: model.MessageChannelTypeTelegram, Fields: []model.Field{ - {Key: "token", Type: "password", Required: true}, - {Key: "api_base", Type: "text", Required: false}, + {Key: "token", Type: model.TypePassword, Required: true}, + {Key: "api_base", Type: model.TypeText, Required: false}, }, }, { Type: model.MessageChannelTypeQQ, Fields: []model.Field{ - {Key: "app_id", Type: "text", Required: true}, + {Key: "app_id", Type: model.TypeText, Required: true}, {Key: "client_secret", Type: "password", Required: true}, }, }, diff --git a/backend/plugins/domain/message_gateway/service/dispatch.go b/backend/plugins/domain/message_gateway/service/dispatch.go new file mode 100644 index 00000000..9e0c8f78 --- /dev/null +++ b/backend/plugins/domain/message_gateway/service/dispatch.go @@ -0,0 +1,190 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + "Wavelet/plugins/domain/message_gateway/errs" + "Wavelet/plugins/domain/message_gateway/model" + "Wavelet/plugins/domain/message_gateway/repository" + "context" + "encoding/json" + "errors" + "fmt" + "strings" +) + +const ( + // TaskDispatchBotMsg is the queue pattern for bot downlink dispatch. + TaskDispatchBotMsg = "message_gateway:dispatch_bot_msg" + // TaskTypeDispatchBotMsg is the admin type identifier for bot downlink dispatch. + TaskTypeDispatchBotMsg = "dispatch_bot_msg" + + taskQueueDefault = "default" + taskParamTypeString = "string" + paramNameText = "text" +) + +// BotDispatchMeta describes the bot downlink dispatch task. +var BotDispatchMeta = contracts.TaskMetaDTO{ + Type: TaskTypeDispatchBotMsg, + AsynqTask: TaskDispatchBotMsg, + Name: "分发 Bot 消息", + DisplayName: "分发 Bot 消息", + Description: "向已绑定的平台账号异步下发 Bot 文本消息", + Category: "messaging", + Queue: taskQueueDefault, + Retryable: true, + Params: []contracts.TaskParamDTO{ + {Name: paramNameText, Label: "消息内容", Type: model.TypeText, Required: true, Placeholder: "要发送的文本", Description: "下发给绑定用户的文本"}, + {Name: "channel_id", Label: "频道 ID", Type: taskParamTypeString, Required: false, Placeholder: "留空表示全部启用频道", Description: "仅向指定频道的绑定发送"}, + {Name: "user_id", Label: "用户 ID", Type: taskParamTypeString, Required: false, Placeholder: "留空表示频道下全部绑定", Description: "仅向指定 Wavelet 用户的绑定发送"}, + }, +} + +type botDispatchPayload struct { + Text string `json:"text"` + ChannelID uint64 `json:"channel_id,string"` + UserID uint64 `json:"user_id,string"` +} + +// BotDispatchHandler sends a text message through enabled bot channels. +type BotDispatchHandler struct{} + +// ValidatePayload requires a non-empty message body. +func (h *BotDispatchHandler) ValidatePayload(payload []byte) ([]byte, error) { + p, err := parseBotDispatchPayload(payload) + if err != nil { + return nil, err + } + return json.Marshal(p) +} + +// Execute delivers the text to matching channel bindings. +func (h *BotDispatchHandler) Execute(ctx context.Context, payload []byte) (*contracts.TaskResultDTO, error) { + p, err := parseBotDispatchPayload(payload) + if err != nil { + return nil, err + } + + channels, err := repository.ListEnabledMessageChannels(ctx) + if err != nil { + return nil, err + } + if p.ChannelID != 0 { + filtered := channels[:0] + for i := range channels { + if channels[i].ID == p.ChannelID { + filtered = append(filtered, channels[i]) + } + } + channels = filtered + if len(channels) == 0 { + return nil, errors.New(errs.ErrChannelNotFound) + } + } + + sent := 0 + failed := 0 + for i := range channels { + n, ferr := dispatchOnChannel(ctx, &channels[i], p.UserID, p.Text) + sent += n + failed += ferr + } + msg := fmt.Sprintf("Bot 消息已尝试发送,成功 %d,失败 %d", sent, failed) + if svc := GetTaskService(ctx); svc != nil { + svc.AppendLog(ctx, "%s", msg) + } + if sent == 0 && failed > 0 { + return nil, errors.New(msg) + } + return &contracts.TaskResultDTO{Message: msg}, nil +} + +func parseBotDispatchPayload(payload []byte) (botDispatchPayload, error) { + var p botDispatchPayload + if len(payload) > 0 { + if err := json.Unmarshal(payload, &p); err != nil { + return p, fmt.Errorf("%s: %w", errs.ErrInvalidJSONFormat, err) + } + } + p.Text = strings.TrimSpace(p.Text) + if p.Text == "" { + return p, errors.New(errs.ErrBotDispatchTextRequired) + } + return p, nil +} + +func dispatchOnChannel(ctx context.Context, row *model.MessageChannel, userID uint64, text string) (sent, failed int) { + factory, ok := Lookup(row.Type) + if !ok { + logger.ErrorF(ctx, "bot dispatch: %s type=%s", errs.ErrBotChannelNotRegistered, row.Type) + return 0, 1 + } + cfg, err := channelConfigFromRow(row) + if err != nil { + logger.ErrorF(ctx, "bot dispatch: decode channel %d: %v", row.ID, err) + return 0, 1 + } + ch, err := factory(cfg, nil) + if err != nil { + logger.ErrorF(ctx, "bot dispatch: create adapter %d: %v", row.ID, err) + return 0, 1 + } + if err := ch.Connect(ctx); err != nil { + logger.ErrorF(ctx, "bot dispatch: connect channel %d: %v", row.ID, err) + return 0, 1 + } + defer func() { _ = ch.Disconnect(ctx) }() + + bindings, err := repository.ListBindingsByChannel(ctx, row.ID) + if err != nil { + logger.ErrorF(ctx, "bot dispatch: list bindings %d: %v", row.ID, err) + return 0, 1 + } + for i := range bindings { + if userID != 0 && bindings[i].UserID != userID { + continue + } + to := model.Recipient{ + ChatID: bindings[i].PlatformUserID, + PlatformUserID: bindings[i].PlatformUserID, + } + if err := ch.Send(ctx, to, model.OutboundMessage{Text: text}); err != nil { + logger.ErrorF(ctx, "bot dispatch: send channel=%d user=%d: %v", row.ID, bindings[i].UserID, err) + failed++ + continue + } + sent++ + } + return sent, failed +} + +func channelConfigFromRow(row *model.MessageChannel) (model.ChannelConfig, error) { + creds, err := DecryptCredentials(row.Credentials) + if err != nil { + return model.ChannelConfig{}, err + } + if creds == nil { + creds = map[string]string{} + } + if creds["bot_token"] == "" && creds["token"] != "" { + creds["bot_token"] = creds["token"] + } + if creds["app_secret"] == "" && creds["client_secret"] != "" { + creds["app_secret"] = creds["client_secret"] + } + extra := ParseExtra(row.Extra) + if extra["base_url"] == "" && creds["api_base"] != "" { + extra["base_url"] = creds["api_base"] + } + return model.ChannelConfig{ + ID: row.ID, + Type: row.Type, + Name: row.Name, + Credentials: creds, + Extra: extra, + }, nil +} diff --git a/backend/plugins/domain/message_gateway/service/dispatch_test.go b/backend/plugins/domain/message_gateway/service/dispatch_test.go new file mode 100644 index 00000000..9ef76d5a --- /dev/null +++ b/backend/plugins/domain/message_gateway/service/dispatch_test.go @@ -0,0 +1,47 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service_test + +import ( + "Wavelet/plugins/domain/message_gateway/service" + "context" + "path/filepath" + "testing" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "Wavelet/plugins/domain/message_gateway/model" + "Wavelet/plugins/domain/message_gateway/repository" +) + +type dispatchTestDB struct{ db *gorm.DB } + +func (m *dispatchTestDB) GORM() *gorm.DB { return m.db } +func (m *dispatchTestDB) DB(ctx context.Context) *gorm.DB { return m.db.WithContext(ctx) } +func (m *dispatchTestDB) Named(_ string) *gorm.DB { return m.db } + +func TestBotDispatchValidatePayload(t *testing.T) { + h := &service.BotDispatchHandler{} + _, err := h.ValidatePayload([]byte(`{}`)) + require.Error(t, err) + _, err = h.ValidatePayload([]byte(`{"text":"hello"}`)) + require.NoError(t, err) +} + +func TestBotDispatchNoChannels(t *testing.T) { + testDB, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "dispatch.db")), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, testDB.AutoMigrate(&model.MessageChannel{}, &model.MessageBinding{})) + repository.SetDBServiceForTest(&dispatchTestDB{db: testDB}) + t.Cleanup(func() { repository.SetDBServiceForTest(nil) }) + + h := &service.BotDispatchHandler{} + res, err := h.Execute(context.Background(), []byte(`{"text":"hello"}`)) + require.NoError(t, err) + require.NotNil(t, res) + assert.Contains(t, res.Message, "成功 0") +} diff --git a/backend/plugins/domain/message_gateway/service/push.go b/backend/plugins/domain/message_gateway/service/push.go index 0ea7081f..e7d6c646 100644 --- a/backend/plugins/domain/message_gateway/service/push.go +++ b/backend/plugins/domain/message_gateway/service/push.go @@ -52,10 +52,12 @@ func GetBuiltInEvents() []model.EventMetadata { // PushRegistryAdapter adapts contracts.PushRegistry onto the built-in event store. type PushRegistryAdapter struct{} +// RegisterBuiltInEvent records a built-in push event definition. func (PushRegistryAdapter) RegisterBuiltInEvent(meta contracts.PushEventMeta) { RegisterBuiltInEvent(eventMetadataFromContract(meta)) } +// SyncEvents persists registered built-in events into the database. func (PushRegistryAdapter) SyncEvents(ctx context.Context) error { return SyncEvents(ctx) } @@ -108,7 +110,7 @@ func ListPushEvents(ctx context.Context) ([]model.PushEvent, error) { // CreatePushEvent stores a push event configuration for a built-in event or task type. func CreatePushEvent(ctx context.Context, req model.CreatePushEventRequest) (model.PushEvent, error) { - eventKey, eventName, defaultTemplateBytes, err := GetEventInfo(req) + eventKey, eventName, defaultTemplateBytes, err := GetEventInfo(ctx, req) if err != nil { return model.PushEvent{}, err } @@ -650,10 +652,10 @@ func FindBuiltInEvent(key string) (model.EventMetadata, bool) { // GetEventInfo derives the event key, display name and default template for a // task-completion based event or a registered built-in event key. -func GetEventInfo(req model.CreatePushEventRequest) (string, string, []byte, error) { +func GetEventInfo(ctx context.Context, req model.CreatePushEventRequest) (string, string, []byte, error) { if req.TaskType != "" { taskName := req.TaskType - if taskSvc := GetTaskService(); taskSvc != nil { + if taskSvc := GetTaskService(ctx); taskSvc != nil { if meta, ok := taskSvc.GetTaskMeta(req.TaskType); ok { taskName = meta.DisplayName } @@ -694,7 +696,7 @@ func EnqueuePushTask(ctx context.Context, payload model.SendPayload) error { if err != nil { return err } - if taskSvc := GetTaskService(); taskSvc != nil { + if taskSvc := GetTaskService(ctx); taskSvc != nil { _, err = taskSvc.Dispatch(ctx, "send_notification", payloadBytes, "system") return err } @@ -749,13 +751,13 @@ var SendNotificationMeta = contracts.TaskMetaDTO{ Category: "push", SupportsTime: false, MaxRetry: 3, - Queue: "default", + Queue: taskQueueDefault, Retryable: true, Params: []contracts.TaskParamDTO{ { Name: "event_key", Label: "事件标识", - Type: "string", + Type: taskParamTypeString, Required: true, Placeholder: "admin_login", Description: "事件标识 (如 admin_login)", @@ -763,7 +765,7 @@ var SendNotificationMeta = contracts.TaskMetaDTO{ { Name: "target", Label: "目标接收者", - Type: "string", + Type: taskParamTypeString, Required: false, Description: "目标接收者", }, diff --git a/backend/plugins/domain/message_gateway/service/service.go b/backend/plugins/domain/message_gateway/service/service.go index dfd33db5..b298c5ec 100644 --- a/backend/plugins/domain/message_gateway/service/service.go +++ b/backend/plugins/domain/message_gateway/service/service.go @@ -251,10 +251,8 @@ func SetUserService(s contracts.UserService) { // GetCache resolves the cache service for the context. func GetCache(ctx context.Context) contracts.CacheService { - if c, ok := ctx.(*core.Context); ok && c != nil { - if s, err := core.Inject[contracts.CacheService](c); err == nil && s != nil { - return s - } + if s, err := core.InjectFrom[contracts.CacheService](ctx); err == nil && s != nil { + return s } cacheMu.RLock() s := cacheSvc @@ -263,7 +261,10 @@ func GetCache(ctx context.Context) contracts.CacheService { } // GetTaskService returns the task service. -func GetTaskService() contracts.TaskService { +func GetTaskService(ctx context.Context) contracts.TaskService { + if s, err := core.InjectFrom[contracts.TaskService](ctx); err == nil && s != nil { + return s + } taskMu.RLock() defer taskMu.RUnlock() return taskSvc @@ -271,10 +272,8 @@ func GetTaskService() contracts.TaskService { // GetUserService resolves the user service for the context. func GetUserService(ctx context.Context) contracts.UserService { - if c, ok := ctx.(*core.Context); ok && c != nil { - if s, err := core.Inject[contracts.UserService](c); err == nil && s != nil { - return s - } + if s, err := core.InjectFrom[contracts.UserService](ctx); err == nil && s != nil { + return s } userMu.RLock() s := userSvc diff --git a/backend/plugins/domain/risk_control/plugin.go b/backend/plugins/domain/risk_control/plugin.go index 3976fc3a..c879c381 100644 --- a/backend/plugins/domain/risk_control/plugin.go +++ b/backend/plugins/domain/risk_control/plugin.go @@ -94,14 +94,7 @@ func (p *Plugin) Apply(ctx *core.Context) error { SetAccessLogEnabled(chCfg.Enabled) logstore.SetDefaultDatabases(dbCfg.Enabled, chCfg.Enabled) - // 0. Bind DBService - if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { - logstore.SetDBService(db) - } else { - core.When[contracts.DBService](ctx, func(db contracts.DBService) { - logstore.SetDBService(db) - }) - } + core.Bind[contracts.DBService](ctx, logstore.SetDBService) ctx.OnDispose(func() error { logstore.SetDBService(nil) return nil diff --git a/backend/plugins/domain/upload/plugin.go b/backend/plugins/domain/upload/plugin.go index 5b4cc281..310eef97 100644 --- a/backend/plugins/domain/upload/plugin.go +++ b/backend/plugins/domain/upload/plugin.go @@ -12,7 +12,6 @@ import ( "Wavelet/plugins/domain/upload/handler" "Wavelet/plugins/domain/upload/shared" "Wavelet/plugins/domain/upload/task" - "context" "embed" "reflect" @@ -56,50 +55,11 @@ func (p *Plugin) Manifest() core.Manifest { // Apply registers upload routes, tasks, and settings into the Context. func (p *Plugin) Apply(ctx *core.Context) error { - // Bind DBService - if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { - shared.SetDBService(db) - } else { - core.When[contracts.DBService](ctx, func(db contracts.DBService) { - shared.SetDBService(db) - }) - } - - // Bind CacheService - if cache, err := core.Inject[contracts.CacheService](ctx); err == nil && cache != nil { - shared.SetCacheService(cache) - } else { - core.When[contracts.CacheService](ctx, func(cache contracts.CacheService) { - shared.SetCacheService(cache) - }) - } - - // Bind StorageService - if storage, err := core.Inject[contracts.StorageService](ctx); err == nil && storage != nil { - shared.SetStorageService(storage) - } else { - core.When[contracts.StorageService](ctx, func(storage contracts.StorageService) { - shared.SetStorageService(storage) - }) - } - - // Bind TaskService - if taskSvc, err := core.Inject[contracts.TaskService](ctx); err == nil && taskSvc != nil { - shared.SetTaskService(taskSvc) - } else { - core.When[contracts.TaskService](ctx, func(taskSvc contracts.TaskService) { - shared.SetTaskService(taskSvc) - }) - } - - // Bind AuthService - if authSvc, err := core.Inject[contracts.AuthService](ctx); err == nil && authSvc != nil { - shared.SetAuthService(authSvc) - } else { - core.When[contracts.AuthService](ctx, func(authSvc contracts.AuthService) { - shared.SetAuthService(authSvc) - }) - } + core.Bind[contracts.DBService](ctx, shared.SetDBService) + core.Bind[contracts.CacheService](ctx, shared.SetCacheService) + core.Bind[contracts.StorageService](ctx, shared.SetStorageService) + core.Bind[contracts.TaskService](ctx, shared.SetTaskService) + core.Bind[contracts.AuthService](ctx, shared.SetAuthService) ctx.OnDispose(func() error { shared.ResetServices() @@ -147,31 +107,10 @@ func (p *Plugin) Apply(ctx *core.Context) error { defaultSingleRetry = 1 ) - // 3. Register tasks. Handlers take raw payload bytes rather than a driver - // specific task type so they run under both the asynq and in-process workers. - cleanupHandler := &task.SystemCleanupHandler{} - ctx.Task().Register(task.SystemCleanupTask, func(c context.Context, payload []byte) error { - _, err := cleanupHandler.Execute(c, payload) - return err - }, extpoints.WithTaskMeta(task.SystemCleanupMeta), extpoints.WithTaskRetry(defaultCleanupRetry)) - - rebuildStatsHandler := &task.RebuildUploadStatsHandler{} - ctx.Task().Register(task.RebuildUploadStatsTask, func(c context.Context, payload []byte) error { - _, err := rebuildStatsHandler.Execute(c, payload) - return err - }, extpoints.WithTaskMeta(task.RebuildUploadStatsMeta), extpoints.WithTaskRetry(defaultStatsRetry)) - - migrationHandler := &task.MigrationHandler{} - ctx.Task().Register(task.StorageMigrationTask, func(c context.Context, payload []byte) error { - _, err := migrationHandler.Execute(c, payload) - return err - }, extpoints.WithTaskMeta(task.StorageMigrationMeta), extpoints.WithTaskRetry(defaultSingleRetry)) - - warmHandler := &task.WarmImageCacheHandler{} - ctx.Task().Register(task.WarmImageCacheTask, func(c context.Context, payload []byte) error { - _, err := warmHandler.Execute(c, payload) - return err - }, extpoints.WithTaskMeta(task.WarmImageCacheMeta), extpoints.WithTaskRetry(1)) + ctx.Task().Register(task.SystemCleanupTask, &task.SystemCleanupHandler{}, extpoints.WithTaskMeta(task.SystemCleanupMeta), extpoints.WithTaskRetry(defaultCleanupRetry)) + ctx.Task().Register(task.RebuildUploadStatsTask, &task.RebuildUploadStatsHandler{}, extpoints.WithTaskMeta(task.RebuildUploadStatsMeta), extpoints.WithTaskRetry(defaultStatsRetry)) + ctx.Task().Register(task.StorageMigrationTask, &task.MigrationHandler{}, extpoints.WithTaskMeta(task.StorageMigrationMeta), extpoints.WithTaskRetry(defaultSingleRetry)) + ctx.Task().Register(task.WarmImageCacheTask, &task.WarmImageCacheHandler{}, extpoints.WithTaskMeta(task.WarmImageCacheMeta), extpoints.WithTaskRetry(1)) // 4. Register Cron Schedule ctx.Schedule().RegisterCron("0 3 * * *", task.SystemCleanupTask, nil) diff --git a/backend/plugins/domain/upload/shared/context_services.go b/backend/plugins/domain/upload/shared/context_services.go index cb6c2415..7e9494cd 100644 --- a/backend/plugins/domain/upload/shared/context_services.go +++ b/backend/plugins/domain/upload/shared/context_services.go @@ -69,10 +69,8 @@ func ResetServices() { // GetDB resolves the GORM DB instance. func GetDB(ctx context.Context) *gorm.DB { - if c, ok := ctx.(*core.Context); ok && c != nil { - if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { - return s.DB(ctx) - } + if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil { + return s.DB(ctx) } svcMu.RLock() s := dbSvc @@ -85,10 +83,8 @@ func GetDB(ctx context.Context) *gorm.DB { // GetCache resolves the CacheService instance. func GetCache(ctx context.Context) contracts.CacheService { - if c, ok := ctx.(*core.Context); ok && c != nil { - if s, err := core.Inject[contracts.CacheService](c); err == nil && s != nil { - return s - } + if s, err := core.InjectFrom[contracts.CacheService](ctx); err == nil && s != nil { + return s } svcMu.RLock() s := cacheSvc @@ -98,10 +94,8 @@ func GetCache(ctx context.Context) contracts.CacheService { // GetStorage resolves the StorageService instance. func GetStorage(ctx context.Context) contracts.StorageService { - if c, ok := ctx.(*core.Context); ok && c != nil { - if s, err := core.Inject[contracts.StorageService](c); err == nil && s != nil { - return s - } + if s, err := core.InjectFrom[contracts.StorageService](ctx); err == nil && s != nil { + return s } svcMu.RLock() s := storageSvc @@ -110,7 +104,10 @@ func GetStorage(ctx context.Context) contracts.StorageService { } // GetTaskService resolves the TaskService instance. -func GetTaskService() contracts.TaskService { +func GetTaskService(ctx context.Context) contracts.TaskService { + if s, err := core.InjectFrom[contracts.TaskService](ctx); err == nil && s != nil { + return s + } svcMu.RLock() defer svcMu.RUnlock() return taskSvc @@ -118,10 +115,8 @@ func GetTaskService() contracts.TaskService { // GetAuthService resolves the AuthService instance. func GetAuthService(ctx context.Context) contracts.AuthService { - if c, ok := ctx.(*core.Context); ok && c != nil { - if s, err := core.Inject[contracts.AuthService](c); err == nil && s != nil { - return s - } + if s, err := core.InjectFrom[contracts.AuthService](ctx); err == nil && s != nil { + return s } svcMu.RLock() s := authSvc diff --git a/backend/plugins/domain/user/errs.go b/backend/plugins/domain/user/errs.go index df4102f2..9f132948 100644 --- a/backend/plugins/domain/user/errs.go +++ b/backend/plugins/domain/user/errs.go @@ -40,4 +40,12 @@ const ( //nolint:gosec // error message, not hardcoded credentials errServicePasswordTooShort = "密码长度至少为 8 位" errUniqueUsernameFailed = "failed to generate unique username" + errInvalidEmail = "邮箱地址无效" + errInvalidEmailCode = "验证码必须是 6 位数字" + errInvalidTaskPayload = "任务参数无效" + errMailSubjectRequired = "邮件主题不能为空" + errMailBodyRequired = "邮件内容不能为空" + errSMTPNotConfigured = "SMTP 未配置" + errEmailCacheUnavailable = "缓存服务不可用,无法保存验证码" + errSendEmailFailed = "邮件发送失败" ) diff --git a/backend/plugins/domain/user/handlers.go b/backend/plugins/domain/user/handlers.go index 8ddea90b..4df8b824 100644 --- a/backend/plugins/domain/user/handlers.go +++ b/backend/plugins/domain/user/handlers.go @@ -11,6 +11,7 @@ import ( "crypto/rand" "crypto/sha256" "encoding/hex" + "encoding/json" "net/http" "strconv" "sync" @@ -190,10 +191,36 @@ func Logout(c *gin.Context) { // @Tags user // @Accept json // @Produce json +// @Param request body user.sendEmailCodeRequest true "目标邮箱" // @Success 200 {object} response.Any "发送成功" // @Failure 400 {object} response.Any "参数错误" +// @Failure 500 {object} response.Any "发送失败" // @Router /api/v1/user/send-email-code [post] func SendEmailCode(c *gin.Context) { + var req sendEmailCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, errInvalidParams) + return + } + payload, err := json.Marshal(sendEmailCodePayload{Email: req.Email}) + if err != nil { + response.AbortInternal(c, errSendEmailFailed) + return + } + ctx := c.Request.Context() + if taskSvc := getTaskService(ctx); taskSvc != nil { + if _, err := taskSvc.Dispatch(ctx, TaskTypeSendEmailCode, payload, "http"); err != nil { + logger.ErrorF(ctx, "dispatch send_email_code failed: %v", err) + response.AbortInternal(c, errSendEmailFailed) + return + } + c.JSON(http.StatusOK, response.OK(gin.H{"sent": true})) + return + } + if _, err := (&SendEmailCodeHandler{}).Execute(ctx, payload); err != nil { + response.AbortBadRequest(c, err.Error()) + return + } c.JSON(http.StatusOK, response.OK(gin.H{"sent": true})) } diff --git a/backend/plugins/domain/user/models.go b/backend/plugins/domain/user/models.go index b742f9a9..9e367c57 100644 --- a/backend/plugins/domain/user/models.go +++ b/backend/plugins/domain/user/models.go @@ -109,6 +109,10 @@ type registerRequest struct { Email string `json:"email"` } +type sendEmailCodeRequest struct { + Email string `json:"email" binding:"required"` +} + // changePasswordRequest 修改密码请求参数 type changePasswordRequest struct { OldPassword string `json:"old_password" binding:"required"` diff --git a/backend/plugins/domain/user/plugin.go b/backend/plugins/domain/user/plugin.go index a46c37b4..42e93383 100644 --- a/backend/plugins/domain/user/plugin.go +++ b/backend/plugins/domain/user/plugin.go @@ -9,7 +9,6 @@ import ( "Wavelet/core/contracts" "Wavelet/core/extpoints" "Wavelet/pkg/ginutil" - "context" "embed" "reflect" @@ -76,16 +75,13 @@ func (p *Plugin) Manifest() core.Manifest { // Apply registers user migrations, services, routes, tasks, schedules, and settings into the Context. func (p *Plugin) Apply(ctx *core.Context) error { - // 0. Bind DBService from Context - if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { - SetDBService(db) - } else { - core.When[contracts.DBService](ctx, func(db contracts.DBService) { - SetDBService(db) - }) - } + core.Bind[contracts.DBService](ctx, SetDBService) + core.Bind[contracts.CacheService](ctx, SetCacheService) + core.Bind[contracts.TaskService](ctx, SetTaskService) ctx.OnDispose(func() error { SetDBService(nil) + SetCacheService(nil) + SetTaskService(nil) return nil }) @@ -101,11 +97,8 @@ func (p *Plugin) Apply(ctx *core.Context) error { if mw, ok := authSvc.DisallowTokenAuthMiddleware().(gin.HandlerFunc); ok { noTokenMW = mw } - } else { - core.When[contracts.AuthService](ctx, func(svc contracts.AuthService) { - SetAuthService(svc) - }) } + core.Bind[contracts.AuthService](ctx, SetAuthService) ctx.OnDispose(func() error { SetAuthService(nil) return nil @@ -155,90 +148,12 @@ func (p *Plugin) Apply(ctx *core.Context) error { } } - const ( - defaultUserTaskRetry = 3 - paramTypeString = "string" - paramNameEmail = "email" - ) - - // 4. Register background tasks - ctx.Task().Register("user:send_email_code", func(_ context.Context, _ []byte) error { - return nil - }, - extpoints.WithTaskType("send_email_code"), - extpoints.WithTaskName("发送邮箱验证码"), - extpoints.WithTaskDescription("异步发送用户注册与验证邮箱验证码"), - extpoints.WithTaskCategory("user"), - extpoints.WithTaskRetry(defaultUserTaskRetry), - extpoints.WithTaskQueue("default"), - extpoints.WithTaskRetryable(true), - extpoints.WithTaskParams( - contracts.TaskParamDTO{ - Name: paramNameEmail, - Label: "目标邮箱", - Type: paramTypeString, - Required: true, - Placeholder: "user@example.com", - Description: "接收验证码的目标邮箱", - }, - contracts.TaskParamDTO{ - Name: "code", - Label: "验证码", - Type: paramTypeString, - Required: true, - Placeholder: "123456", - Description: "6 位数字验证码", - }, - ), - ) - - ctx.Task().Register("mail:send", func(_ context.Context, _ []byte) error { - return nil - }, - extpoints.WithTaskType("send_email"), - extpoints.WithTaskName("发送邮件"), - extpoints.WithTaskDescription("异步发送系统邮件"), - extpoints.WithTaskCategory("mail"), - extpoints.WithTaskRetry(defaultUserTaskRetry), - extpoints.WithTaskQueue("default"), - extpoints.WithTaskRetryable(true), - extpoints.WithTaskParams( - contracts.TaskParamDTO{ - Name: "to", - Label: "接收邮箱 (To)", - Type: paramTypeString, - Required: true, - Placeholder: "receiver@example.com", - Description: "接收邮件的目标邮箱地址", - }, - contracts.TaskParamDTO{ - Name: "subject", - Label: "邮件主题 (Subject)", - Type: paramTypeString, - Required: true, - Placeholder: "请输入邮件主题", - Description: "发送邮件的主题标题", - }, - contracts.TaskParamDTO{ - Name: "body", - Label: "邮件内容 (Body)", - Type: "text", - Required: true, - Placeholder: "请输入邮件内容(支持 HTML格式)", - Description: "发送邮件的内容主体", - }, - ), - ) - - ctx.Task().Register("user:cleanup_inactive", func(_ context.Context, _ []byte) error { - return nil - }, - extpoints.WithTaskType("cleanup_inactive_users"), - extpoints.WithTaskName("清理未激活用户"), - extpoints.WithTaskDescription("清理长期未激活的注册用户与临时凭据"), - extpoints.WithTaskCategory("user"), - extpoints.WithTaskQueue("default"), - ) + ctx.Task().Register(TaskSendEmailCode, &SendEmailCodeHandler{}, + extpoints.WithTaskMeta(SendEmailCodeMeta), extpoints.WithTaskRetry(defaultUserTaskRetry)) + ctx.Task().Register(TaskSendMail, &SendMailHandler{}, + extpoints.WithTaskMeta(SendMailMeta), extpoints.WithTaskRetry(defaultUserTaskRetry)) + ctx.Task().Register(TaskCleanupInactive, &CleanupInactiveHandler{}, + extpoints.WithTaskMeta(CleanupInactiveMeta)) // 5. Register Settings Schemas ctx.Settings().Register(extpoints.SettingSchema{ diff --git a/backend/plugins/domain/user/repository.go b/backend/plugins/domain/user/repository.go index 2bb02a9e..94445a9c 100644 --- a/backend/plugins/domain/user/repository.go +++ b/backend/plugins/domain/user/repository.go @@ -8,8 +8,10 @@ import ( "Wavelet/core/contracts" "Wavelet/pkg/util" "context" + "errors" "strings" "sync" + "time" "gorm.io/gorm" ) @@ -27,10 +29,8 @@ func SetDBService(s contracts.DBService) { } func getDB(ctx context.Context) *gorm.DB { - if c, ok := ctx.(*core.Context); ok && c != nil { - if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { - return s.DB(ctx) - } + if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil { + return s.DB(ctx) } dbMu.RLock() @@ -180,6 +180,26 @@ func DeleteUserWithRelations(ctx context.Context, id uint64) error { }) } +// ListInactiveNeverLoggedInUserIDs returns non-admin users created before cutoff +// who have never logged in. Seeded admin/system accounts are excluded. +func ListInactiveNeverLoggedInUserIDs(ctx context.Context, cutoff time.Time) ([]uint64, error) { + db := getDB(ctx) + if db == nil { + return nil, errors.New("database not available") + } + var ids []uint64 + unixEpoch := time.Unix(0, 0).UTC() + err := db.Model(&User{}). + Where("is_admin = ? AND username NOT IN ?", false, []string{"admin", "system"}). + Where("created_at < ?", cutoff). + Where("last_login_at IS NULL OR last_login_at < ?", unixEpoch). + Pluck("id", &ids).Error + if err != nil { + return nil, err + } + return ids, nil +} + // GetFirstAdminUser 获取第一个管理员用户 func GetFirstAdminUser(ctx context.Context) (*User, error) { var u User diff --git a/backend/plugins/domain/user/task.go b/backend/plugins/domain/user/task.go new file mode 100644 index 00000000..439079aa --- /dev/null +++ b/backend/plugins/domain/user/task.go @@ -0,0 +1,385 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package user + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "Wavelet/pkg/logger" + pkgmail "Wavelet/pkg/mail" + "context" + "crypto/rand" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "net/mail" + "strconv" + "strings" + "sync" + "time" + "unicode" +) + +const ( + // TaskSendEmailCode is the queue pattern for email verification codes. + TaskSendEmailCode = "user:send_email_code" + // TaskTypeSendEmailCode is the admin type identifier for email verification codes. + TaskTypeSendEmailCode = "send_email_code" + // TaskSendMail is the queue pattern for generic outbound mail. + TaskSendMail = "mail:send" + // TaskTypeSendMail is the admin type identifier for generic outbound mail. + TaskTypeSendMail = "send_email" + // TaskCleanupInactive is the queue pattern for inactive-user cleanup. + TaskCleanupInactive = "user:cleanup_inactive" + // TaskTypeCleanupInactive is the admin type identifier for inactive-user cleanup. + TaskTypeCleanupInactive = "cleanup_inactive_users" + + defaultUserTaskRetry = 3 + emailCodeTTL = 10 * time.Minute + emailCodeCacheKeyPrefix = "user:email_code:" + inactiveRetentionDays = 30 + hoursPerDay = 24 + inactiveRetention = inactiveRetentionDays * hoursPerDay * time.Hour + smtpConfigKeyHost = "smtp_host" + smtpConfigKeyPort = "smtp_port" + smtpConfigKeyUsername = "smtp_username" + smtpConfigKeyPassword = "smtp_password" + defaultSMTPPort = 587 + emailCodeLength = 6 + emailCodeModulo = 1000000 + taskQueueDefault = "default" + taskParamTypeString = "string" + taskParamTypeText = "text" + paramNameEmail = "email" +) + +var smtpConfigKeys = []string{ + smtpConfigKeyHost, smtpConfigKeyPort, smtpConfigKeyUsername, smtpConfigKeyPassword, +} + +var ( + cacheMu sync.RWMutex + cacheSvc contracts.CacheService + taskMu sync.RWMutex + taskSvc contracts.TaskService +) + +// SetCacheService sets the cache contract used to store email verification codes. +func SetCacheService(s contracts.CacheService) { + cacheMu.Lock() + defer cacheMu.Unlock() + cacheSvc = s +} + +// SetTaskService sets the task contract used by HTTP handlers to enqueue mail jobs. +func SetTaskService(s contracts.TaskService) { + taskMu.Lock() + defer taskMu.Unlock() + taskSvc = s +} + +func getCache(ctx context.Context) contracts.CacheService { + if s, err := core.InjectFrom[contracts.CacheService](ctx); err == nil && s != nil { + return s + } + cacheMu.RLock() + defer cacheMu.RUnlock() + return cacheSvc +} + +func getTaskService(ctx context.Context) contracts.TaskService { + if s, err := core.InjectFrom[contracts.TaskService](ctx); err == nil && s != nil { + return s + } + taskMu.RLock() + defer taskMu.RUnlock() + return taskSvc +} + +func appendTaskLog(ctx context.Context, format string, args ...any) { + if svc := getTaskService(ctx); svc != nil { + svc.AppendLog(ctx, format, args...) + } +} + +// SendEmailCodeMeta describes the email verification-code task. +var SendEmailCodeMeta = contracts.TaskMetaDTO{ + Type: TaskTypeSendEmailCode, + AsynqTask: TaskSendEmailCode, + Name: "发送邮箱验证码", + DisplayName: "发送邮箱验证码", + Description: "异步发送用户注册与验证邮箱验证码", + Category: "user", + MaxRetry: defaultUserTaskRetry, + Queue: taskQueueDefault, + Retryable: true, + Params: []contracts.TaskParamDTO{ + {Name: paramNameEmail, Label: "目标邮箱", Type: taskParamTypeString, Required: true, Placeholder: "user@example.com", Description: "接收验证码的目标邮箱"}, + {Name: "code", Label: "验证码", Type: taskParamTypeString, Required: false, Placeholder: "123456", Description: "6 位数字验证码,留空则自动生成"}, + }, +} + +// SendMailMeta describes the generic outbound-mail task. +var SendMailMeta = contracts.TaskMetaDTO{ + Type: TaskTypeSendMail, + AsynqTask: TaskSendMail, + Name: "发送邮件", + DisplayName: "发送邮件", + Description: "异步发送系统邮件", + Category: "mail", + MaxRetry: defaultUserTaskRetry, + Queue: taskQueueDefault, + Retryable: true, + Params: []contracts.TaskParamDTO{ + {Name: "to", Label: "接收邮箱 (To)", Type: taskParamTypeString, Required: true, Placeholder: "receiver@example.com", Description: "接收邮件的目标邮箱地址"}, + {Name: "subject", Label: "邮件主题 (Subject)", Type: taskParamTypeString, Required: true, Placeholder: "请输入邮件主题", Description: "发送邮件的主题标题"}, + {Name: "body", Label: "邮件内容 (Body)", Type: taskParamTypeText, Required: true, Placeholder: "请输入邮件内容(支持 HTML格式)", Description: "发送邮件的内容主体"}, + }, +} + +// CleanupInactiveMeta describes the inactive-user cleanup task. +var CleanupInactiveMeta = contracts.TaskMetaDTO{ + Type: TaskTypeCleanupInactive, + AsynqTask: TaskCleanupInactive, + Name: "清理未激活用户", + DisplayName: "清理未激活用户", + Description: "清理长期未登录的注册用户及其访问令牌", + Category: "user", + Queue: taskQueueDefault, + Retryable: true, +} + +type sendEmailCodePayload struct { + Email string `json:"email"` + Code string `json:"code"` +} + +type sendMailPayload struct { + To string `json:"to"` + Subject string `json:"subject"` + Body string `json:"body"` +} + +// SendEmailCodeHandler sends a 6-digit email verification code and caches it. +type SendEmailCodeHandler struct{} + +// ValidatePayload checks the destination address and optional code. +func (h *SendEmailCodeHandler) ValidatePayload(payload []byte) ([]byte, error) { + p, err := parseSendEmailCodePayload(payload) + if err != nil { + return nil, err + } + return json.Marshal(p) +} + +// Execute generates (if needed), caches, and emails the verification code. +func (h *SendEmailCodeHandler) Execute(ctx context.Context, payload []byte) (*contracts.TaskResultDTO, error) { + p, err := parseSendEmailCodePayload(payload) + if err != nil { + return nil, err + } + if p.Code == "" { + p.Code, err = generateEmailCode() + if err != nil { + return nil, err + } + } + + cache := getCache(ctx) + if cache == nil { + return nil, errors.New(errEmailCacheUnavailable) + } + if err := cache.Set(ctx, emailCodeCacheKey(p.Email), p.Code, emailCodeTTL); err != nil { + return nil, fmt.Errorf("store email code: %w", err) + } + + cfg, err := loadSMTPConfig(ctx) + if err != nil { + return nil, err + } + subject := "邮箱验证码" + body := fmt.Sprintf("

您的验证码是 %s,%d 分钟内有效。

", p.Code, int(emailCodeTTL.Minutes())) + appendTaskLog(ctx, "发送邮箱验证码到 %s", maskEmail(p.Email)) + if err := pkgmail.SendMail(ctx, cfg, p.Email, subject, body); err != nil { + logger.ErrorF(ctx, "send email code failed: %v", err) + return nil, errors.New(errSendEmailFailed) + } + return &contracts.TaskResultDTO{Message: fmt.Sprintf("验证码已发送至 %s", maskEmail(p.Email))}, nil +} + +// SendMailHandler sends a generic HTML email through the configured SMTP server. +type SendMailHandler struct{} + +// ValidatePayload checks to/subject/body. +func (h *SendMailHandler) ValidatePayload(payload []byte) ([]byte, error) { + p, err := parseSendMailPayload(payload) + if err != nil { + return nil, err + } + return json.Marshal(p) +} + +// Execute sends the mail. +func (h *SendMailHandler) Execute(ctx context.Context, payload []byte) (*contracts.TaskResultDTO, error) { + p, err := parseSendMailPayload(payload) + if err != nil { + return nil, err + } + cfg, err := loadSMTPConfig(ctx) + if err != nil { + return nil, err + } + appendTaskLog(ctx, "发送邮件到 %s,主题: %s", maskEmail(p.To), p.Subject) + if err := pkgmail.SendMail(ctx, cfg, p.To, p.Subject, p.Body); err != nil { + logger.ErrorF(ctx, "send mail failed: %v", err) + return nil, errors.New(errSendEmailFailed) + } + return &contracts.TaskResultDTO{Message: fmt.Sprintf("邮件已发送至 %s", maskEmail(p.To))}, nil +} + +// CleanupInactiveHandler deletes users who registered long ago and never logged in. +type CleanupInactiveHandler struct{} + +// Execute removes stale never-logged-in non-admin users and their access tokens. +func (h *CleanupInactiveHandler) Execute(ctx context.Context, _ []byte) (*contracts.TaskResultDTO, error) { + cutoff := time.Now().Add(-inactiveRetention) + ids, err := ListInactiveNeverLoggedInUserIDs(ctx, cutoff) + if err != nil { + return nil, err + } + appendTaskLog(ctx, "扫描到 %d 个超过 %d 天未登录的注册用户", len(ids), int(inactiveRetention.Hours()/float64(hoursPerDay))) + deleted := 0 + for _, id := range ids { + if err := DeleteUserWithRelations(ctx, id); err != nil { + logger.ErrorF(ctx, "cleanup inactive user %d failed: %v", id, err) + continue + } + deleted++ + } + msg := fmt.Sprintf("已清理 %d 个长期未登录用户及其访问令牌", deleted) + appendTaskLog(ctx, "%s", msg) + return &contracts.TaskResultDTO{Message: msg}, nil +} + +func parseSendEmailCodePayload(payload []byte) (sendEmailCodePayload, error) { + var p sendEmailCodePayload + if len(payload) > 0 { + if err := json.Unmarshal(payload, &p); err != nil { + return p, errors.New(errInvalidTaskPayload) + } + } + p.Email = normalizeEmail(p.Email) + if err := validateEmail(p.Email); err != nil { + return p, err + } + p.Code = strings.TrimSpace(p.Code) + if p.Code != "" && !isSixDigitCode(p.Code) { + return p, errors.New(errInvalidEmailCode) + } + return p, nil +} + +func parseSendMailPayload(payload []byte) (sendMailPayload, error) { + var p sendMailPayload + if err := json.Unmarshal(payload, &p); err != nil { + return p, errors.New(errInvalidTaskPayload) + } + p.To = normalizeEmail(p.To) + p.Subject = strings.TrimSpace(p.Subject) + if err := validateEmail(p.To); err != nil { + return p, err + } + if p.Subject == "" { + return p, errors.New(errMailSubjectRequired) + } + if strings.TrimSpace(p.Body) == "" { + return p, errors.New(errMailBodyRequired) + } + return p, nil +} + +func loadSMTPConfig(ctx context.Context) (pkgmail.Config, error) { + db := getDB(ctx) + if db == nil { + return pkgmail.Config{}, errors.New(errSMTPNotConfigured) + } + var rows []struct { + Key string + Value string + } + if err := db.Table("w_system_configs"). + Select("key", "value"). + Where("key IN ?", smtpConfigKeys). + Find(&rows).Error; err != nil { + return pkgmail.Config{}, fmt.Errorf("read smtp config: %w", err) + } + cfg := pkgmail.Config{Port: defaultSMTPPort} + for _, row := range rows { + switch row.Key { + case smtpConfigKeyHost: + cfg.Host = strings.TrimSpace(row.Value) + case smtpConfigKeyPort: + if n, err := strconv.Atoi(strings.TrimSpace(row.Value)); err == nil && n > 0 { + cfg.Port = n + } + case smtpConfigKeyUsername: + cfg.Username = strings.TrimSpace(row.Value) + case smtpConfigKeyPassword: + cfg.Password = row.Value + } + } + if cfg.Host == "" || cfg.Username == "" { + return pkgmail.Config{}, errors.New(errSMTPNotConfigured) + } + return cfg, nil +} + +func generateEmailCode() (string, error) { + var buf [4]byte + if _, err := rand.Read(buf[:]); err != nil { + return "", err + } + n := binary.BigEndian.Uint32(buf[:]) % emailCodeModulo + return fmt.Sprintf("%06d", n), nil +} + +func emailCodeCacheKey(email string) string { + return emailCodeCacheKeyPrefix + normalizeEmail(email) +} + +func normalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +func validateEmail(email string) error { + if email == "" { + return errors.New(errEmailEmpty) + } + addr, err := mail.ParseAddress(email) + if err != nil || !strings.EqualFold(addr.Address, email) { + return errors.New(errInvalidEmail) + } + return nil +} + +func isSixDigitCode(code string) bool { + if len(code) != emailCodeLength { + return false + } + for _, r := range code { + if !unicode.IsDigit(r) { + return false + } + } + return true +} + +func maskEmail(email string) string { + at := strings.IndexByte(email, '@') + if at <= 1 { + return "***" + } + return email[:1] + "***" + email[at:] +} diff --git a/backend/plugins/domain/user/task_test.go b/backend/plugins/domain/user/task_test.go new file mode 100644 index 00000000..8c4ccb2b --- /dev/null +++ b/backend/plugins/domain/user/task_test.go @@ -0,0 +1,109 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package user_test + +import ( + "Wavelet/core/contracts" + "Wavelet/pkg/idgen" + "Wavelet/plugins/domain/user" + "context" + "path/filepath" + "testing" + "time" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type taskTestDB struct{ db *gorm.DB } + +func (m *taskTestDB) GORM() *gorm.DB { return m.db } +func (m *taskTestDB) DB(ctx context.Context) *gorm.DB { return m.db.WithContext(ctx) } +func (m *taskTestDB) Named(_ string) *gorm.DB { return m.db } + +type sysConfigRow struct { + Key string `gorm:"primaryKey;size:64"` + Value string `gorm:"type:text"` +} + +func (sysConfigRow) TableName() string { return "w_system_configs" } + +func setupUserTaskDB(t *testing.T) *gorm.DB { + t.Helper() + _ = idgen.Init(1) + testDB, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "user_task.db")), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, testDB.AutoMigrate(&user.User{}, &user.AccessToken{}, &sysConfigRow{})) + user.SetDBService(&taskTestDB{db: testDB}) + t.Cleanup(func() { user.SetDBService(nil) }) + return testDB +} + +func TestSendEmailCodeValidatePayload(t *testing.T) { + h := &user.SendEmailCodeHandler{} + _, err := h.ValidatePayload([]byte(`{"email":"not-an-email"}`)) + require.Error(t, err) + + out, err := h.ValidatePayload([]byte(`{"email":"User@Example.com"}`)) + require.NoError(t, err) + assert.Contains(t, string(out), `"user@example.com"`) +} + +func TestSendMailValidatePayload(t *testing.T) { + h := &user.SendMailHandler{} + _, err := h.ValidatePayload([]byte(`{"to":"a@b.com","subject":"","body":"x"}`)) + require.Error(t, err) + _, err = h.ValidatePayload([]byte(`{"to":"a@b.com","subject":"Hi","body":"

ok

"}`)) + require.NoError(t, err) +} + +func TestSendMailRequiresSMTP(t *testing.T) { + setupUserTaskDB(t) + h := &user.SendMailHandler{} + _, err := h.Execute(context.Background(), []byte(`{"to":"a@b.com","subject":"Hi","body":"

ok

"}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "SMTP") +} + +func TestCleanupInactiveNeverLoggedInUsers(t *testing.T) { + db := setupUserTaskDB(t) + old := time.Now().Add(-40 * 24 * time.Hour) + stale := user.User{ID: 42, Username: "stale", Password: "x", IsActive: true, CreatedAt: old} + require.NoError(t, db.Create(&stale).Error) + require.NoError(t, db.Model(&stale).Updates(map[string]any{ + "created_at": old, + "last_login_at": time.Time{}, + }).Error) + + fresh := user.User{ID: 43, Username: "fresh", Password: "x", IsActive: true, LastLoginAt: time.Now()} + require.NoError(t, db.Create(&fresh).Error) + + admin := user.User{ID: 1, Username: "admin", Password: "x", IsAdmin: true, CreatedAt: old} + require.NoError(t, db.Create(&admin).Error) + require.NoError(t, db.Model(&admin).Updates(map[string]any{ + "created_at": old, + "last_login_at": time.Time{}, + }).Error) + + h := &user.CleanupInactiveHandler{} + res, err := h.Execute(context.Background(), nil) + require.NoError(t, err) + require.NotNil(t, res) + assert.Contains(t, res.Message, "1") + + _, err = user.GetUserByID(context.Background(), 42) + assert.Error(t, err) + _, err = user.GetUserByID(context.Background(), 43) + require.NoError(t, err) + _, err = user.GetUserByID(context.Background(), 1) + require.NoError(t, err) +} + +func TestSendEmailCodeMetaExported(t *testing.T) { + assert.Equal(t, "send_email_code", user.SendEmailCodeMeta.Type) + assert.Equal(t, "user:send_email_code", user.SendEmailCodeMeta.AsynqTask) + _ = contracts.TaskHandler(&user.SendEmailCodeHandler{}) +} diff --git a/backend/plugins/drivers/driver_asynq_cron/plugin.go b/backend/plugins/drivers/driver_asynq_cron/plugin.go index d822d894..5439583c 100644 --- a/backend/plugins/drivers/driver_asynq_cron/plugin.go +++ b/backend/plugins/drivers/driver_asynq_cron/plugin.go @@ -120,23 +120,8 @@ func (p *Plugin) Apply(ctx *core.Context) error { } p.mu.Unlock() - // Bind DBService - if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { - setDBService(db) - } else { - core.When[contracts.DBService](ctx, func(db contracts.DBService) { - setDBService(db) - }) - } - - // Bind TaskService - if taskSvc, err := core.Inject[contracts.TaskService](ctx); err == nil && taskSvc != nil { - setTaskService(taskSvc) - } else { - core.When[contracts.TaskService](ctx, func(taskSvc contracts.TaskService) { - setTaskService(taskSvc) - }) - } + core.Bind[contracts.DBService](ctx, setDBService) + core.Bind[contracts.TaskService](ctx, setTaskService) ctx.OnDispose(func() error { setDBService(nil) diff --git a/backend/plugins/drivers/driver_asynq_worker/db_helper.go b/backend/plugins/drivers/driver_asynq_worker/db_helper.go index 025fbca1..a212de18 100644 --- a/backend/plugins/drivers/driver_asynq_worker/db_helper.go +++ b/backend/plugins/drivers/driver_asynq_worker/db_helper.go @@ -34,10 +34,8 @@ func SetRedisClient(c redis.UniversalClient) { } func getDB(ctx context.Context) *gorm.DB { - if c, ok := ctx.(*core.Context); ok && c != nil { - if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { - return s.DB(ctx) - } + if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil { + return s.DB(ctx) } dbMu.RLock() s := dbSvc diff --git a/backend/plugins/drivers/driver_asynq_worker/plugin.go b/backend/plugins/drivers/driver_asynq_worker/plugin.go index d16b1a6c..fee807f8 100644 --- a/backend/plugins/drivers/driver_asynq_worker/plugin.go +++ b/backend/plugins/drivers/driver_asynq_worker/plugin.go @@ -8,6 +8,7 @@ import ( "Wavelet/core" "Wavelet/core/contracts" "context" + "encoding/json" "errors" "fmt" "sync" @@ -144,14 +145,7 @@ func (p *Plugin) Apply(ctx *core.Context) error { ResetAsynqClient() p.mu.Unlock() - // 0. Bind DBService - if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { - setDBService(db) - } else { - core.When[contracts.DBService](ctx, func(db contracts.DBService) { - setDBService(db) - }) - } + core.Bind[contracts.DBService](ctx, setDBService) ctx.OnDispose(func() error { setDBService(nil) return nil @@ -191,12 +185,15 @@ func (p *Plugin) Start(_ context.Context) error { mux := asynq.NewServeMux() if p.coreCtx != nil && p.coreCtx.Tasks() != nil { + appCtx := p.coreCtx.Root() for _, td := range p.coreCtx.Tasks().Tasks() { handler, err := toAsynqHandler(td.Pattern, td.Handler) if err != nil { return fmt.Errorf("driver_asynq_worker: invalid handler for task pattern %q: %w", td.Pattern, err) } - mux.Handle(td.Pattern, handler) + mux.Handle(td.Pattern, asynq.HandlerFunc(func(c context.Context, t *asynq.Task) error { + return handler.ProcessTask(core.WithAppContext(c, appCtx), t) + })) } } @@ -285,6 +282,14 @@ func toAsynqHandler(pattern string, h any) (asynq.Handler, error) { RegisterHandler(pattern, th) return asynq.HandlerFunc(ProcessTask), nil } + if th, ok := h.(contracts.TaskHandler); ok { + RegisterHandler(pattern, contractTaskAdapter{inner: th}) + return asynq.HandlerFunc(ProcessTask), nil + } + if fn, ok := h.(func(context.Context, []byte) (*contracts.TaskResultDTO, error)); ok { + RegisterHandler(pattern, contractFuncAdapter{fn: fn}) + return asynq.HandlerFunc(ProcessTask), nil + } inner, err := toRawAsynqHandler(h) if err != nil { @@ -294,6 +299,58 @@ func toAsynqHandler(pattern string, h any) (asynq.Handler, error) { return asynq.HandlerFunc(ProcessTask), nil } +type contractTaskAdapter struct { + inner contracts.TaskHandler +} + +func (a contractTaskAdapter) Execute(ctx context.Context, payload []byte) (*TaskResult, error) { + res, err := a.inner.Execute(ctx, payload) + if err != nil { + return nil, err + } + return dtoToTaskResult(res), nil +} + +func (a contractTaskAdapter) ValidatePayload(payload []byte) ([]byte, error) { + if v, ok := a.inner.(PayloadValidator); ok { + return v.ValidatePayload(payload) + } + return payload, nil +} + +type contractFuncAdapter struct { + fn func(context.Context, []byte) (*contracts.TaskResultDTO, error) +} + +func (a contractFuncAdapter) Execute(ctx context.Context, payload []byte) (*TaskResult, error) { + res, err := a.fn(ctx, payload) + if err != nil { + return nil, err + } + return dtoToTaskResult(res), nil +} + +func dtoToTaskResult(res *contracts.TaskResultDTO) *TaskResult { + if res == nil { + return &TaskResult{Message: "ok"} + } + out := &TaskResult{Message: res.Message} + if res.Detail == nil { + return out + } + if s, ok := res.Detail.(string); ok { + out.Detail = s + return out + } + b, err := json.Marshal(res.Detail) + if err != nil { + out.Detail = fmt.Sprint(res.Detail) + return out + } + out.Detail = string(b) + return out +} + func toRawAsynqHandler(h any) (asynq.Handler, error) { switch fn := h.(type) { case asynq.HandlerFunc: diff --git a/backend/plugins/drivers/driver_http/plugin.go b/backend/plugins/drivers/driver_http/plugin.go index 72ebaa34..fd3fb5dc 100644 --- a/backend/plugins/drivers/driver_http/plugin.go +++ b/backend/plugins/drivers/driver_http/plugin.go @@ -121,27 +121,13 @@ func (p *Plugin) Apply(ctx *core.Context) error { } p.mu.Unlock() - // Bind DBService from Context - if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { - setDBService(db) - } else { - core.When[contracts.DBService](ctx, func(db contracts.DBService) { - setDBService(db) - }) - } + core.Bind[contracts.DBService](ctx, setDBService) ctx.OnDispose(func() error { setDBService(nil) return nil }) - // Bind CacheService from Context - if cache, err := core.Inject[contracts.CacheService](ctx); err == nil && cache != nil { - setCacheService(cache) - } else { - core.When[contracts.CacheService](ctx, func(cache contracts.CacheService) { - setCacheService(cache) - }) - } + core.Bind[contracts.CacheService](ctx, setCacheService) ctx.OnDispose(func() error { setCacheService(nil) return nil @@ -184,30 +170,8 @@ func (p *Plugin) Start(ctx context.Context) error { } } - // Mount routes collected in Context RouterExtension - if p.coreCtx != nil && p.coreCtx.Router() != nil { - SetWhitelist(p.coreCtx.Router().Whitelist()) - for _, rd := range p.coreCtx.Router().Routes() { - allHandlers := make([]gin.HandlerFunc, 0, len(rd.Middlewares)+len(rd.Handlers)) - - for _, m := range rd.Middlewares { - gh, err := toGinHandler(m) - if err != nil { - return fmt.Errorf("driver_http: invalid middleware for route %s %s: %w", rd.Method, rd.Path, err) - } - allHandlers = append(allHandlers, gh) - } - - for _, h := range rd.Handlers { - gh, err := toGinHandler(h) - if err != nil { - return fmt.Errorf("driver_http: invalid handler for route %s %s: %w", rd.Method, rd.Path, err) - } - allHandlers = append(allHandlers, gh) - } - - p.engine.Handle(rd.Method, rd.Path, allHandlers...) - } + if err := p.mountContextRoutes(ctx); err != nil { + return err } // Mount Swagger in non-production environments @@ -282,6 +246,41 @@ func (p *Plugin) Stop(ctx context.Context) error { return err } +func (p *Plugin) mountContextRoutes(ctx context.Context) error { + if p.coreCtx == nil || p.coreCtx.Router() == nil || p.engine == nil { + return nil + } + p.engine.Use(appContextMiddleware(ctx, p.coreCtx.Root())) + SetWhitelist(p.coreCtx.Router().Whitelist()) + for _, rd := range p.coreCtx.Router().Routes() { + allHandlers := make([]gin.HandlerFunc, 0, len(rd.Middlewares)+len(rd.Handlers)) + for _, m := range rd.Middlewares { + gh, err := toGinHandler(m) + if err != nil { + return fmt.Errorf("driver_http: invalid middleware for route %s %s: %w", rd.Method, rd.Path, err) + } + allHandlers = append(allHandlers, gh) + } + for _, h := range rd.Handlers { + gh, err := toGinHandler(h) + if err != nil { + return fmt.Errorf("driver_http: invalid handler for route %s %s: %w", rd.Method, rd.Path, err) + } + allHandlers = append(allHandlers, gh) + } + p.engine.Handle(rd.Method, rd.Path, allHandlers...) + } + return nil +} + +//nolint:contextcheck // middleware must wrap the gin request context, not Start's ctx +func appContextMiddleware(_ context.Context, appCtx *core.Context) gin.HandlerFunc { + return func(c *gin.Context) { + c.Request = c.Request.WithContext(core.WithAppContext(c.Request.Context(), appCtx)) + c.Next() + } +} + // Addr returns the current listening address (or configured address if not yet started). func (p *Plugin) Addr() string { p.mu.RLock() diff --git a/backend/plugins/drivers/driver_inproc_cron/plugin.go b/backend/plugins/drivers/driver_inproc_cron/plugin.go index 49fbc3fe..40caf4d9 100644 --- a/backend/plugins/drivers/driver_inproc_cron/plugin.go +++ b/backend/plugins/drivers/driver_inproc_cron/plugin.go @@ -83,7 +83,11 @@ func (p *Plugin) Start(ctx context.Context) error { p.scheduler = newInprocScheduler(p.coreCtx.Schedules(), p.coreCtx.Tasks(), taskSvc) } - return p.scheduler.Start(ctx) + runCtx := ctx + if p.coreCtx != nil { + runCtx = core.WithAppContext(ctx, p.coreCtx.Root()) + } + return p.scheduler.Start(runCtx) } // Stop terminates the in-process cron scheduler. diff --git a/backend/plugins/drivers/driver_inproc_worker/db_helper.go b/backend/plugins/drivers/driver_inproc_worker/db_helper.go index d564c491..32077db9 100644 --- a/backend/plugins/drivers/driver_inproc_worker/db_helper.go +++ b/backend/plugins/drivers/driver_inproc_worker/db_helper.go @@ -24,10 +24,8 @@ func setDBService(s contracts.DBService) { } func getDB(ctx context.Context) *gorm.DB { - if c, ok := ctx.(*core.Context); ok && c != nil { - if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { - return s.DB(ctx) - } + if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil { + return s.DB(ctx) } dbMu.RLock() s := dbSvc diff --git a/backend/plugins/drivers/driver_inproc_worker/executor.go b/backend/plugins/drivers/driver_inproc_worker/executor.go index fe211612..d22b2363 100644 --- a/backend/plugins/drivers/driver_inproc_worker/executor.go +++ b/backend/plugins/drivers/driver_inproc_worker/executor.go @@ -4,11 +4,14 @@ package driver_inproc_worker import ( + "Wavelet/core" + "Wavelet/core/contracts" "Wavelet/core/extpoints" "Wavelet/pkg/idgen" "Wavelet/pkg/logger" "Wavelet/pkg/util" "context" + "encoding/json" "errors" "fmt" "sync" @@ -40,6 +43,7 @@ type InprocQueue struct { // baseCtx is the app-lifetime context captured at Start; task handlers // derive their timeouts from it so shutdown cancellation propagates. baseCtx context.Context + appCtx *core.Context } // NewInprocQueue creates a new InprocQueue with a given concurrency and queue capacity. @@ -182,10 +186,14 @@ func (q *InprocQueue) executeTask(ctx context.Context, msg TaskMessage) { taskCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + if q.appCtx != nil { + taskCtx = core.WithAppContext(taskCtx, q.appCtx) + ctx = core.WithAppContext(ctx, q.appCtx) + } q.markRunning(ctx, msg) start := time.Now() - err := invokeHandler(taskCtx, td.Handler, msg.Payload) + result, err := invokeHandler(taskCtx, td.Handler, msg.Payload) duration := time.Since(start) if err != nil { @@ -210,25 +218,29 @@ func (q *InprocQueue) executeTask(ctx context.Context, msg TaskMessage) { } return } - q.succeedExecution(ctx, msg, duration) + q.succeedExecution(ctx, msg, duration, result) } -func invokeHandler(ctx context.Context, handler any, payload []byte) error { +func invokeHandler(ctx context.Context, handler any, payload []byte) (*contracts.TaskResultDTO, error) { if handler == nil { - return errors.New("nil task handler") + return nil, errors.New("nil task handler") } switch fn := handler.(type) { - case func(context.Context, []byte) error: + case contracts.TaskHandler: + return fn.Execute(ctx, payload) + case func(context.Context, []byte) (*contracts.TaskResultDTO, error): return fn(ctx, payload) + case func(context.Context, []byte) error: + return nil, fn(ctx, payload) case func(context.Context) error: - return fn(ctx) + return nil, fn(ctx) case func([]byte) error: - return fn(payload) + return nil, fn(payload) case func() error: - return fn() + return nil, fn() default: - return fmt.Errorf("unsupported handler type: %T", handler) + return nil, fmt.Errorf("unsupported handler type: %T", handler) } } @@ -280,16 +292,27 @@ func (q *InprocQueue) markRunning(ctx context.Context, msg TaskMessage) { q.appendExecutionLog(ctx, msg.ID, fmt.Sprintf("[系统] 开始执行异步任务 [类型: %s]", msg.TaskType)) } -func (q *InprocQueue) succeedExecution(ctx context.Context, msg TaskMessage, duration time.Duration) { +func (q *InprocQueue) succeedExecution(ctx context.Context, msg TaskMessage, duration time.Duration, result *contracts.TaskResultDTO) { db := getDB(ctx) if db == nil { return } now := time.Now() + resultText := "ok" + if result != nil { + resultText = result.Message + if result.Detail != nil { + if s, ok := result.Detail.(string); ok && s != "" { + resultText = result.Message + "\n" + s + } else if b, err := json.Marshal(result.Detail); err == nil && len(b) > 0 && string(b) != "null" { + resultText = result.Message + "\n" + string(b) + } + } + } updates := map[string]any{ taskExecutionColStatus: taskExecutionStatusSucceeded, "error_message": "", - "result": "ok", + "result": resultText, "finished_at": now, "duration": duration.Milliseconds(), } diff --git a/backend/plugins/drivers/driver_inproc_worker/plugin.go b/backend/plugins/drivers/driver_inproc_worker/plugin.go index 2525955a..119c3b1d 100644 --- a/backend/plugins/drivers/driver_inproc_worker/plugin.go +++ b/backend/plugins/drivers/driver_inproc_worker/plugin.go @@ -119,13 +119,7 @@ func (p *Plugin) ConfigEnabled(view core.ConfigView) bool { func (p *Plugin) Apply(ctx *core.Context) error { p.coreCtx = ctx - if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { - setDBService(db) - } else { - core.When[contracts.DBService](ctx, func(db contracts.DBService) { - setDBService(db) - }) - } + core.Bind[contracts.DBService](ctx, setDBService) taskSvc := newInprocTaskService(ctx.Tasks()) core.Provide[contracts.TaskService](ctx, taskSvc) @@ -151,6 +145,9 @@ func (p *Plugin) Start(ctx context.Context) error { if p.queue == nil { p.queue = NewInprocQueue(p.concurrency, p.queueCapacity, p.coreCtx.Tasks()) } + if p.coreCtx != nil { + p.queue.appCtx = p.coreCtx.Root() + } globalMu.Lock() globalQueue = p.queue diff --git a/backend/plugins/drivers/driver_inproc_worker/plugin_test.go b/backend/plugins/drivers/driver_inproc_worker/plugin_test.go index 2555fd76..85feaff7 100644 --- a/backend/plugins/drivers/driver_inproc_worker/plugin_test.go +++ b/backend/plugins/drivers/driver_inproc_worker/plugin_test.go @@ -80,9 +80,9 @@ func TestInprocWorkerDispatchByTypeTracksExecution(t *testing.T) { require.NoError(t, p.Apply(ctx)) var executedCount atomic.Int32 - ctx.Tasks().Register("system:cleanup", func(_ context.Context, _ []byte) error { + ctx.Tasks().Register("system:cleanup", func(_ context.Context, _ []byte) (*contracts.TaskResultDTO, error) { executedCount.Add(1) - return nil + return &contracts.TaskResultDTO{Message: "cleaned 3 files"}, nil }, extpoints.WithTaskType("system_cleanup"), extpoints.WithTaskName("系统垃圾清理"), @@ -111,6 +111,6 @@ func TestInprocWorkerDispatchByTypeTracksExecution(t *testing.T) { if listErr != nil || total == 0 || len(execs) == 0 { return false } - return execs[0].TaskID == taskID && execs[0].Status == "succeeded" && execs[0].TaskName == "系统垃圾清理" + return execs[0].TaskID == taskID && execs[0].Status == "succeeded" && execs[0].TaskName == "系统垃圾清理" && execs[0].Result == "cleaned 3 files" }, 2*time.Second, 20*time.Millisecond, "inproc worker should persist a succeeded execution record") } diff --git a/backend/plugins/drivers/drivers_test.go b/backend/plugins/drivers/drivers_test.go index 2a323e83..09149511 100644 --- a/backend/plugins/drivers/drivers_test.go +++ b/backend/plugins/drivers/drivers_test.go @@ -239,9 +239,9 @@ func TestAsynqWorkerDispatchTracksExecution(t *testing.T) { core.Provide[contracts.DBService](ctx, &testDBService{db: testDB}) var processed atomic.Bool - ctx.Tasks().Register("system:cleanup", func(_ context.Context, _ []byte) error { + ctx.Tasks().Register("system:cleanup", func(_ context.Context, _ []byte) (*contracts.TaskResultDTO, error) { processed.Store(true) - return nil + return &contracts.TaskResultDTO{Message: "cleaned 3 files"}, nil }, extpoints.WithTaskType("system_cleanup"), extpoints.WithTaskName("系统垃圾清理"), @@ -277,7 +277,7 @@ func TestAsynqWorkerDispatchTracksExecution(t *testing.T) { if listErr != nil || len(execs) == 0 { return false } - return execs[0].TaskID == taskID && execs[0].Status == "succeeded" + return execs[0].TaskID == taskID && execs[0].Status == "succeeded" && execs[0].Result == "cleaned 3 files" }, 5*time.Second, 50*time.Millisecond, "task execution should become succeeded after worker runs") } diff --git a/backend/plugins/infra/storage/plugin.go b/backend/plugins/infra/storage/plugin.go index 3cd105af..1fd7fff9 100644 --- a/backend/plugins/infra/storage/plugin.go +++ b/backend/plugins/infra/storage/plugin.go @@ -48,25 +48,11 @@ func (p *Plugin) Name() string { // Apply mounts the storage service into the Context. func (p *Plugin) Apply(ctx *core.Context) error { - // Bind DBService - if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil { + core.Bind[contracts.DBService](ctx, func(db contracts.DBService) { objectstore.SetDBService(db) diskcache.SetDBService(db) - } else { - core.When[contracts.DBService](ctx, func(db contracts.DBService) { - objectstore.SetDBService(db) - diskcache.SetDBService(db) - }) - } - - // Bind CacheService - if cache, err := core.Inject[contracts.CacheService](ctx); err == nil && cache != nil { - objectstore.SetCacheService(cache) - } else { - core.When[contracts.CacheService](ctx, func(cache contracts.CacheService) { - objectstore.SetCacheService(cache) - }) - } + }) + core.Bind[contracts.CacheService](ctx, objectstore.SetCacheService) ctx.OnDispose(func() error { objectstore.SetDBService(nil) From 455e2f8be59eee0272e04799c4f1e1450eb6c975 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 2 Sep 2026 17:07:07 +0800 Subject: [PATCH 09/44] fix(config): serve public settings and enforce login CAP Public config now comes from admin as a flat visibility=1 map instead of a cross-plugin query that compared an integer column to "visible". Login and register resolve CaptchaService per request so CAP is not skipped when user applies before cap. --- backend/core/contracts/config_public.go | 8 +- backend/docs/docs.go | 2 +- backend/docs/swagger.json | 2 +- backend/docs/swagger.yaml | 2 +- .../plugins/domain/admin/handler/config.go | 2 +- backend/plugins/domain/admin/plugin.go | 1 + backend/plugins/domain/admin/plugin_test.go | 5 ++ .../plugins/domain/admin/service/config.go | 8 ++ .../admin/service/system_config_test.go | 45 +++++++++++ backend/plugins/domain/auth/plugin.go | 1 + backend/plugins/domain/cap/repository.go | 8 +- backend/plugins/domain/domain_test.go | 63 +++++++++++++++ backend/plugins/domain/system/plugin.go | 53 +++++++------ .../domain/system/public_config_test.go | 24 ++++-- backend/plugins/domain/system/repository.go | 35 --------- backend/plugins/domain/user/plugin.go | 48 ++++++++---- .../domain/user/plugin_captcha_test.go | 76 +++++++++++++++++++ 17 files changed, 289 insertions(+), 94 deletions(-) delete mode 100644 backend/plugins/domain/system/repository.go diff --git a/backend/core/contracts/config_public.go b/backend/core/contracts/config_public.go index 31cd92b2..57fd4d37 100644 --- a/backend/core/contracts/config_public.go +++ b/backend/core/contracts/config_public.go @@ -5,8 +5,10 @@ package contracts import "context" -// PublicConfigProvider supplies the payload for GET /api/v1/config/public -// when a downstream plugin replaces Wavelet's default {configs, app} JSON. +// PublicConfigProvider supplies GET /api/v1/config/public. +// The owner of w_system_configs (admin) must provide this. The payload is a +// flat key/value map of visibility=1 rows; the frontend reads keys such as +// cap_login_enabled directly off data. type PublicConfigProvider interface { - PublicConfig(ctx context.Context) (any, error) + PublicConfig(ctx context.Context) (map[string]string, error) } diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 028c21b1..79ac24d2 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -4538,7 +4538,7 @@ const docTemplate = `{ }, "/api/v1/config/public": { "get": { - "description": "返回系统配置表中 visibility 为 1 的配置键值集合", + "description": "返回系统配置表中 visibility 为 1 的扁平键值集合(如 cap_login_enabled)", "consumes": [ "application/json" ], diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index a01fa772..85a0289a 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -4531,7 +4531,7 @@ }, "/api/v1/config/public": { "get": { - "description": "返回系统配置表中 visibility 为 1 的配置键值集合", + "description": "返回系统配置表中 visibility 为 1 的扁平键值集合(如 cap_login_enabled)", "consumes": [ "application/json" ], diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 52dbb9ce..d895e98e 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -4115,7 +4115,7 @@ paths: get: consumes: - application/json - description: 返回系统配置表中 visibility 为 1 的配置键值集合 + description: 返回系统配置表中 visibility 为 1 的扁平键值集合(如 cap_login_enabled) produces: - application/json responses: diff --git a/backend/plugins/domain/admin/handler/config.go b/backend/plugins/domain/admin/handler/config.go index a431c874..abbf60ae 100644 --- a/backend/plugins/domain/admin/handler/config.go +++ b/backend/plugins/domain/admin/handler/config.go @@ -16,7 +16,7 @@ import ( // GetPublicConfig 获取公共配置 // @Summary 获取公共配置 -// @Description 返回系统配置表中 visibility 为 1 的配置键值集合 +// @Description 返回系统配置表中 visibility 为 1 的扁平键值集合(如 cap_login_enabled) // @Tags config // @Accept json // @Produce json diff --git a/backend/plugins/domain/admin/plugin.go b/backend/plugins/domain/admin/plugin.go index 418f1d11..2512a9e5 100644 --- a/backend/plugins/domain/admin/plugin.go +++ b/backend/plugins/domain/admin/plugin.go @@ -92,6 +92,7 @@ func (p *Plugin) Apply(ctx *core.Context) error { core.Bind[contracts.StorageService](ctx, service.SetStorageService) core.Bind[contracts.RiskControlService](ctx, service.SetRiskControlService) service.SetEventEmitter(ctx.Events().Emit) + core.Provide[contracts.PublicConfigProvider](ctx, service.PublicConfigAdapter{}) ctx.OnDispose(func() error { service.ResetServices() diff --git a/backend/plugins/domain/admin/plugin_test.go b/backend/plugins/domain/admin/plugin_test.go index 451897be..4aace149 100644 --- a/backend/plugins/domain/admin/plugin_test.go +++ b/backend/plugins/domain/admin/plugin_test.go @@ -5,6 +5,7 @@ package admin_test import ( "Wavelet/core" + "Wavelet/core/contracts" "Wavelet/plugins/domain/admin" "context" "testing" @@ -40,6 +41,10 @@ func TestAdminPluginUnit(t *testing.T) { setting, ok := ctx.Settings().Get("admin.system_cleanup_cron") require.True(t, ok) assert.Equal(t, "0 4 * * *", setting.Default) + + provider, err := core.Inject[contracts.PublicConfigProvider](ctx) + require.NoError(t, err) + require.NotNil(t, provider) } func TestAdminMigrationsIncludeTaskExecutionsAndSchedules(t *testing.T) { diff --git a/backend/plugins/domain/admin/service/config.go b/backend/plugins/domain/admin/service/config.go index f453536b..a0ac0233 100644 --- a/backend/plugins/domain/admin/service/config.go +++ b/backend/plugins/domain/admin/service/config.go @@ -18,6 +18,14 @@ import ( const maskedConfigValue = "******" +// PublicConfigAdapter exposes visibility=1 system configs as PublicConfigProvider. +type PublicConfigAdapter struct{} + +// PublicConfig returns the unauthenticated public config map. +func (PublicConfigAdapter) PublicConfig(ctx context.Context) (map[string]string, error) { + return PublicSystemConfigs(ctx) +} + // PublicSystemConfigs returns the key/value map exposed to unauthenticated clients. func PublicSystemConfigs(ctx context.Context) (map[string]string, error) { configs, err := repository.ListVisibleSystemConfigs(ctx) diff --git a/backend/plugins/domain/admin/service/system_config_test.go b/backend/plugins/domain/admin/service/system_config_test.go index bce4321c..07d93edb 100644 --- a/backend/plugins/domain/admin/service/system_config_test.go +++ b/backend/plugins/domain/admin/service/system_config_test.go @@ -69,6 +69,51 @@ func setupSystemConfigTest(t *testing.T) (*gorm.DB, func()) { return sqliteDB, cleanup } +func TestPublicSystemConfigsExposesVisibleKeys(t *testing.T) { + dbConn, cleanup := setupSystemConfigTest(t) + defer cleanup() + repository.ResetSystemConfigRAMCacheForTest() + ctx := context.Background() + + hidden := model.SystemConfig{ + Key: "secret_key", + Value: "nope", + Type: "system", + Visibility: model.ConfigVisibilityHidden, + } + visible := model.SystemConfig{ + Key: model.ConfigKeyCapLoginEnabled, + Value: "true", + Type: "system", + Visibility: model.ConfigVisibilityVisible, + } + if err := dbConn.Create(&hidden).Error; err != nil { + t.Fatalf("Create(hidden) error = %v", err) + } + if err := dbConn.Create(&visible).Error; err != nil { + t.Fatalf("Create(visible) error = %v", err) + } + + got, err := service.PublicSystemConfigs(ctx) + if err != nil { + t.Fatalf("PublicSystemConfigs() error = %v", err) + } + if got[model.ConfigKeyCapLoginEnabled] != "true" { + t.Fatalf("PublicSystemConfigs()[%s] = %q, want %q", model.ConfigKeyCapLoginEnabled, got[model.ConfigKeyCapLoginEnabled], "true") + } + if _, ok := got["secret_key"]; ok { + t.Fatalf("PublicSystemConfigs() leaked hidden key secret_key") + } + + viaProvider, err := service.PublicConfigAdapter{}.PublicConfig(ctx) + if err != nil { + t.Fatalf("PublicConfigAdapter.PublicConfig() error = %v", err) + } + if viaProvider[model.ConfigKeyCapLoginEnabled] != "true" { + t.Fatalf("PublicConfigAdapter.PublicConfig()[%s] = %q, want %q", model.ConfigKeyCapLoginEnabled, viaProvider[model.ConfigKeyCapLoginEnabled], "true") + } +} + func TestListSystemConfigsByKeys_EmptyKeys(t *testing.T) { result, err := repository.ListSystemConfigsByKeys(context.Background(), nil) if err != nil { diff --git a/backend/plugins/domain/auth/plugin.go b/backend/plugins/domain/auth/plugin.go index eff4d138..fac1c7a1 100644 --- a/backend/plugins/domain/auth/plugin.go +++ b/backend/plugins/domain/auth/plugin.go @@ -119,6 +119,7 @@ func (p *Plugin) Apply(ctx *core.Context) error { "/api/v1/user/login", "/api/v1/user/register", "/api/v1/user/send-email-code", + "/api/v1/config/public", "/api/v1/cap/challenge", "/api/v1/cap/redeem", "/api/healthz", diff --git a/backend/plugins/domain/cap/repository.go b/backend/plugins/domain/cap/repository.go index 4caa0040..ad9c26f2 100644 --- a/backend/plugins/domain/cap/repository.go +++ b/backend/plugins/domain/cap/repository.go @@ -24,12 +24,10 @@ func setDBService(s contracts.DBService) { dbSvc = s } -// getDB resolves a GORM handle, preferring the *core.Context when supplied by callers. +// getDB resolves a GORM handle from the request/app context, then the Bind fallback. func getDB(ctx context.Context) *gorm.DB { - if c, ok := ctx.(*core.Context); ok && c != nil { - if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { - return s.DB(ctx) - } + if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil { + return s.DB(ctx) } dbMu.RLock() s := dbSvc diff --git a/backend/plugins/domain/domain_test.go b/backend/plugins/domain/domain_test.go index 32a031a1..a5b3ab13 100644 --- a/backend/plugins/domain/domain_test.go +++ b/backend/plugins/domain/domain_test.go @@ -11,16 +11,21 @@ import ( "Wavelet/plugins/domain/auth" "Wavelet/plugins/domain/message_gateway" "Wavelet/plugins/domain/risk_control" + "Wavelet/plugins/domain/system" "Wavelet/plugins/domain/user" "Wavelet/plugins/infra/cache" "Wavelet/plugins/infra/logger" "Wavelet/plugins/infra/storage" "context" + "encoding/json" "io/fs" + "net/http" + "net/http/httptest" "path/filepath" "testing" "github.com/alicebob/miniredis/v2" + "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" @@ -388,6 +393,64 @@ func TestAdminPlugin(t *testing.T) { schema, ok := ctx.Settings().Get("admin.system_cleanup_cron") require.True(t, ok) assert.Equal(t, "0 4 * * *", schema.Default) + + provider, err := core.Inject[contracts.PublicConfigProvider](ctx) + require.NoError(t, err) + require.NotNil(t, provider) +} + +func TestPublicConfigExposesVisibleAdminRows(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) + testDB := setupTestDB(t) + + require.NoError(t, db.New(db.WithDB(testDB)).Apply(ctx)) + require.NoError(t, cache.New().Apply(ctx)) + require.NoError(t, logger.New().Apply(ctx)) + + require.NoError(t, testDB.Create(&admin.SystemConfig{ + Key: "cap_login_enabled", + Value: "true", + Type: "system", + Visibility: 1, + }).Error) + + require.NoError(t, admin.New().Apply(ctx)) + require.NoError(t, system.New().Apply(ctx)) + + var handler gin.HandlerFunc + for _, rd := range ctx.Router().Routes() { + if rd.Method != "GET" || rd.Path != "/api/v1/config/public" { + continue + } + require.NotEmpty(t, rd.Handlers) + switch h := rd.Handlers[0].(type) { + case gin.HandlerFunc: + handler = h + case func(*gin.Context): + handler = h + default: + t.Fatalf("unexpected handler type %T", rd.Handlers[0]) + } + break + } + require.NotNil(t, handler) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/config/public", nil) + c.Request = c.Request.WithContext(core.WithAppContext(c.Request.Context(), ctx)) + handler(c) + + var body struct { + Data map[string]string `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "body = %s", w.Body.String()) + assert.Equal(t, "true", body.Data["cap_login_enabled"]) + _, wrapped := body.Data["configs"] + assert.False(t, wrapped, "payload must be a flat map, got %v", body.Data) } func TestAllDomainPluginsCombined(t *testing.T) { diff --git a/backend/plugins/domain/system/plugin.go b/backend/plugins/domain/system/plugin.go index b03f98e8..9d9be0d5 100644 --- a/backend/plugins/domain/system/plugin.go +++ b/backend/plugins/domain/system/plugin.go @@ -9,8 +9,8 @@ import ( "Wavelet/core/contracts" "Wavelet/pkg/logger" "Wavelet/pkg/response" + "context" "net/http" - "reflect" "github.com/gin-gonic/gin" ) @@ -28,13 +28,6 @@ func (p *Plugin) Name() string { return "system" } -// Inject declares required dependencies for the system domain plugin. -func (p *Plugin) Inject() []reflect.Type { - return []reflect.Type{ - reflect.TypeFor[contracts.DBService](), - } -} - // Manifest returns the plugin metadata. func (p *Plugin) Manifest() core.Manifest { return core.Manifest{ @@ -47,37 +40,31 @@ func (p *Plugin) Manifest() core.Manifest { // Apply registers system routes. func (p *Plugin) Apply(ctx *core.Context) error { - appName := ctx.Config().String("app.app_name", "Wavelet") - // 1. Health check ctx.Router().GET("/api/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) ctx.Router().RegisterWhitelist("/api/healthz") - // 2. Public config + // 2. Public config — owned data comes from PublicConfigProvider (admin). ctx.Router().GET("/api/v1/config/public", func(c *gin.Context) { - if p, err := core.Inject[contracts.PublicConfigProvider](ctx); err == nil && p != nil { - data, err := p.PublicConfig(c.Request.Context()) - if err != nil { - logger.ErrorF(c.Request.Context(), "[System] public config provider failed: %v", err) - response.AbortInternal(c, "public config unavailable") - return - } - c.JSON(http.StatusOK, response.OK(data)) + provider := resolvePublicConfigProvider(c.Request.Context(), ctx) + if provider == nil { + c.JSON(http.StatusOK, response.OK(map[string]string{})) return } - configs, err := listPublicSystemConfigs(c.Request.Context(), ctx) + data, err := provider.PublicConfig(c.Request.Context()) if err != nil { - logger.ErrorF(c.Request.Context(), "[System] query public system configs failed: %v", err) + logger.ErrorF(c.Request.Context(), "[System] public config provider failed: %v", err) + response.AbortInternal(c, "public config unavailable") + return } - c.JSON(http.StatusOK, response.OK(gin.H{ - "configs": configs, - "app": gin.H{ - "name": appName, - }, - })) + if data == nil { + data = map[string]string{} + } + c.JSON(http.StatusOK, response.OK(data)) }) + ctx.Router().RegisterWhitelist("/api/v1/config/public") // 3. Custom injection ctx.Router().GET("/custom", func(c *gin.Context) { @@ -86,3 +73,15 @@ func (p *Plugin) Apply(ctx *core.Context) error { return nil } + +func resolvePublicConfigProvider(reqCtx context.Context, appCtx *core.Context) contracts.PublicConfigProvider { + if p, err := core.InjectFrom[contracts.PublicConfigProvider](reqCtx); err == nil && p != nil { + return p + } + if appCtx != nil { + if p, err := core.Inject[contracts.PublicConfigProvider](appCtx); err == nil && p != nil { + return p + } + } + return nil +} diff --git a/backend/plugins/domain/system/public_config_test.go b/backend/plugins/domain/system/public_config_test.go index 47d8840f..d2065556 100644 --- a/backend/plugins/domain/system/public_config_test.go +++ b/backend/plugins/domain/system/public_config_test.go @@ -18,13 +18,17 @@ import ( "github.com/gin-gonic/gin" ) -type stubPublic struct{ payload any } +type stubPublic struct{ payload map[string]string } -func (s stubPublic) PublicConfig(context.Context) (any, error) { return s.payload, nil } +func (s stubPublic) PublicConfig(context.Context) (map[string]string, error) { + return s.payload, nil +} type errPublic struct{ err error } -func (s errPublic) PublicConfig(context.Context) (any, error) { return nil, s.err } +func (s errPublic) PublicConfig(context.Context) (map[string]string, error) { + return nil, s.err +} func TestPublicConfigUsesProviderWhenPresent(t *testing.T) { gin.SetMode(gin.TestMode) @@ -54,16 +58,22 @@ func TestPublicConfigDefaultWithoutProvider(t *testing.T) { if err := New().Apply(ctx); err != nil { t.Fatal(err) } + if !ctx.Router().IsWhitelisted("/api/v1/config/public") { + t.Fatal("GET /api/v1/config/public not whitelisted") + } raw := invokePublicConfig(t, publicConfigHandler(t, ctx)) var data map[string]any if err := json.Unmarshal(raw, &data); err != nil { t.Fatal(err) } - if _, ok := data["configs"]; !ok { - t.Fatalf("data = %s, want key configs", raw) + if len(data) != 0 { + t.Fatalf("data = %s, want empty flat map", raw) } - if _, ok := data["app"]; !ok { - t.Fatalf("data = %s, want key app", raw) + if _, ok := data["configs"]; ok { + t.Fatalf("data = %s, default payload must not wrap configs", raw) + } + if _, ok := data["app"]; ok { + t.Fatalf("data = %s, default payload must not wrap app", raw) } } diff --git a/backend/plugins/domain/system/repository.go b/backend/plugins/domain/system/repository.go deleted file mode 100644 index 612feb4c..00000000 --- a/backend/plugins/domain/system/repository.go +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package system - -import ( - "Wavelet/core" - "Wavelet/core/contracts" - "context" -) - -// publicSystemConfig 前端公共配置接口的只读投影。 -type publicSystemConfig struct { - Key string `json:"key"` - Value string `json:"value"` -} - -// listPublicSystemConfigs 读取对前端可见的系统配置项。 -// -// 注意:w_system_configs 的所有者插件是 admin,contracts 目前尚未暴露读取契约, -// 因此此处仍只能直连只读查询;待 admin 提供 SettingsService 契约后应改为调用契约。 -func listPublicSystemConfigs(ctx context.Context, appCtx *core.Context) ([]publicSystemConfig, error) { - dbSvc, err := core.Inject[contracts.DBService](appCtx) - if err != nil { - return nil, err - } - if dbSvc == nil { - return nil, nil - } - var configs []publicSystemConfig - err = dbSvc.DB(ctx).Table("w_system_configs"). - Where("visibility = ?", "visible"). - Find(&configs).Error - return configs, err -} diff --git a/backend/plugins/domain/user/plugin.go b/backend/plugins/domain/user/plugin.go index 42e93383..744e7b90 100644 --- a/backend/plugins/domain/user/plugin.go +++ b/backend/plugins/domain/user/plugin.go @@ -9,6 +9,7 @@ import ( "Wavelet/core/contracts" "Wavelet/core/extpoints" "Wavelet/pkg/ginutil" + "context" "embed" "reflect" @@ -113,19 +114,12 @@ func (p *Plugin) Apply(ctx *core.Context) error { } core.Provide[contracts.UserService](ctx, p.userSvc) - passThrough := gin.HandlerFunc(func(c *gin.Context) { c.Next() }) - loginCap, registerCap, emailCap := passThrough, passThrough, passThrough - if capSvc, err := core.Inject[contracts.CaptchaService](ctx); err == nil && capSvc != nil { - if mw, ok := capSvc.VerifyMiddleware("login").(gin.HandlerFunc); ok { - loginCap = mw - } - if mw, ok := capSvc.VerifyMiddleware("register").(gin.HandlerFunc); ok { - registerCap = mw - } - if mw, ok := capSvc.VerifyMiddleware("send_email_code").(gin.HandlerFunc); ok { - emailCap = mw - } - } + // CAP middleware is resolved per request. user Apply runs before cap in + // the default plugin list; snapshotting CaptchaService here would leave + // login/register as a permanent pass-through. + loginCap := captchaGuard(ctx, "login") + registerCap := captchaGuard(ctx, "register") + emailCap := captchaGuard(ctx, "send_email_code") // 3. Register HTTP Routes userGroup := ctx.Router().Group("/api/v1/user") @@ -182,3 +176,31 @@ func (p *Plugin) Apply(ctx *core.Context) error { return nil } + +func captchaGuard(appCtx *core.Context, scope string) gin.HandlerFunc { + return func(c *gin.Context) { + svc := resolveCaptchaService(c.Request.Context(), appCtx) + if svc == nil { + c.Next() + return + } + mw, ok := svc.VerifyMiddleware(scope).(gin.HandlerFunc) + if !ok || mw == nil { + c.Next() + return + } + mw(c) + } +} + +func resolveCaptchaService(reqCtx context.Context, appCtx *core.Context) contracts.CaptchaService { + if s, err := core.InjectFrom[contracts.CaptchaService](reqCtx); err == nil && s != nil { + return s + } + if appCtx != nil { + if s, err := core.Inject[contracts.CaptchaService](appCtx); err == nil && s != nil { + return s + } + } + return nil +} diff --git a/backend/plugins/domain/user/plugin_captcha_test.go b/backend/plugins/domain/user/plugin_captcha_test.go index 62d542a8..6fc31d60 100644 --- a/backend/plugins/domain/user/plugin_captcha_test.go +++ b/backend/plugins/domain/user/plugin_captcha_test.go @@ -5,6 +5,8 @@ package user_test import ( "context" + "net/http" + "net/http/httptest" "reflect" "testing" @@ -72,3 +74,77 @@ func TestApplyWithCaptchaServiceWrapsLogin(t *testing.T) { } t.Fatal("missing POST /api/v1/user/login") } + +type denyCaptchaService struct{} + +func (denyCaptchaService) VerifyMiddleware(string) any { + return gin.HandlerFunc(func(c *gin.Context) { + c.AbortWithStatus(http.StatusUnauthorized) + }) +} + +func (denyCaptchaService) ChallengeHandler() any { return gin.HandlerFunc(func(c *gin.Context) {}) } + +func (denyCaptchaService) RedeemHandler() any { return gin.HandlerFunc(func(c *gin.Context) {}) } + +func TestLoginCaptchaGuardResolvesServiceAfterApply(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := core.NewContext(context.Background()) + if err := user.New().Apply(ctx); err != nil { + t.Fatal(err) + } + core.Provide[contracts.CaptchaService](ctx, denyCaptchaService{}) + + handler := loginCaptchaGuard(t, ctx) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/user/login", nil) + handler(c) + + if !c.IsAborted() { + t.Fatal("login captcha guard did not abort after late CaptchaService provide") + } + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized) + } +} + +func TestLoginCaptchaGuardPassesWithoutCaptchaService(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := core.NewContext(context.Background()) + if err := user.New().Apply(ctx); err != nil { + t.Fatal(err) + } + + handler := loginCaptchaGuard(t, ctx) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/user/login", nil) + handler(c) + + if c.IsAborted() { + t.Fatal("login captcha guard aborted without CaptchaService") + } +} + +func loginCaptchaGuard(t *testing.T, ctx *core.Context) gin.HandlerFunc { + t.Helper() + for _, rd := range ctx.Router().Routes() { + if rd.Method != "POST" || rd.Path != "/api/v1/user/login" { + continue + } + if len(rd.Handlers) == 0 { + t.Fatal("POST /api/v1/user/login has no handlers") + } + switch h := rd.Handlers[0].(type) { + case gin.HandlerFunc: + return h + case func(*gin.Context): + return h + default: + t.Fatalf("unexpected handler type %T", rd.Handlers[0]) + } + } + t.Fatal("missing POST /api/v1/user/login") + return nil +} From ef88811ccb8d8ba20df29ac6bd8f73eadf2990bf Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 2 Sep 2026 17:31:06 +0800 Subject: [PATCH 10/44] fix(frontend): call versioned CAP challenge and redeem APIs Point the PoW solver at /api/v1/cap/{challenge,redeem} so login verification hits the routes registered by the cap plugin. --- frontend/lib/cap-solver.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/lib/cap-solver.ts b/frontend/lib/cap-solver.ts index 7ca45473..3abcc371 100644 --- a/frontend/lib/cap-solver.ts +++ b/frontend/lib/cap-solver.ts @@ -135,7 +135,7 @@ function solveInWorker( export async function getCapToken(scope = 'login'): Promise { // 1. 获取难题 - const challengeRes = await fetch('/api/cap/challenge', { + const challengeRes = await fetch('/api/v1/cap/challenge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope }), @@ -171,7 +171,7 @@ export async function getCapToken(scope = 'login'): Promise { console.groupEnd(); // 3. 提交答案兑换一次性凭证 - const redeemRes = await fetch('/api/cap/redeem', { + const redeemRes = await fetch('/api/v1/cap/redeem', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: challenge.token, solutions, scope }), From bec1352ef7d2bd10a9baa0931a4568cced182b81 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 2 Sep 2026 17:39:40 +0800 Subject: [PATCH 11/44] fix(logs): collect access logs regardless of plugin order Global Router.Use middleware is applied at HTTP Start instead of being snapshotted when each route is registered, so risk_control still wraps admin APIs that mount earlier. Access-log collection is enabled by default on SQLite/Postgres, not only ClickHouse. --- backend/core/extpoints/extpoints_test.go | 19 ++++++++- backend/core/extpoints/router.go | 15 ++++--- .../domain/risk_control/logstore/db_helper.go | 6 +-- backend/plugins/domain/risk_control/plugin.go | 4 +- .../domain/risk_control/plugin_test.go | 2 + backend/plugins/drivers/driver_http/plugin.go | 41 +++++++++++++------ backend/plugins/drivers/drivers_test.go | 35 ++++++++++++++++ 7 files changed, 94 insertions(+), 28 deletions(-) diff --git a/backend/core/extpoints/extpoints_test.go b/backend/core/extpoints/extpoints_test.go index fe684d75..d5612d7c 100644 --- a/backend/core/extpoints/extpoints_test.go +++ b/backend/core/extpoints/extpoints_test.go @@ -80,12 +80,12 @@ func TestRouterExtension(t *testing.T) { for _, route := range routes { if route.Method == "GET" && route.Path == "/api/v1/orders" { foundOrderGet = true - assert.Equal(t, []any{mGlobal, mAPI, "api_extra_middleware"}, route.Middlewares) + assert.Equal(t, []any{mAPI, "api_extra_middleware"}, route.Middlewares) assert.Equal(t, []any{hList}, route.Handlers) } if route.Method == "PUT" && route.Path == "/api/v1/admin/users/:id" { foundUserPut = true - assert.Equal(t, []any{mGlobal, mAPI, "api_extra_middleware", mAdmin}, route.Middlewares) + assert.Equal(t, []any{mAPI, "api_extra_middleware", mAdmin}, route.Middlewares) assert.Equal(t, []any{hUserPut}, route.Handlers) } } @@ -93,6 +93,21 @@ func TestRouterExtension(t *testing.T) { assert.True(t, foundUserPut) } +func TestRouterGlobalMiddlewareIsNotSnapshottedOntoRoutes(t *testing.T) { + r := extpoints.NewRouterRegistry() + r.GET("/before", "handler") + r.Use("late_global") + r.GET("/after", "handler") + + for _, route := range r.Routes() { + if len(route.Middlewares) != 0 { + t.Errorf("route %s %s Middlewares = %v, want none (globals live on Router.Middlewares)", + route.Method, route.Path, route.Middlewares) + } + } + assert.Equal(t, []any{"late_global"}, r.Middlewares()) +} + func TestRouterWhitelist(t *testing.T) { r := extpoints.NewRouterRegistry() require.NotNil(t, r) diff --git a/backend/core/extpoints/router.go b/backend/core/extpoints/router.go index 44258028..61e47262 100644 --- a/backend/core/extpoints/router.go +++ b/backend/core/extpoints/router.go @@ -95,11 +95,12 @@ func (r *RouterRegistry) addRoute(method, fullPath string, handlers ...any) Rout r.nextID++ rd := RouteDefinition{ - ID: r.nextID, - Method: strings.ToUpper(method), - Path: fullPath, - Handlers: handlers, - Middlewares: append([]any(nil), r.middlewares...), + ID: r.nextID, + Method: strings.ToUpper(method), + Path: fullPath, + Handlers: handlers, + // Global Router.Use middlewares are applied at HTTP Start from + // Router.Middlewares(), so late-registered plugins still wrap earlier routes. } r.routes = append(r.routes, rd) return rd @@ -241,9 +242,7 @@ func (g *RouterGroup) addRoute(method, fullPath string, handlers ...any) RouteDe g.registry.mu.Lock() defer g.registry.mu.Unlock() - allMiddlewares := make([]any, 0, len(g.registry.middlewares)+len(g.middlewares)) - allMiddlewares = append(allMiddlewares, g.registry.middlewares...) - allMiddlewares = append(allMiddlewares, g.middlewares...) + allMiddlewares := append([]any(nil), g.middlewares...) g.registry.nextID++ rd := RouteDefinition{ diff --git a/backend/plugins/domain/risk_control/logstore/db_helper.go b/backend/plugins/domain/risk_control/logstore/db_helper.go index af540ef9..ed33c591 100644 --- a/backend/plugins/domain/risk_control/logstore/db_helper.go +++ b/backend/plugins/domain/risk_control/logstore/db_helper.go @@ -42,10 +42,8 @@ func SetChDBForTest(db *gorm.DB) { } func getDB(ctx context.Context) *gorm.DB { - if c, ok := ctx.(*core.Context); ok && c != nil { - if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil { - return s.DB(ctx) - } + if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil { + return s.DB(ctx) } dbMu.RLock() s := dbSvc diff --git a/backend/plugins/domain/risk_control/plugin.go b/backend/plugins/domain/risk_control/plugin.go index c879c381..dbe9e0ec 100644 --- a/backend/plugins/domain/risk_control/plugin.go +++ b/backend/plugins/domain/risk_control/plugin.go @@ -91,7 +91,9 @@ func (p *Plugin) Apply(ctx *core.Context) error { var dbCfg rcDBConfig _ = ctx.Config().Bind("database", &dbCfg) - SetAccessLogEnabled(chCfg.Enabled) + // Access logs persist on the active log database (SQLite / Postgres / ClickHouse). + // Collection is independent of ClickHouse being enabled. + SetAccessLogEnabled(true) logstore.SetDefaultDatabases(dbCfg.Enabled, chCfg.Enabled) core.Bind[contracts.DBService](ctx, logstore.SetDBService) diff --git a/backend/plugins/domain/risk_control/plugin_test.go b/backend/plugins/domain/risk_control/plugin_test.go index fbc37a6d..aa986b66 100644 --- a/backend/plugins/domain/risk_control/plugin_test.go +++ b/backend/plugins/domain/risk_control/plugin_test.go @@ -35,6 +35,8 @@ func TestRiskControlPluginUnit(t *testing.T) { setting, ok := ctx.Settings().Get("risk_control.enable_access_log") require.True(t, ok) assert.Equal(t, true, setting.Default) + assert.True(t, risk_control.IsAccessLogEnabled(), + "access log collection must be on even when ClickHouse is disabled") require.NoError(t, ctx.Dispose()) assert.False(t, customMWCalled) // not dispatched via gin engine here diff --git a/backend/plugins/drivers/driver_http/plugin.go b/backend/plugins/drivers/driver_http/plugin.go index fd3fb5dc..b323786c 100644 --- a/backend/plugins/drivers/driver_http/plugin.go +++ b/backend/plugins/drivers/driver_http/plugin.go @@ -252,27 +252,42 @@ func (p *Plugin) mountContextRoutes(ctx context.Context) error { } p.engine.Use(appContextMiddleware(ctx, p.coreCtx.Root())) SetWhitelist(p.coreCtx.Router().Whitelist()) + + globalMW, err := toGinHandlers(p.coreCtx.Router().Middlewares()) + if err != nil { + return fmt.Errorf("driver_http: invalid global middleware: %w", err) + } + for _, rd := range p.coreCtx.Router().Routes() { - allHandlers := make([]gin.HandlerFunc, 0, len(rd.Middlewares)+len(rd.Handlers)) - for _, m := range rd.Middlewares { - gh, err := toGinHandler(m) - if err != nil { - return fmt.Errorf("driver_http: invalid middleware for route %s %s: %w", rd.Method, rd.Path, err) - } - allHandlers = append(allHandlers, gh) + routeMW, convErr := toGinHandlers(rd.Middlewares) + if convErr != nil { + return fmt.Errorf("driver_http: invalid middleware for route %s %s: %w", rd.Method, rd.Path, convErr) } - for _, h := range rd.Handlers { - gh, err := toGinHandler(h) - if err != nil { - return fmt.Errorf("driver_http: invalid handler for route %s %s: %w", rd.Method, rd.Path, err) - } - allHandlers = append(allHandlers, gh) + handlers, convErr := toGinHandlers(rd.Handlers) + if convErr != nil { + return fmt.Errorf("driver_http: invalid handler for route %s %s: %w", rd.Method, rd.Path, convErr) } + allHandlers := make([]gin.HandlerFunc, 0, len(globalMW)+len(routeMW)+len(handlers)) + allHandlers = append(allHandlers, globalMW...) + allHandlers = append(allHandlers, routeMW...) + allHandlers = append(allHandlers, handlers...) p.engine.Handle(rd.Method, rd.Path, allHandlers...) } return nil } +func toGinHandlers(hs []any) ([]gin.HandlerFunc, error) { + out := make([]gin.HandlerFunc, 0, len(hs)) + for _, h := range hs { + gh, err := toGinHandler(h) + if err != nil { + return nil, err + } + out = append(out, gh) + } + return out, nil +} + //nolint:contextcheck // middleware must wrap the gin request context, not Start's ctx func appContextMiddleware(_ context.Context, appCtx *core.Context) gin.HandlerFunc { return func(c *gin.Context) { diff --git a/backend/plugins/drivers/drivers_test.go b/backend/plugins/drivers/drivers_test.go index 09149511..5b9c008a 100644 --- a/backend/plugins/drivers/drivers_test.go +++ b/backend/plugins/drivers/drivers_test.go @@ -152,6 +152,41 @@ func TestHTTPDriverLifecycle(t *testing.T) { require.NoError(t, err) } +func TestHTTPDriverAppliesGlobalMiddlewareRegisteredAfterRoutes(t *testing.T) { + ctx := core.NewContext(context.Background()) + ctx.Config().SetSource(core.NewMapSource(nil)) + require.NoError(t, ctx.Config().Resolve()) + + ctx.Router().GET("/early", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + var called atomic.Bool + ctx.Router().Use(func(c *gin.Context) { + called.Store(true) + c.Next() + }) + + httpPlugin := driver_http.New(driver_http.WithAddr("127.0.0.1:0")) + require.NoError(t, httpPlugin.Apply(ctx)) + d, ok := ctx.Driver(core.DriverTypeHTTP) + require.True(t, ok) + require.NoError(t, d.Start(context.Background())) + t.Cleanup(func() { + stopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = d.Stop(stopCtx) + }) + + resp, err := http.Get(fmt.Sprintf("http://%s/early", httpPlugin.Addr())) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + if !called.Load() { + t.Fatal("Router.Use middleware registered after the route must still run at HTTP Start") + } +} + func TestAsynqWorkerDriverLifecycle(t *testing.T) { mr, err := miniredis.Run() require.NoError(t, err) From 8aa0753b12bb954aeb8d4e75cf08bd4454b666f9 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 2 Sep 2026 17:40:43 +0800 Subject: [PATCH 12/44] fix(logs): flush small access-log batches within two seconds Default MinBatchSize of 50 left quiet admin traffic in memory forever because MaxFlushWait was unset. Force a timed flush so the logs page can show recent authenticated requests. --- backend/plugins/domain/risk_control/service.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/plugins/domain/risk_control/service.go b/backend/plugins/domain/risk_control/service.go index d25c84fd..c502027d 100644 --- a/backend/plugins/domain/risk_control/service.go +++ b/backend/plugins/domain/risk_control/service.go @@ -13,8 +13,12 @@ import ( "time" ) -// fallbackLogEngine 是日志库状态不可得时对外暴露的引擎标识。 -const fallbackLogEngine = "sqlite" +const ( + // fallbackLogEngine 是日志库状态不可得时对外暴露的引擎标识。 + fallbackLogEngine = "sqlite" + // accessLogMaxFlushWait 强制把不足 MinBatchSize 的访问日志刷盘,避免管理台低频访问永远看不到记录。 + accessLogMaxFlushWait = 2 * time.Second +) var ( logWriterMu sync.RWMutex @@ -30,6 +34,7 @@ func InitLogWriter(ctx context.Context) { } cfg := batchwriter.DefaultConfig() + cfg.MaxFlushWait = accessLogMaxFlushWait writer, err := batchwriter.New[*logstore.UserAccessLog](cfg, writeAccessLogBatch, batchwriter.WithDropHandler[*logstore.UserAccessLog](func(item *logstore.UserAccessLog) { path := "" From 1f1f4efec7abe3cf987f0a47280ae0374df587b4 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 2 Sep 2026 17:52:57 +0800 Subject: [PATCH 13/44] fix(admin): stop console Intl errors and log websocket drops Use raw i18n for push template hints so ICU does not parse {{placeholders}}. Pass total into the user list record count. Allow log websocket origins behind the Next rewrite, skip the proxy on Upgrade, and do not open a socket after unmount. --- backend/plugins/domain/admin/handler/logs.go | 23 +++++++- backend/plugins/domain/admin/service/log.go | 48 ++++++++++++++-- .../domain/admin/service/log_origin_test.go | 55 +++++++++++++++++++ .../(main)/admin/logs/components/app-logs.tsx | 10 +++- .../admin/push/components/events-tab.tsx | 8 +-- .../users/components/user-filter-bar.tsx | 2 +- frontend/proxy.ts | 9 ++- 7 files changed, 140 insertions(+), 15 deletions(-) create mode 100644 backend/plugins/domain/admin/service/log_origin_test.go diff --git a/backend/plugins/domain/admin/handler/logs.go b/backend/plugins/domain/admin/handler/logs.go index aac63a14..3bb2083c 100644 --- a/backend/plugins/domain/admin/handler/logs.go +++ b/backend/plugins/domain/admin/handler/logs.go @@ -14,6 +14,7 @@ import ( "errors" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" @@ -179,11 +180,31 @@ func GetLogsAnalytics(c *gin.Context) { func getUpgrader() *websocket.Upgrader { return &websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { - return service.IsAllowedLogOrigin(r.Context(), r.Header.Get("Origin"), r.Host) + return service.IsAllowedLogOrigin( + r.Context(), + r.Header.Get("Origin"), + r.Host, + forwardedHosts(r)..., + ) }, } } +func forwardedHosts(r *http.Request) []string { + raw := r.Header.Get("X-Forwarded-Host") + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + hosts := make([]string, 0, len(parts)) + for _, part := range parts { + if h := strings.TrimSpace(part); h != "" { + hosts = append(hosts, h) + } + } + return hosts +} + // errNegativeParam 表示查询参数解析出了负数。 var errNegativeParam = errors.New("parameter must not be negative") diff --git a/backend/plugins/domain/admin/service/log.go b/backend/plugins/domain/admin/service/log.go index e0cca4a6..f138613f 100644 --- a/backend/plugins/domain/admin/service/log.go +++ b/backend/plugins/domain/admin/service/log.go @@ -11,6 +11,7 @@ import ( "Wavelet/plugins/domain/admin/repository" "context" "fmt" + "net" "net/url" "strings" "time" @@ -47,18 +48,20 @@ func RobotsTxtBody(ctx context.Context) string { } // IsAllowedLogOrigin reports whether a WebSocket handshake origin may subscribe to logs. -func IsAllowedLogOrigin(ctx context.Context, origin, host string) bool { +// extraHosts are reverse-proxy hosts such as X-Forwarded-Host (the browser origin +// when Next.js rewrites /api to the backend). +func IsAllowedLogOrigin(ctx context.Context, origin, host string, extraHosts ...string) bool { if origin == "" { return true } - // 1. 同源检查 (Same-origin check) u, err := url.Parse(origin) - if err == nil && strings.EqualFold(u.Host, host) { - return true + if err == nil { + if originMatchesHost(u.Host, host, extraHosts...) { + return true + } } - // 2. 检查配置的允许跨域 Origin (Check allowed origins in system config) sc, cfgErr := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress) if cfgErr != nil || sc.Value == "" { return false @@ -73,6 +76,41 @@ func IsAllowedLogOrigin(ctx context.Context, origin, host string) bool { return false } +func originMatchesHost(originHost, host string, extraHosts ...string) bool { + if originHost == "" { + return false + } + if hostMatches(originHost, host) { + return true + } + for _, extra := range extraHosts { + if hostMatches(originHost, extra) { + return true + } + } + return false +} + +func hostMatches(originHost, candidate string) bool { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + return false + } + if strings.EqualFold(originHost, candidate) { + return true + } + // Reverse-proxy / local Next rewrite: Origin is :3000, backend Host is :8000. + return strings.EqualFold(hostName(originHost), hostName(candidate)) +} + +func hostName(hostport string) string { + h, _, err := net.SplitHostPort(hostport) + if err != nil { + return hostport + } + return h +} + // AccessLogs queries the analytical access log store and decorates rows with user names. func AccessLogs(ctx context.Context, q model.AccessLogQuery) (model.AccessLogsResponse, error) { rc := GetRiskControlService(ctx) diff --git a/backend/plugins/domain/admin/service/log_origin_test.go b/backend/plugins/domain/admin/service/log_origin_test.go new file mode 100644 index 00000000..092310c2 --- /dev/null +++ b/backend/plugins/domain/admin/service/log_origin_test.go @@ -0,0 +1,55 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package service_test + +import ( + "Wavelet/plugins/domain/admin/service" + "context" + "testing" +) + +func TestIsAllowedLogOrigin(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + origin string + host string + extraHosts []string + want bool + }{ + {name: "empty origin", origin: "", host: "localhost:8000", want: true}, + {name: "same host", origin: "http://localhost:8000", host: "localhost:8000", want: true}, + {name: "same host different case", origin: "http://LocalHost:8000", host: "localhost:8000", want: true}, + { + name: "next rewrite different port same hostname", + origin: "http://localhost:3000", + host: "localhost:8000", + want: true, + }, + { + name: "x-forwarded-host matches origin", + origin: "http://localhost:3000", + host: "backend:8080", + extraHosts: []string{"localhost:3000"}, + want: true, + }, + { + name: "unrelated origin", + origin: "https://evil.example", + host: "localhost:8000", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := service.IsAllowedLogOrigin(ctx, tt.origin, tt.host, tt.extraHosts...) + if got != tt.want { + t.Errorf("IsAllowedLogOrigin(%q, %q, %v) = %v, want %v", + tt.origin, tt.host, tt.extraHosts, got, tt.want) + } + }) + } +} diff --git a/frontend/app/(main)/admin/logs/components/app-logs.tsx b/frontend/app/(main)/admin/logs/components/app-logs.tsx index 2429166d..8c9fc99f 100644 --- a/frontend/app/(main)/admin/logs/components/app-logs.tsx +++ b/frontend/app/(main)/admin/logs/components/app-logs.tsx @@ -252,10 +252,16 @@ export function AppLogs() { // ---- Initialize ------------------------------------------------------ useEffect(() => { - loadHistory(0).then(() => connectWs()); + let cancelled = false; + loadHistory(0).then(() => { + if (cancelled) return; + connectWs(); + }); return () => { - wsRef.current?.close(); + cancelled = true; + const ws = wsRef.current; wsRef.current = null; + ws?.close(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/frontend/app/(main)/admin/push/components/events-tab.tsx b/frontend/app/(main)/admin/push/components/events-tab.tsx index aabcfdc2..b45e9f17 100644 --- a/frontend/app/(main)/admin/push/components/events-tab.tsx +++ b/frontend/app/(main)/admin/push/components/events-tab.tsx @@ -713,8 +713,8 @@ export function EventsTab() { {newEventType === 'task' - ? t('taskTemplateVars') - : t('eventTemplateVars')} + ? t.raw('taskTemplateVars') + : t.raw('eventTemplateVars')}