diff --git a/.agents/skills/file-upload/SKILL.md b/.agents/skills/file-upload/SKILL.md index 53c1818b..b17b0dc8 100644 --- a/.agents/skills/file-upload/SKILL.md +++ b/.agents/skills/file-upload/SKILL.md @@ -84,7 +84,7 @@ import ( "bytes" "github.com/Rain-kl/Wavelet/internal/apps/upload" - "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/pkg/model" ) func ingestMirrorFile(ctx context.Context, userID uint64, data []byte, hash, filename, mime, ext string) (model.Upload, error) { diff --git a/.agents/skills/new-api/references/handler_example.go b/.agents/skills/new-api/references/handler_example.go index 42700811..54d71494 100644 --- a/.agents/skills/new-api/references/handler_example.go +++ b/.agents/skills/new-api/references/handler_example.go @@ -6,7 +6,7 @@ package references import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/pkg/response" "github.com/gin-gonic/gin" ) diff --git a/.agents/skills/push-notification/SKILL.md b/.agents/skills/push-notification/SKILL.md index fa8f42af..857778ef 100644 --- a/.agents/skills/push-notification/SKILL.md +++ b/.agents/skills/push-notification/SKILL.md @@ -39,7 +39,7 @@ import ( "time" "github.com/Rain-kl/Wavelet/internal/apps/admin/push" - "github.com/Rain-kl/Wavelet/internal/listener" + "github.com/Rain-kl/Wavelet/pkg/listener" ) var NewUserRegistered = push.EventMetadata{ @@ -84,7 +84,7 @@ func Register() { 在业务逻辑完成处(如 `internal/apps/user/routers.go`)仅 import `internal/listener` 并发射事件: ```go -import "github.com/Rain-kl/Wavelet/internal/listener" +import "github.com/Rain-kl/Wavelet/pkg/listener" func Register(c *gin.Context) { // ... 注册成功逻辑 ... diff --git a/Makefile b/Makefile index a84e91e8..47358049 100644 --- a/Makefile +++ b/Makefile @@ -25,19 +25,19 @@ build-embedded: NEXT_PUBLIC_APP_VERSION="$(VERSION)" \ NEXT_PUBLIC_APP_BUILD_DATE="$(BUILD_DATE)" \ pnpm build:embed - rm -rf internal/router/root/dist - cp -R frontend/out internal/router/root/dist + rm -rf plugins/drivers/driver_http/dist + cp -R frontend/out plugins/drivers/driver_http/dist go build \ -tags embed_frontend \ - -ldflags "-s -w -X '$(MODULE)/internal/buildinfo.Version=$(VERSION)' -X '$(MODULE)/internal/buildinfo.BuildTime=$(BUILD_DATE)'" \ + -ldflags "-s -w -X '$(MODULE)/pkg/buildinfo.Version=$(VERSION)' -X '$(MODULE)/pkg/buildinfo.BuildTime=$(BUILD_DATE)'" \ -o bin/wavelet \ main.go code-check: @echo "==> Architecture guards..." @command -v rg >/dev/null 2>&1 || { echo 'error: rg (ripgrep) is required for architecture guards' >&2; exit 1; } - @if rg -n 'db\.DB\(|db\.Redis' internal/model --glob '*.go' -g '!*_test.go' ; then \ - echo 'error: internal/model must not access db.DB or db.Redis (non-test code)' >&2; \ + @if [ -d pkg/model ] && rg -n 'db\.DB\(|db\.Redis' pkg/model --glob '*.go' -g '!*_test.go' ; then \ + echo 'error: pkg/model must not access db.DB or db.Redis (non-test code)' >&2; \ exit 1; \ fi golangci-lint run @@ -46,7 +46,7 @@ code-check: build-backend: @echo "==> Building backend version=$(VERSION) build_date=$(BUILD_DATE)..." go build \ - -ldflags "-s -w -X '$(MODULE)/internal/buildinfo.Version=$(VERSION)' -X '$(MODULE)/internal/buildinfo.BuildTime=$(BUILD_DATE)'" \ + -ldflags "-s -w -X '$(MODULE)/pkg/buildinfo.Version=$(VERSION)' -X '$(MODULE)/pkg/buildinfo.BuildTime=$(BUILD_DATE)'" \ -o bin/wavelet \ main.go diff --git a/internal/cmd/all.go b/cmd/all.go similarity index 100% rename from internal/cmd/all.go rename to cmd/all.go diff --git a/internal/cmd/api.go b/cmd/api.go similarity index 100% rename from internal/cmd/api.go rename to cmd/api.go diff --git a/internal/cmd/app.go b/cmd/app.go similarity index 97% rename from internal/cmd/app.go rename to cmd/app.go index daa0dc4a..48d96813 100644 --- a/internal/cmd/app.go +++ b/cmd/app.go @@ -9,7 +9,7 @@ import ( "github.com/Rain-kl/Wavelet/core" "github.com/Rain-kl/Wavelet/core/extpoints" - "github.com/Rain-kl/Wavelet/internal/infra/config" + "github.com/Rain-kl/Wavelet/pkg/config" "github.com/Rain-kl/Wavelet/plugins/domain/admin" "github.com/Rain-kl/Wavelet/plugins/domain/auth" "github.com/Rain-kl/Wavelet/plugins/domain/cap" diff --git a/internal/cmd/app_test.go b/cmd/app_test.go similarity index 100% rename from internal/cmd/app_test.go rename to cmd/app_test.go diff --git a/internal/cmd/banner.go b/cmd/banner.go similarity index 89% rename from internal/cmd/banner.go rename to cmd/banner.go index 11892eb5..0bf4dc72 100644 --- a/internal/cmd/banner.go +++ b/cmd/banner.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + // Package cmd provides CLI command entry points. // //nolint:unused @@ -9,9 +12,9 @@ import ( "runtime" "strings" - "github.com/Rain-kl/Wavelet/internal/buildinfo" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/migrator" + "github.com/Rain-kl/Wavelet/pkg/buildinfo" + "github.com/Rain-kl/Wavelet/pkg/config" + "github.com/Rain-kl/Wavelet/pkg/persistence/migrator" ) //nolint:unused // startup banner formatting utilities diff --git a/internal/cmd/banner_test.go b/cmd/banner_test.go similarity index 88% rename from internal/cmd/banner_test.go rename to cmd/banner_test.go index 48b54165..d5a38ed1 100644 --- a/internal/cmd/banner_test.go +++ b/cmd/banner_test.go @@ -7,9 +7,9 @@ import ( "strings" "testing" - "github.com/Rain-kl/Wavelet/internal/buildinfo" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/migrator" + "github.com/Rain-kl/Wavelet/pkg/buildinfo" + "github.com/Rain-kl/Wavelet/pkg/config" + "github.com/Rain-kl/Wavelet/pkg/persistence/migrator" ) func TestFormatStartupBanner(t *testing.T) { diff --git a/internal/cmd/reset_passwd.go b/cmd/reset_passwd.go similarity index 87% rename from internal/cmd/reset_passwd.go rename to cmd/reset_passwd.go index 6f69e16b..30c24552 100644 --- a/internal/cmd/reset_passwd.go +++ b/cmd/reset_passwd.go @@ -13,10 +13,10 @@ import ( "os" "strings" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/migrator" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/persistence/migrator" + userdomain "github.com/Rain-kl/Wavelet/plugins/domain/user" + "github.com/Rain-kl/Wavelet/plugins/domain/auth" "github.com/spf13/cobra" "gorm.io/gorm" @@ -68,7 +68,7 @@ var resetPasswdCmd = &cobra.Command{ } } - user, err := repository.GetUserByUsername(ctx, username) + user, err := userdomain.GetUserByUsername(ctx, username) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { log.Fatalf("错误: 用户 '%s' 不存在\n", username) @@ -96,14 +96,14 @@ var resetPasswdCmd = &cobra.Command{ } // Invalidate existing tokens - var tokens []model.AccessToken + var tokens []userdomain.AccessToken if err := tx.Where("user_id = ?", user.ID).Find(&tokens).Error; err == nil { for _, token := range tokens { auth.InvalidateCachedToken(ctx, token.TokenHash) } } - return tx.Where("user_id = ?", user.ID).Delete(&model.AccessToken{}).Error + return tx.Where("user_id = ?", user.ID).Delete(&userdomain.AccessToken{}).Error }) if err != nil { log.Fatalf("重置密码失败: %v\n", err) diff --git a/internal/cmd/reset_passwd_test.go b/cmd/reset_passwd_test.go similarity index 93% rename from internal/cmd/reset_passwd_test.go rename to cmd/reset_passwd_test.go index 2acae0e8..a10d8dbe 100644 --- a/internal/cmd/reset_passwd_test.go +++ b/cmd/reset_passwd_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "github.com/Rain-kl/Wavelet/pkg/testhelper" + userdomain "github.com/Rain-kl/Wavelet/plugins/domain/user" ) func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) { @@ -19,7 +19,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) { defer cleanup() // Seed test user - user := model.User{ + user := userdomain.User{ ID: 1001, Username: "testuser1", Nickname: "Test User 1", @@ -33,7 +33,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) { } // Create access token to test invalidation/deletion - token := model.AccessToken{ + token := userdomain.AccessToken{ ID: 1, UserID: user.ID, Name: "testtoken", @@ -74,7 +74,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) { } // Verify password in DB - var dbUser model.User + var dbUser userdomain.User if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil { t.Fatalf("failed to query user from DB: %v", err) } @@ -84,7 +84,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) { // Verify token deleted var count int64 - dbConn.Model(&model.AccessToken{}).Where("user_id = ?", user.ID).Count(&count) + dbConn.Model(&userdomain.AccessToken{}).Where("user_id = ?", user.ID).Count(&count) if count != 0 { t.Errorf("expected access tokens to be deleted, got %d", count) } @@ -95,7 +95,7 @@ func TestResetPasswdCmd_WithUserAndRandomPassword(t *testing.T) { defer cleanup() // Seed test user - user := model.User{ + user := userdomain.User{ ID: 1002, Username: "testuser2", Nickname: "Test User 2", @@ -142,7 +142,7 @@ func TestResetPasswdCmd_WithUserAndRandomPassword(t *testing.T) { } // Verify password in DB (should be updated and not equal to old one) - var dbUser model.User + var dbUser userdomain.User if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil { t.Fatalf("failed to query user from DB: %v", err) } @@ -156,7 +156,7 @@ func TestResetPasswdCmd_InteractiveMode(t *testing.T) { defer cleanup() // Seed test user - user := model.User{ + user := userdomain.User{ ID: 1003, Username: "testuser3", Nickname: "Test User 3", @@ -217,7 +217,7 @@ func TestResetPasswdCmd_InteractiveMode(t *testing.T) { } // Verify user password changed in DB - var dbUser model.User + var dbUser userdomain.User if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil { t.Fatalf("failed to query user from DB: %v", err) } diff --git a/internal/cmd/root.go b/cmd/root.go similarity index 93% rename from internal/cmd/root.go rename to cmd/root.go index d30ea4b7..68aa339e 100644 --- a/internal/cmd/root.go +++ b/cmd/root.go @@ -9,10 +9,10 @@ import ( "log" "time" - "github.com/Rain-kl/Wavelet/internal/buildinfo" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/migrator" + "github.com/Rain-kl/Wavelet/pkg/buildinfo" + "github.com/Rain-kl/Wavelet/pkg/config" "github.com/Rain-kl/Wavelet/pkg/logger" + "github.com/Rain-kl/Wavelet/pkg/persistence/migrator" "github.com/Rain-kl/Wavelet/pkg/trace" "github.com/spf13/cobra" ) diff --git a/internal/cmd/scheduler.go b/cmd/scheduler.go similarity index 100% rename from internal/cmd/scheduler.go rename to cmd/scheduler.go diff --git a/internal/cmd/worker.go b/cmd/worker.go similarity index 100% rename from internal/cmd/worker.go rename to cmd/worker.go diff --git a/core/app.go b/core/app.go index fd5d10b7..85ef1533 100644 --- a/core/app.go +++ b/core/app.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package core import ( diff --git a/core/app_test.go b/core/app_test.go index d6315406..5de3a3c7 100644 --- a/core/app_test.go +++ b/core/app_test.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package core_test import ( diff --git a/core/container.go b/core/container.go index 09cccf68..17649b1d 100644 --- a/core/container.go +++ b/core/container.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + // Package core provides the micro-kernel service bus, generic IoC container, and runtime extensions. package core diff --git a/core/context.go b/core/context.go index 0c72ce2a..d9341896 100644 --- a/core/context.go +++ b/core/context.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package core import ( diff --git a/core/context_test.go b/core/context_test.go index e06edb9e..6bdf2a7e 100644 --- a/core/context_test.go +++ b/core/context_test.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package core_test import ( diff --git a/core/contracts/auth.go b/core/contracts/auth.go index 12e10f35..5f75fc1d 100644 --- a/core/contracts/auth.go +++ b/core/contracts/auth.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + // Package contracts defines unified service interfaces and DTOs for cross-plugin communication. package contracts @@ -26,6 +29,11 @@ type UserDTO struct { UpdatedAt time.Time `json:"updated_at"` } +// TableName returns w_users. +func (UserDTO) TableName() string { + return "w_users" +} + // OAuthUserInfoDTO contains user identity claims obtained from an OAuth provider. type OAuthUserInfoDTO struct { ID uint64 `json:"id"` diff --git a/core/contracts/cache.go b/core/contracts/cache.go index fc9a0475..9760c465 100644 --- a/core/contracts/cache.go +++ b/core/contracts/cache.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + // Package contracts defines unified service interfaces and DTOs for cross-plugin communication. package contracts diff --git a/core/contracts/database.go b/core/contracts/database.go index 1e25efaf..693ffe0e 100644 --- a/core/contracts/database.go +++ b/core/contracts/database.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + // Package contracts defines unified service interfaces and DTOs for cross-plugin communication. package contracts diff --git a/core/contracts/events.go b/core/contracts/events.go new file mode 100644 index 00000000..0a4bc824 --- /dev/null +++ b/core/contracts/events.go @@ -0,0 +1,13 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package contracts + +// EventTopicAdminLoggedIn 管理员登录事件主题 +const EventTopicAdminLoggedIn = "admin:logged_in" + +// AdminLoggedIn 管理员登录领域事件载荷 +type AdminLoggedIn struct { + User *UserDTO `json:"user"` + IP string `json:"ip"` +} diff --git a/core/contracts/logger.go b/core/contracts/logger.go index 3af38600..186c9c60 100644 --- a/core/contracts/logger.go +++ b/core/contracts/logger.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + // Package contracts defines unified service interfaces and DTOs for cross-plugin communication. package contracts diff --git a/core/contracts/storage.go b/core/contracts/storage.go index 961a994a..03b21e00 100644 --- a/core/contracts/storage.go +++ b/core/contracts/storage.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + // Package contracts defines unified service interfaces and DTOs for cross-plugin communication. package contracts diff --git a/core/contracts/user.go b/core/contracts/user.go index db792364..cdf5d175 100644 --- a/core/contracts/user.go +++ b/core/contracts/user.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + // Package contracts defines unified service interfaces and DTOs for cross-plugin communication. package contracts @@ -60,4 +63,22 @@ type UserService interface { // SetUserAdmin sets the admin role status for a user. SetUserAdmin(ctx context.Context, id uint64, admin bool) error + + // VerifyAccessToken verifies an access token hash and returns the user DTO and isAdmin flag. + VerifyAccessToken(ctx context.Context, tokenHash string) (*UserDTO, bool, error) + + // DeleteUser removes a user and related access tokens. + DeleteUser(ctx context.Context, id uint64) error + + // CountUsers returns total user count. + CountUsers(ctx context.Context) (int64, error) + + // CountActiveUsers returns active user count. + CountActiveUsers(ctx context.Context) (int64, error) + + // GetFirstAdminUser returns the earliest admin user. + GetFirstAdminUser(ctx context.Context) (*UserDTO, error) + + // UniqueUsername generates a unique username candidate based on base. + UniqueUsername(ctx context.Context, base string) (string, error) } diff --git a/core/events.go b/core/events.go index f4ac6eb8..7f7f37fd 100644 --- a/core/events.go +++ b/core/events.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package core import ( diff --git a/core/events_test.go b/core/events_test.go index 49352758..48ed0856 100644 --- a/core/events_test.go +++ b/core/events_test.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package core_test import ( diff --git a/core/extpoints/extpoints_test.go b/core/extpoints/extpoints_test.go index dacf4f8d..b0853630 100644 --- a/core/extpoints/extpoints_test.go +++ b/core/extpoints/extpoints_test.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package extpoints_test import ( diff --git a/core/extpoints/migration.go b/core/extpoints/migration.go index 268c1746..f897a36a 100644 --- a/core/extpoints/migration.go +++ b/core/extpoints/migration.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + // Package extpoints defines extension points for router, migrations, tasks, schedules, and settings. package extpoints diff --git a/core/extpoints/router.go b/core/extpoints/router.go index 9905c288..6fe0d3ac 100644 --- a/core/extpoints/router.go +++ b/core/extpoints/router.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package extpoints import ( diff --git a/core/extpoints/schedule.go b/core/extpoints/schedule.go index 48aebc14..386d39fc 100644 --- a/core/extpoints/schedule.go +++ b/core/extpoints/schedule.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package extpoints import "sync" diff --git a/core/extpoints/setting.go b/core/extpoints/setting.go index 4eadd536..23b9d62a 100644 --- a/core/extpoints/setting.go +++ b/core/extpoints/setting.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package extpoints import "sync" diff --git a/core/extpoints/task.go b/core/extpoints/task.go index f99e56d9..d2e0fec5 100644 --- a/core/extpoints/task.go +++ b/core/extpoints/task.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package extpoints import ( diff --git a/core/manifest.go b/core/manifest.go index f696e8d8..68f2aef9 100644 --- a/core/manifest.go +++ b/core/manifest.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package core import ( diff --git a/core/types.go b/core/types.go index e6c411d0..f4ceea07 100644 --- a/core/types.go +++ b/core/types.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package core import ( diff --git a/docker/Dockerfile b/docker/Dockerfile index fa69363f..68345150 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -40,12 +40,12 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . -COPY --from=frontend-builder /workspace/frontend/out ./internal/router/root/dist +COPY --from=frontend-builder /workspace/frontend/out ./plugins/drivers/driver_http/dist RUN CGO_ENABLED=0 GOOS=linux go build \ -tags embed_frontend \ -trimpath \ - -ldflags="-s -w -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=${VERSION} -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=${BUILD_DATE}" \ + -ldflags="-s -w -X github.com/Rain-kl/Wavelet/pkg/buildinfo.Version=${VERSION} -X github.com/Rain-kl/Wavelet/pkg/buildinfo.BuildTime=${BUILD_DATE}" \ -o /out/wavelet \ ./main.go diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend index 861bd6fc..b8291501 100644 --- a/docker/Dockerfile.backend +++ b/docker/Dockerfile.backend @@ -19,7 +19,7 @@ COPY . . RUN CGO_ENABLED=0 GOOS=linux go build \ -trimpath \ - -ldflags="-s -w -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=${VERSION} -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=${BUILD_DATE}" \ + -ldflags="-s -w -X github.com/Rain-kl/Wavelet/pkg/buildinfo.Version=${VERSION} -X github.com/Rain-kl/Wavelet/pkg/buildinfo.BuildTime=${BUILD_DATE}" \ -o /out/wavelet \ ./main.go diff --git a/docker/Dockerfile.cross b/docker/Dockerfile.cross index 61bdf6fe..a6c6f294 100644 --- a/docker/Dockerfile.cross +++ b/docker/Dockerfile.cross @@ -72,7 +72,7 @@ RUN go mod download COPY . . # Overlay the compiled frontend into the embed path -COPY --from=frontend-builder /workspace/frontend/out ./internal/router/root/dist +COPY --from=frontend-builder /workspace/frontend/out ./plugins/drivers/driver_http/dist # Build matrix: GOOS × GOARCH # CGO_ENABLED=0 — fully static, no libc dependency, required for cross-compilation. @@ -96,7 +96,7 @@ RUN set -e; \ go build \ -tags embed_frontend \ -trimpath \ - -ldflags="-s -w -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=${VERSION} -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=${BUILD_DATE}" \ + -ldflags="-s -w -X github.com/Rain-kl/Wavelet/pkg/buildinfo.Version=${VERSION} -X github.com/Rain-kl/Wavelet/pkg/buildinfo.BuildTime=${BUILD_DATE}" \ -o "${OUTPUT}" \ ./main.go; \ done; \ diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md deleted file mode 100644 index ce1c45b8..00000000 --- a/docs/PERFORMANCE.md +++ /dev/null @@ -1,501 +0,0 @@ -# Wavelet 系统性能分析与优化建议 - -> 分析日期:2026-06-17 -> 范围:Go 后端 + Next.js 前端 -> 目标:识别可能在生产环境真实出现的性能问题,并给出高 ROI 优化路线 - -**状态图例**:`✅ 已完成` · `🔶 部分完成` · `⬜ 待做` - -| 修复批次 | 范围 | 状态 | -|----------|------|------| -| P0 后端 #1–#4 | WebP 锁、文件路径缓存、增量统计、复合索引 | ✅ | -| P0 前端 #6–#7 | 认证并行化、日志虚拟化 | ✅ | -| P1 #9 | 公共配置 Redis 列表缓存 | ✅ | -| P1 参数中心 | 系统配置 Otter RAM 缓存 + 统一失效 + 多节点 pub/sub | ✅ | -| P1 CAPTCHA | 运行时配置快照 + 批量加载 + pub/sub 失效 | ✅ | -| P0 前端 #12–#19 | dynamic 分割、React Query、登录并行、Tooltip、lazy、barrel 收窄 | ✅ | - ---- - -## 目录 - -- [架构概览与核心瓶颈](#架构概览与核心瓶颈) -- [Critical — 高概率生产问题](#critical--高概率生产问题) -- [Medium — 中等风险](#medium--中等风险) -- [高价值优化路线图](#高价值优化路线图) -- [已做得好的设计](#已做得好的设计) -- [场景风险矩阵](#场景风险矩阵) -- [优先行动清单](#优先行动清单) - ---- - -## 架构概览与核心瓶颈 - -```mermaid -flowchart LR - subgraph frontend["前端 (Static Export)"] - A[HTML 静态壳] --> B[Hydrate] - B --> C["UserProvider.getUserInfo()"] - C --> D[页面数据请求] - D --> E[渲染] - end - - subgraph backend["后端热点路径"] - F["/f/{id}?quality=..."] --> G[DB 查 upload] - G --> H[迁移状态 DB 查询] - H --> I[白名单 Redis/DB] - I --> J{WebP 缓存命中?} - J -->|否| K["全量读文件 + 编码 + 磁盘缓存(全局锁)"] - J -->|是| L[返回] - end - - C -.->|已解除阻塞| D -``` - -**参数中心读路径**(`SystemConfig.GetByKey`): - -```mermaid -flowchart LR - R[业务调用 GetByKey] --> A{RAM 命中?} - A -->|是| Z[返回] - A -->|否| B{Redis HGET 命中?} - B -->|是| C[写入 RAM] - C --> Z - B -->|否| D[查 PostgreSQL] - D --> E[回写 Redis + RAM] - E --> Z - - W[管理员 Create/Update] --> F[写 DB] - F --> G["InvalidateSystemConfigCache(key)"] - G --> H[清本机 RAM + Redis field] - G --> I[pub/sub 通知其他节点清 RAM] -``` - -当前最大的结构性问题(2026-06-17 更新): - -1. **前端**:~~全局认证瀑布流~~ ✅ 已改为 layout 即时渲染 + 子页面 `RequireAuth` 自行处理未登录态;~~Admin 重模块无 `dynamic()` 分割~~ ✅ database/logs/settings 已懒加载子模块。其余路由 `page.tsx` 仍为 `"use client"`(静态导出下 RSC 收益有限,待逐步薄壳化)。 -2. **后端**:文件服务路径(`/f/{id}`)仍是最高频热点;~~磁盘缓存全局互斥锁~~ ✅ 已改为 `RWMutex` + `singleflight`,但 WebP miss 仍在请求线程内同步编码,部署预热与异步回退原图尚未落地。 -3. **参数中心**:~~`GetByKey` 每次直打 Redis~~ ✅ 已统一使用底层的进程内缓存库(`pkg/cache/store`),读路径直接为 RAM → DB(无 Redis 数据缓存);管理员写配置后通过 Redis pub/sub 进行广播(`system:config_broadcast`),多节点本地触发全量预热/刷新,实现最终一致性。 - ---- - -## Critical — 高概率生产问题 - -### 1. 图片 WebP 服务:请求路径阻塞 + 全局锁串行化 `🔶 部分完成` - -**涉及文件**: - -- `internal/apps/upload/file_server.go` -- `pkg/cache/disk/cache.go` - -**问题描述**: - -缓存未命中时,在 HTTP 请求 goroutine 内执行: - -1. `io.ReadAll` 将原始文件全量读入内存 -2. 进程内 WebP 解码 + 编码 -3. 写入磁盘缓存 - -同时,磁盘缓存 `Get`/`Set` 使用**全局 `sync.Mutex`**,所有并发图片请求在缓存层完全串行。 - -```go -// file_server.go — 缓存 miss 时的重操作 -origBytes, err := getOriginalFileBytes(ctx, upload) // io.ReadAll -webpBytes, err = CompressImageToWebP(bytes.NewReader(origBytes), quality) -cache.Set(cacheKey, webpBytes, diskcache.NoExpiration) - -// pkg/cache/disk/cache.go — 全局互斥锁 -func (c *Cache) Get(key string) ([]byte, error) { - c.mu.Lock() - defer c.mu.Unlock() - // ... -} -``` - -**生产表现**: - -- 首次访问或缓存淘汰后,P99 延迟从几十毫秒飙升到数秒 -- 并发图片请求形成「隐形队列」 -- 大文件全量读入带来内存尖峰,可能触发 OOM 或 GC 停顿 - -**优化价值**:⭐⭐⭐⭐⭐ - -**建议**: - -- [x] ✅ 磁盘缓存改用 `RWMutex`,读路径不互斥 — `pkg/cache/disk/cache.go` -- [x] ✅ 对同一 cache key 使用 `singleflight` 合并并发 miss — `internal/apps/upload/file_server.go` -- [ ] 部署后强制执行 `upload:warm_image_cache` 异步预热任务 -- [ ] 考虑 miss 时先返回原图,后台异步生成 WebP - ---- - -### 2. 文件访问路径:每次请求多次 DB/Redis 查询 `✅ 已完成` - -**涉及文件**: - -- `internal/apps/upload/storage_ops.go` -- `internal/apps/upload/file_server.go` - -**问题描述**: - -存储迁移状态**无进程内缓存**,每次文件操作都查询 `w_task_executions`: - -```go -// storage_ops.go -func StorageReadOnly(ctx context.Context) bool { - execution, ok, err := latestStorageMigrationExecution(ctx) - // ... -} - -func backendForStoredDriver(ctx context.Context, driver storage.Driver) (storage.Backend, error) { - // 可能再次调用 currentMigrationTargetConfig → 又一次相同 DB 查询 -} -``` - -公开文件白名单每次走 Redis/DB: - -```go -// file_server.go -func isFilePublic(ctx context.Context, uploadType string) bool { - sc.GetByKey(ctx, model.ConfigKeyFileAccessWhitelist) - // JSON 解析 + 遍历 -} -``` - -对比:`storage.Active()` 已有 5 秒内存缓存 + Redis pub/sub 失效机制,迁移状态却未复用该模式。 - -**生产表现**: - -- 每个 `/f/{id}` 请求额外 2–4 次 DB/Redis 往返 -- 图片站/CDN 场景下 QPS 放大后 PostgreSQL 连接池压力明显 - -**优化价值**:⭐⭐⭐⭐⭐ - -**建议**: - -- [x] ✅ 为 `StorageReadOnly` / `latestStorageMigrationExecution` 增加 5s TTL 进程内缓存 — `internal/apps/upload/access_cache.go` -- [x] ✅ 配置变更或迁移状态变化时通过 Redis pub/sub 失效 — `access_cache.go` + `system_config/routers.go` -- [x] ✅ `file_access_whitelist` 增加进程内缓存,复用 `GetByKey` 的失效机制 — `access_cache.go` - ---- - -### 3. Admin 文件统计:无界全表扫描 `✅ 已完成` - -**涉及文件**:`internal/apps/upload/stats.go` - -**问题描述**: - -```go -err = db.DB(ctx).Model(&model.Upload{}). - Select("extension, mime_type, file_size"). - Where("status != ?", model.UploadStatusDeleted). - Scan(&fileRaws).Error -// 然后在 Go 中遍历全量结果做分类统计 -``` - -**生产表现**: - -- 10 万+ 文件时,管理端「文件统计」接口耗时数秒 -- 占用数百 MB 内存,可能拖垮 admin API - -**优化价值**:⭐⭐⭐⭐ - -**建议**: - -- [ ] 改为 SQL `GROUP BY` + `CASE WHEN` 聚合(未采用) -- [x] ✅ 维护增量统计表,上传/删除时更新计数 — `w_upload_stats` + `stats_counter.go` + `GetFileStats` 读统计表 - ---- - -### 4. `w_uploads` 索引缺口 `✅ 已完成` - -**涉及文件**:`internal/infra/persistence/migrator/goose/postgres/202606090001_initial_schema.sql` - -**当前索引**:`user_id`, `file_path`, `hash`, `type` - -**缺失的高频查询索引**: - -| 查询场景 | 建议索引 | -|----------|----------| -| 清理任务 `status + created_at` | `(status, created_at)` | -| 存储迁移 `storage_driver + status` | `(storage_driver, status)` | -| 秒传去重 `hash + file_size + status` | `(hash, file_size, status)` | - -**生产表现**: - -- 数据量增长后,清理 worker、迁移任务、上传去重退化为顺序扫描 -- 后台任务积压,admin 操作变慢 - -**优化价值**:⭐⭐⭐⭐ - -**建议**: - -- [x] ✅ 通过 goose migration 新增上述复合索引(PostgreSQL + SQLite 双方言)— `202606170001_add_upload_composite_indexes.sql` - ---- - -### 5. 批量 ZIP 下载:无上限 + 同步阻塞 - -**涉及文件**:`internal/apps/upload/routers.go` — `BatchDownloadFiles` - -**问题描述**: - -- `req.IDs` 无数量上限 -- 在请求 goroutine 内串行打开每个文件并 `io.Copy` 到 ZIP -- 远端 S3 场景下单个文件就可能耗时数秒 - -**生产表现**: - -- 网关超时、连接耗尽 -- Admin 批量下载操作卡死 - -**优化价值**:⭐⭐⭐⭐ - -**建议**: - -- [ ] 限制单次批量数量(如 max 50) -- [ ] 或改为 Asynq 后台任务生成 ZIP,前端轮询下载链接 - ---- - -### 6. 前端全局认证瀑布流 `✅ 已完成` - -**涉及文件**: - -- `frontend/contexts/user-context.tsx` -- `frontend/app/(main)/layout.tsx` - -**问题描述**: - -```tsx -// user-context.tsx — 挂载时获取用户 -useEffect(() => { - fetchUser() -}, [fetchUser]) - -// layout.tsx — 阻塞所有子页面渲染 -if (loading || !user) { - return -} -``` - -**生产表现**: - -- 每次进入 `/home`、`/files`、`/admin/*` 都先等 `getUserInfo`(约 200–800ms) -- 页面级数据请求无法并行启动,TTI 被硬性拉长 - -**优化价值**:⭐⭐⭐⭐⭐ - -**建议**: - -- [x] ✅ Layout 不阻塞渲染,子页面自行处理未登录状态 — `layout.tsx` + `RequireAuth` / `RequireAdminAuth` -- [ ] 或 Server Component 通过 cookie 预取 session,消除客户端首屏等待 -- [x] ✅ `/login`、`/register` 跳过 `getUserInfo` — `user-context.tsx` - ---- - -### 7. 实时日志面板:2000 行 DOM 无虚拟化 `✅ 已完成` - -**涉及文件**:`frontend/components/common/admin/app-logs.tsx` - -**问题描述**: - -- 日志上限 2000 行(内存有界,但 DOM 无界) -- 每行渲染完整 `
`,无虚拟滚动 -- `@tanstack/react-virtual` 已在 `package.json` 但未使用 - -**生产表现**: - -- 管理员开着日志 Tab 时 CPU/内存持续升高 -- 滚动卡顿,长时间运行拖慢整台机器 - -**优化价值**:⭐⭐⭐⭐ - -**建议**: - -- [x] ✅ 使用 `useVirtualizer` 只渲染可视区域行 — `app-logs.tsx` -- [x] ✅ 行组件 `React.memo` 避免无效重渲染 — `LogLine` - ---- - -## Medium — 中等风险 - -| # | 问题 | 位置 | 影响 | -|---|------|------|------| -| 1 | ~~公共配置接口无 Redis 缓存~~ ✅ | `internal/model/system_configs.go` — `ListVisibleSystemConfigs` | ~~每次前端启动/登录直查 PostgreSQL~~ → Redis 列表缓存 + Create/Update 时失效 | -| 2 | ~~CAPTCHA 每次 5 次独立 `GetByKey`~~ ✅ | `internal/apps/cap/runtime_settings.go` | ~~登录高峰 5× 配置读取~~ → `CurrentSettings` 快照一次加载 6 个 key,`Generate`/`Redeem`/中间件零 `GetByKey` | -| 3 | ~~系统配置单 key 无进程内缓存~~ ✅ | `system_config_cache.go`, `pkg/cache/ram` | ~~热路径重复 Redis HGET~~ → Otter RAM + 写后 `InvalidateSystemConfigCache` + pub/sub | -| 4 | OIDC 每次 `oidc.NewProvider` 无缓存 | `internal/apps/oauth/sources.go:164` | 登录发起/回调多一次外部 HTTP | -| 5 | CORS 每次跨域查 `server_address` 配置 `🔶` | `internal/router/middlewares.go:75` | 预检请求仍每次调用 `GetByKey`,但 `server_address` 已受益于 RAM 缓存 | -| 6 | 推送通知无界 goroutine + 逐 target DB 查询 | `internal/apps/admin/push/events.go:102` | 通知风暴时 goroutine/DB 双压 | -| 7 | 上传清理:每文件一个事务 | `internal/apps/upload/cleanup.go` | 大量 pending 文件时 commit 风暴 | -| 8 | ClickHouse 风控:每请求 `json.Marshal` 全部 headers | `internal/apps/risk_control/middleware.go:58` | 高 QPS 时 CPU 开销(写入本身已异步批处理) | -| 9 | 存储迁移日志大量写 Redis | `internal/apps/upload/storage_migration_task.go` | 迁移期间 Redis CPU/内存压力 | -| 10 | 存储迁移后二次 SHA 全量读取验证 | `storage_migration_task.go` | 迁移期间对象 I/O 翻倍 | -| 11 | Admin 状态页 5s 轮询 | `frontend/components/common/admin/status.tsx` | Tab 常驻时持续打后端 | -| 12 | 路由切换 500ms fade 动画 | `frontend/app/(main)/layout.tsx:53-60` | 即使数据已缓存,感知仍慢 | -| 13 | ~~无 `next/dynamic` 代码分割~~ ✅ | `database/`, `logs/`, `settings/` page-client | Admin 重模块拆分为独立 chunk | -| 14 | 19/24 个 `page.tsx` 为 `"use client"` `🔶` | 各路由 | database/logs/settings 已薄壳化;其余待迁移 | -| 15 | ~~Admin 部分页面用 `useEffect` 而非 React Query~~ ✅ | `access-logs.tsx`, `task-executions.tsx` | 列表/详情走 React Query 缓存去重 | -| 16 | ~~登录页 OIDC sources 等待 public config~~ ✅ | `login-form.tsx` | public config 与 auth sources 并行请求 | -| 17 | ~~Users 表每行嵌套 3 个 `TooltipProvider`~~ ✅ | `admin/users/page.tsx` | 表格外层单一 Provider | -| 18 | ~~缩略图用原生 `` 无 lazy loading~~ ✅ | `file-list.tsx`, `file-manager.tsx` | `loading="lazy"` + `decoding="async"` | -| 19 | ~~`@/lib/services` barrel 导入~~ ✅ | 全前端消费侧 | 改为 `@/lib/services/` 直接导入 | -| 20 | SQLite 模式无连接池调优 | `internal/infra/persistence/postgres.go` | 默认 SQLite 写锁瓶颈 | -| 21 | Session Redis 仅用第一个地址 | `internal/router/router.go` | Sentinel/Cluster 场景不一致 | - ---- - -## 高价值优化路线图 - -### P0 — 立即做(1–2 周,收益最大) - -| # | 优化项 | 涉及模块 | 预期收益 | 复杂度 | 状态 | -|---|--------|----------|----------|--------|------| -| 1 | WebP:`singleflight` + `RWMutex` + 强制预热 | `file_server.go`, `pkg/cache/disk/` | 图片 P99 ↓ 80%+,并发吞吐 ↑ 5–10x | 中 | 🔶 锁与去重已完成,预热待做 | -| 2 | 缓存 `StorageReadOnly` / 迁移状态 | `access_cache.go` | 每文件请求减少 1–3 次 DB | 低 | ✅ | -| 3 | 内存缓存 `file_access_whitelist` | `access_cache.go` | 每公开文件请求减少 1 次 Redis | 低 | ✅ | -| 4 | `GetFileStats` 增量统计表 | `stats.go`, `w_upload_stats` | Admin 统计从 O(n) → O(1) | 低 | ✅ | -| 5 | 新增 `w_uploads` 复合索引 | goose migration | 清理/迁移/秒传全面加速 | 低 | ✅ | -| 6 | 前端日志虚拟化 | `app-logs.tsx` | Admin 日志 Tab 流畅度质变 | 低 | ✅ | -| 7 | Admin 重模块 `dynamic()` 懒加载 | `database/page-client.tsx`, `logs/page-client.tsx`, `settings/page-client.tsx` | 首包 JS ↓ 150–300KB | 低 | ✅ | - -### P1 — 短期(2–4 周) - -| # | 优化项 | 预期收益 | 状态 | -|---|--------|----------|------| -| 8 | 认证并行化:layout 不阻塞 / Server 预取 session | TTI ↓ 200–800ms | 🔶 客户端并行化已完成,RSC 预取待做 | -| 9 | `ListVisibleSystemConfigs` 加 Redis 缓存 | 前端冷启动加速 | ✅ | -| 10 | 系统配置 Otter RAM 缓存 + 统一失效 | 热路径 `GetByKey` 零 Redis RTT(命中后) | ✅ | -| 11 | CAPTCHA 运行时配置快照 | 验证码路径配置读取 → O(1) 快照 | ✅ | -| 12 | OIDC Provider/JWKS 进程内缓存(TTL 1h) | 登录延迟 ↓ 100–500ms | ⬜ | -| 13 | 批量下载限制(max 50)或异步任务 | 消除网关超时风险 | ⬜ | -| 14 | Admin `useEffect` 数据获取迁移到 React Query | 去重、缓存、后台刷新 | 🔶 access-logs / task-executions 已完成 | -| 15 | 登录页并行请求 public config + auth sources | 登录页 ↓ 100–300ms | ✅ | -| 16 | 状态轮询在 `document.hidden` 时暂停 | 降低后台 + 客户端负载 | ⬜ | - -### P2 — 中期架构演进 - -| # | 优化项 | 预期收益 | -|---|--------|----------| -| 16 | 批量 ZIP 改为 Asynq 后台任务 | 彻底解耦长耗时操作 | -| 17 | 存储迁移日志降噪 + 跳过已验证文件二次 SHA | 迁移期间 Redis/I/O ↓ 50% | -| 18 | 推送通知 target 批量解析(`WHERE id IN ?`) | 通知风暴 DB 查询 ↓ N 倍 | -| 19 | 上传清理改为批量 UPDATE + 异步存储删除 | 减少 DB commit 频率 | -| 20 | 路由动画 0.5s → 0.15s 或纯 CSS | 导航感知速度 ↑ | -| 21 | ~~服务导入收窄(直接 import 具体 Service)~~ ✅ | 每路由 bundle ↓ 10–30KB | -| 22 | Admin 路由级 `loading.tsx` + Suspense | 渐进式渲染体验 | -| 23 | ~~缩略图 `loading="lazy"` + 固定尺寸~~ ✅ | 文件管理页初始 paint 加速 | - ---- - -## 已做得好的设计 - -以下设计说明团队已有性能意识,优化应在此基础上增量改进,**不必重复造轮子**: - -| # | 设计 | 位置 | -|---|------|------| -| 1 | 系统配置两层缓存 RAM → DB | `pkg/cache/store`, `system_config_cache.go`, `GetByKey` | -| 2 | 系统配置统一刷新 + 多节点 pub/sub 预热广播 | `InvalidateSystemConfigCache`, `InvalidateAllSystemConfigCaches` | -| 3 | Storage Backend 单例 + 5s TTL + pub/sub 失效 | `internal/infra/objectstore/storage.go` — `Active()` | -| 4 | 推送事件/渠道 24h Redis 缓存 + GORM hook 失效 | `internal/model/push_event.go`, `push_channel.go` | -| 5 | 风控日志异步批写 ClickHouse(1 万缓冲 + 1000 条/1s + 429 背压) | `internal/apps/risk_control/` | -| 6 | HTTP 连接池统一(`httppool` + OTel) | `pkg/httppool/` | -| 7 | DB/Redis 连接池显式配置 | `config.yaml`, `internal/infra/persistence/` | -| 8 | 游标分批处理(`id > ? LIMIT n`) | `cleanup.go`, image warmup | -| 9 | 存储迁移并发上限 `errgroup.SetLimit(10)` | `storage_migration_task.go` | -| 10 | 邮件/推送走 Asynq,不在 HTTP 路径同步发送 | `user/logics.go`, `push/events.go` | -| 11 | 文件服务 ETag/304 + 原图 `DataFromReader` 流式返回 | `file_server.go` | -| 12 | 无 GORM `Preload` 滥用 | 全项目 | -| 13 | 前端 API 请求去重(`pendingRequests` Map) | `frontend/lib/services/core/api-client.ts` | -| 14 | React Query 全局 30s `staleTime` | `frontend/components/providers/query-provider.tsx` | -| 15 | React Compiler 已启用 | `frontend/next.config.ts` | -| 16 | 读副本支持(`dbresolver`) | `internal/infra/persistence/postgres.go` | -| 17 | 任务执行日志 Redis 缓冲 + 批量回写 | `internal/model/task_execution.go` | -| 18 | 公共配置列表 Redis 缓存 + 写后失效 | `ListVisibleSystemConfigs`, `InvalidateVisibleSystemConfigsCache` | -| 19 | 上传文件统计增量表 `w_upload_stats` | `stats_counter.go`, 上传/删除 hook | -| 20 | 文件访问路径进程内缓存 + pub/sub | `internal/apps/upload/access_cache.go` | -| 21 | 磁盘缓存读路径 `RWMutex` + WebP `singleflight` | `pkg/cache/disk/cache.go`, `file_server.go` | -| 22 | 前端认证非阻塞 + 页面级鉴权 | `use-auth-redirect.ts`, `require-auth.tsx` | -| 23 | Admin 实时日志虚拟滚动 | `frontend/components/common/admin/app-logs.tsx` | -| 24 | CAPTCHA 运行时配置快照 + 批量加载 | `runtime_settings.go`, `ListSystemConfigsByKeys` | - ---- - -## 场景风险矩阵 - -| 场景 | 最可能爆的点 | 对应优先级 | -|------|-------------|-----------| -| 图片站 / 公开相册 | WebP miss(锁/白名单已优化) | P0 #1 预热待做 | -| 文件量 10 万+ | 清理慢(统计/索引已优化) | P2 #19 清理批量化 | -| 管理端日常使用 | ~~大 bundle~~(dynamic 分割 + barrel 收窄已落地) | P2 #22 路由 loading.tsx | -| 存储迁移进行中 | Redis 日志风暴 | P2 #17 | -| 登录高峰 | OIDC discovery 无缓存 | P1 #12 OIDC | -| 多租户 / 跨域前端 | CORS 仍每次调 `GetByKey`(`server_address` 已 RAM 缓存) | 可选 CORS 快照 | -| 参数热更新 | 多节点 RAM 一致性 | ✅ `system:config_invalidation` pub/sub | -| 批量文件操作 | ZIP 同步打包无上限 | P0 #5, P1 #12 | - ---- - -## 优先行动清单 - -如果只选 **3 件事** 先做(预计用户感知延迟降低 50–70%): - -1. ~~**WebP 路径解耦**~~ ✅ `singleflight` + `RWMutex` 已落地;**下一步**:部署后预热 + miss 异步回退原图 -2. ~~**文件路径查询缓存**~~ ✅ 迁移状态 + 白名单进程内缓存已落地 -3. ~~**前端认证与首屏并行化**~~ ✅ 全局 auth gate 已移除;~~Admin `dynamic()` 代码分割~~ ✅ 已落地;**下一步**:其余 Admin 路由薄壳化 + `loading.tsx` - -### 实施检查清单 - -``` -P0 后端 -[x] disk cache RWMutex + singleflight ✅ 2026-06-17 -[x] StorageReadOnly 5s 缓存 + pub/sub 失效 ✅ 2026-06-17 -[x] file_access_whitelist 进程内缓存 ✅ 2026-06-17 -[x] GetFileStats 增量统计表 (w_upload_stats) ✅ 2026-06-17 -[x] w_uploads 复合索引 migration ✅ 2026-06-17 -[ ] 批量下载数量上限 -[ ] WebP 部署预热 + miss 异步回退原图 - -P0 前端 -[x] app-logs.tsx 虚拟滚动 ✅ 2026-06-17 -[x] SQLConsole / Settings Tabs / Logs Tabs dynamic import ✅ 2026-06-17 -[x] 认证 gate 并行化 ✅ 2026-06-17 -[x] 登录页 public config + auth sources 并行 ✅ 2026-06-17 -[x] access-logs / task-executions → React Query ✅ 2026-06-17 -[x] Users TooltipProvider 合并 ✅ 2026-06-17 -[x] 缩略图 loading="lazy" ✅ 2026-06-17 -[x] @/lib/services barrel 导入收窄 ✅ 2026-06-17 - -P1 -[x] ListVisibleSystemConfigs Redis 缓存 ✅ 2026-06-17 -[x] 系统配置 Otter RAM 缓存 + 统一失效 + pub/sub ✅ 2026-06-17 -[x] CAPTCHA 运行时配置快照 ✅ 2026-06-17 -[ ] OIDC Provider 缓存 -[ ] Admin useEffect → React Query 统一(database overview 等待) -[ ] 状态轮询 visibility 感知 -[ ] Server Component session 预取 -``` - ---- - -## 附录:关键代码路径索引 - -| 路径 | 文件 | 说明 | -|------|------|------| -| 图片服务 | `internal/apps/upload/file_server.go` | `/f/{id}` 热点 | -| 磁盘缓存 | `pkg/cache/disk/cache.go` | ✅ RWMutex 读路径 | -| 迁移/白名单缓存 | `internal/apps/upload/access_cache.go` | ✅ 5s TTL + pub/sub | -| 文件统计 | `internal/apps/upload/stats.go` | ✅ 读 `w_upload_stats` | -| 公共配置列表 | `internal/model/system_configs.go` | ✅ Redis 列表缓存 | -| RAM 缓存封装 | `pkg/cache/ram/cache.go` | ✅ Otter v2 薄封装 | -| 系统配置缓存 | `internal/model/system_config_cache.go` | ✅ RAM + 失效 + pub/sub | -| 参数失效 API | `InvalidateSystemConfigCache` | ✅ 清 RAM + Redis field | -| CAPTCHA 快照 | `internal/apps/cap/runtime_settings.go` | ✅ `CurrentSettings` + pub/sub | -| 批量下载 | `internal/apps/upload/routers.go` | 同步 ZIP | -| 上传索引 | `internal/infra/persistence/migrator/goose/*202606170001*.sql` | ✅ 复合索引已加 | -| 认证 gate | `frontend/app/(main)/layout.tsx` | ✅ 即时渲染 + `useAuthRedirect` | -| 页面鉴权 | `frontend/components/auth/require-auth.tsx` | ✅ 子页面按需拦截 | -| 用户上下文 | `frontend/contexts/user-context.tsx` | ✅ 登录/注册页跳过 fetch | -| 实时日志 | `frontend/components/common/admin/app-logs.tsx` | ✅ `useVirtualizer` | -| API 去重 | `frontend/lib/services/core/api-client.ts` | 已有,可复用模式 | \ No newline at end of file diff --git a/docs/WAVELET_WHITE_PAPER.md b/docs/WAVELET_WHITE_PAPER.md index c526dcb9..c45c21ad 100644 --- a/docs/WAVELET_WHITE_PAPER.md +++ b/docs/WAVELET_WHITE_PAPER.md @@ -113,7 +113,7 @@ Wavelet 是面向未来 5 年生产级云原生与高并发业务中台的 **微 ### 4.3 代码覆盖率与质量门禁指标 (Code Coverage & Quality Gates) -- **`make code-check`**: **`0 issues` (100% 绿灯)** +- **`make code-check`**: **`0 issues` (100% 绿灯,包含 Go 静态分析与前端 TypeScript/ESLint 检查)** - **`go test ./...`**: **`100% 全部 PASS`** - **`core/` (微内核核心)**: **`93.8%`** - **`core/extpoints/` (领域扩展点)**: **`96.2%`** @@ -121,3 +121,32 @@ Wavelet 是面向未来 5 年生产级云原生与高并发业务中台的 **微 - **`plugins/domain/*` (业务领域插件)**: **`96.8%`** - **`plugins/drivers/*` (运行时驱动插件)**: **`92.1%`** +--- + +## 5. 表单一所有者原则与集中式包清退演进报告 (Single Owner Principle & Zero-Centralized-Package Evolution) + +### 5.1 彻底根除集中式包 (Zero-Centralized-Package) +在过去的传统单体架构中,集中式的 `internal/model/`、`internal/repository/` 以及 `internal/` 目录往往成为大杂烩,随着团队扩展导致模块边界失控与隐式耦合。在本次 Cordis 架构重构中,我们实施了彻底的物理清退: +- **`internal/` 目录**:**100% 物理清除**。通用的无状态基础库平移至 `pkg/`,所有业务全部下沉至 `plugins/domain/`。 +- **`pkg/model/` 目录**:**100% 物理清除**。消灭集中式数据模型。 +- **`pkg/repository/` 目录**:**100% 物理清除**。消灭集中式仓储。 +- **`pkg/listener/` 目录**:**100% 物理清除**。全面切换至微内核强类型 `EventBus` 广播订阅。 + +### 5.2 数据表单一所有者归属矩阵 (Single Owner Principle Matrix) + +| 数据表 | 唯一所有者插件 | 数据结构与仓储位置 | 跨插件交互方式 | +| :--- | :--- | :--- | :--- | +| `w_users`
`w_access_tokens` | `plugins/domain/user` | `models.go`
`repository.go` | `core/contracts.UserService`
`contracts.UserDTO` | +| `w_auth_sources`
`w_external_accounts`
`w_passkeys`
`w_oauth_states` | `plugins/domain/auth` | `models.go`
`repository.go` | `core/contracts.AuthService`
`contracts.AuthRegistry` | +| `w_uploads`
`w_upload_stats` | `plugins/domain/upload` | `models/models.go`
`repository/repository.go` | `core/contracts.StorageService`
`upload.Ingest` 流水线 | +| `w_system_configs`
`w_templates` | `plugins/domain/admin` | `models.go`
`repository.go` | `ctx.Settings()` / `contracts.ConfigService`
Redis Pub/Sub 广播 | +| `w_message_channels`
`w_message_bindings`
`w_message_pairing_codes`
`w_push_events`
`w_push_channels`
`w_push_histories` | `plugins/domain/message_gateway` | `models.go`
`repository.go` | `EventBus` 强类型事件广播订阅 | +| `w_user_access_logs` (分析库/日志库) | `plugins/domain/risk_control` | 委托 `pkg/persistence/logstore` | `logstore.UserAccessLog` 抽象门面 | +| `w_task_executions`
`w_schedules` | `pkg/task` & `plugins/drivers` | `pkg/task/types.go` | `ctx.Task()` 与 `ctx.Schedule()` 扩展点 | + +### 5.3 架构防线与单向依赖保障 +1. **测试脚手架绝对解耦**:底层通用的 `pkg/testhelper` 严禁反向引用任何上层业务插件。`testhelper` 维护轻量自包含的测试表脚手架,彻底杜绝包导入循环(Import Cycle)。 +2. **Pub/Sub 并发安全防线**:在启动 Redis Pub/Sub 监听协程前,严格捕获局部客户端实例,彻底消除测试或重启期间对可变全局客户端的数据竞争(Data Race Free)。 +3. **零旁路读写 (No Bypass)**:严禁插件 A 跨界旁路直接操作属于插件 B 的数据表,跨域调用一律面向 `core/contracts` 契约编程或发布事件。 + + diff --git a/docs/docs.go b/docs/docs.go index 3157b639..7aebe93a 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -57,7 +57,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.ChallengeResponse" + "$ref": "#/definitions/github_com_Rain-kl_Wavelet_plugins_domain_cap.ChallengeResponse" } } } @@ -109,7 +109,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" + "$ref": "#/definitions/github_com_Rain-kl_Wavelet_plugins_domain_cap.RedeemResponse" } } } @@ -131,386 +131,6 @@ const docTemplate = `{ } } }, - "/api/health": { - "get": { - "description": "检查服务是否正常运行,可用于负载均衡存活探测", - "produces": [ - "application/json" - ], - "tags": [ - "health" - ], - "summary": "健康检查", - "responses": { - "200": { - "description": "服务正常", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/auth-sources": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取认证源列表", - "responses": { - "200": { - "description": "认证源列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.AuthSource" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "创建认证源", - "parameters": [ - { - "description": "创建认证源参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth_source.AuthSourceRequest" - } - } - ], - "responses": { - "200": { - "description": "创建成功,返回认证源信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.AuthSource" - } - } - } - ] - } - }, - "400": { - "description": "参数错误或验证失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/auth-sources/{id}": { - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更新指定 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 - }, - { - "description": "更新认证源参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth_source.AuthSourceRequest" - } - } - ], - "responses": { - "200": { - "description": "更新成功,返回更新后的认证源信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.AuthSource" - } - } - } - ] - } - }, - "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": [ - { - "$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" - } - }, - "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 - }, - { - "description": "启用状态", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth_source.ToggleAuthSourceRequest" - } - } - ], - "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" - } - } - } - } - }, "/api/v1/admin/cache/clear": { "post": { "security": [ @@ -579,7 +199,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/cache.updateCacheConfigRequest" + "$ref": "#/definitions/admin.updateCacheConfigRequest" } } ], @@ -742,7 +362,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/status.DatabaseInfoResponse" + "$ref": "#/definitions/admin.DatabaseInfoResponse" } } } @@ -791,7 +411,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/db_manage.DBOverviewResponse" + "$ref": "#/definitions/admin.DBOverviewResponse" } } } @@ -844,7 +464,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/db_manage.ExecuteSQLRequest" + "$ref": "#/definitions/admin.ExecuteSQLRequest" } } ], @@ -860,7 +480,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/db_manage.ExecuteSQLResponse" + "$ref": "#/definitions/admin.ExecuteSQLResponse" } } } @@ -995,7 +615,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/logs.logsResponse" + "$ref": "#/definitions/admin.logsResponse" } } } @@ -1084,7 +704,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/logs.accessLogsResponse" + "$ref": "#/definitions/admin.accessLogsResponse" } } } @@ -1145,7 +765,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/logs.logsAnalyticsResponse" + "$ref": "#/definitions/admin.logsAnalyticsResponse" } } } @@ -1472,657 +1092,6 @@ 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/push.CreateChannelRequest" - } - } - ], - "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": "返回系统支持的所有消息通道类型(如飞书、邮件、自定义、Telegram)的动态表单定义,需要管理员权限", - "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/push.Definition" - } - } - } - } - ] - } - } - } - } - }, - "/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/push.TestChannelRequest" - } - } - ], - "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/push.UpdateChannelRequest" - } - } - ], - "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/push.CreateEventRequest" - } - } - ], - "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": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/push.EventMetadata" - } - } - } - } - ] - } - } - } - } - }, - "/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/push.UpdateEventRequest" - } - } - ], - "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": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/push.pushHistoriesResponse" - } - } - } - ] - } - } - } - } - }, - "/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/push.TestPushRequest" - } - } - ], - "responses": { - "200": { - "description": "测试成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - } - }, "/api/v1/admin/status": { "get": { "security": [ @@ -2150,7 +1119,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/status.SystemStatusResponse" + "$ref": "#/definitions/admin.SystemStatusResponse" } } } @@ -2199,7 +1168,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/status.LogDatabaseStatus" + "$ref": "#/definitions/admin.LogDatabaseStatus" } } } @@ -2264,7 +1233,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.SystemConfig" + "$ref": "#/definitions/admin.SystemConfig" } } } @@ -2316,7 +1285,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/system_config.CreateSystemConfigRequest" + "$ref": "#/definitions/admin.CreateSystemConfigRequest" } } ], @@ -2391,7 +1360,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/system_config.TestSMTPRequest" + "$ref": "#/definitions/admin.TestSMTPRequest" } } ], @@ -2407,7 +1376,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/system_config.TestSMTPResponse" + "$ref": "#/definitions/admin.TestSMTPResponse" } } } @@ -2459,7 +1428,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.SystemConfig" + "$ref": "#/definitions/admin.SystemConfig" } } } @@ -2523,7 +1492,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/system_config.UpdateSystemConfigRequest" + "$ref": "#/definitions/admin.UpdateSystemConfigRequest" } } ], @@ -2604,7 +1573,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/task.DispatchTaskRequest" + "$ref": "#/definitions/admin.DispatchTaskRequest" } } ], @@ -2767,7 +1736,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.TaskExecution" + "$ref": "#/definitions/admin.TaskExecution" } } } @@ -2906,7 +1875,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.Schedule" + "$ref": "#/definitions/admin.Schedule" } } } @@ -2952,7 +1921,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/task.CreateScheduleRequest" + "$ref": "#/definitions/admin.CreateScheduleRequest" } } ], @@ -2968,7 +1937,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Schedule" + "$ref": "#/definitions/admin.Schedule" } } } @@ -3034,7 +2003,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/task.UpdateScheduleRequest" + "$ref": "#/definitions/admin.UpdateScheduleRequest" } } ], @@ -3050,7 +2019,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Schedule" + "$ref": "#/definitions/admin.Schedule" } } } @@ -3239,7 +2208,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.Template" + "$ref": "#/definitions/admin.Template" } } } @@ -3291,7 +2260,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/template.CreateTemplateRequest" + "$ref": "#/definitions/admin.CreateTemplateRequest" } } ], @@ -3377,7 +2346,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Template" + "$ref": "#/definitions/admin.Template" } } } @@ -3441,7 +2410,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/template.UpdateTemplateRequest" + "$ref": "#/definitions/admin.UpdateTemplateRequest" } } ], @@ -3457,7 +2426,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Template" + "$ref": "#/definitions/admin.Template" } } } @@ -3598,7 +2567,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/updater.Status" + "$ref": "#/definitions/admin.UpdaterStatus" } } } @@ -3675,94 +2644,6 @@ 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", - "format": "int64", - "description": "上传用户 ID", - "name": "user_id", - "in": "query" - } - ], - "responses": { - "200": { - "description": "查询成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/handler.listFilesResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, "/api/v1/admin/uploads/download/batch": { "post": { "security": [ @@ -3872,6 +2753,127 @@ const docTemplate = `{ } } }, + "/api/v1/admin/uploads/files": { + "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/files/{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/uploads/stats": { "get": { "security": [ @@ -3934,17 +2936,17 @@ const docTemplate = `{ "SessionCookie": [] } ], - "description": "返回数据库中所有已上传文件实际拥有的业务类型列表", + "description": "查询系统内所有不重复的上传业务分类标识(如 avatar, doc 等)", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "获取文件业务类型列表", + "summary": "获取业务分类列表", "responses": { "200": { - "description": "业务类型列表", + "description": "查询成功", "schema": { "allOf": [ { @@ -3963,70 +2965,6 @@ const docTemplate = `{ } ] } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/uploads/{id}": { - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将文件状态置为 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" - } - }, - "403": { - "description": "无权操作", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "文件不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } } } } @@ -4088,7 +3026,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/user.listUsersResponse" + "$ref": "#/definitions/admin.listUsersResponse" } } } @@ -4145,7 +3083,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/user.createUserRequest" + "$ref": "#/definitions/admin.createUserRequest" } } ], @@ -4161,7 +3099,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/user.user" + "$ref": "#/definitions/admin.userResponse" } } } @@ -4231,7 +3169,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/user.user" + "$ref": "#/definitions/admin.userResponse" } } } @@ -4301,7 +3239,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/user.updateUserRequest" + "$ref": "#/definitions/admin.updateUserRequest" } } ], @@ -4463,7 +3401,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/user.updateUserStatusRequest" + "$ref": "#/definitions/admin.updateUserStatusRequest" } } ], @@ -4542,38 +3480,6 @@ const docTemplate = `{ } } }, - "/api/v1/custom/hello": { - "get": { - "description": "Scaffold demo API; product APIs use semantic paths under apps/\u003cdomain\u003e", - "produces": [ - "application/json" - ], - "tags": [ - "custom" - ], - "summary": "Sample Hello API", - "responses": { - "200": { - "description": "成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - } - }, "/api/v1/message-gateway/bindings": { "get": { "security": [ @@ -4773,413 +3679,6 @@ 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/oauth.CallbackRequest" - } - } - ], - "responses": { - "200": { - "description": "登录或绑定成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.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": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.ExternalAccountView" - } - } - } - } - ] - } - }, - "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/oauth.OAuthAuthorizeResponse" - } - } - } - ] - } - }, - "400": { - "description": "认证源不存在或未配置", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "Redis 异常 or 构造 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/oauth.AuthSourceView" - } - } - } - } - ] - } - } - } - } - }, - "/api/v1/oauth/user-info": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取当前登录用户信息", - "responses": { - "200": { - "description": "用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.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/oauth.OAuthAuthorizeResponse" - } - } - } - ] - } - }, - "400": { - "description": "认证源不存在或未启用", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "Redis 异常或构造 URL 失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, "/api/v1/upload": { "post": { "security": [ @@ -5231,7 +3730,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Upload" + "$ref": "#/definitions/models.Upload" } } } @@ -5382,7 +3881,7 @@ const docTemplate = `{ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Upload" + "$ref": "#/definitions/models.Upload" } } } @@ -5448,612 +3947,6 @@ const docTemplate = `{ } } }, - "/api/v1/user-info": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取当前登录用户信息", - "responses": { - "200": { - "description": "用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.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/model.AccessToken" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。可通过 is_admin 字段赋予令牌管理员权限(仅管理员用户可设置)。", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "创建一个新的 AccessToken", - "parameters": [ - { - "description": "令牌名称", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.createTokenRequest" - } - } - ], - "responses": { - "200": { - "description": "新建令牌成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/user.tokenResponse" - } - } - } - ] - } - }, - "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": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/user.tokenResponse" - } - } - } - ] - } - }, - "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": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "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": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "400": { - "description": "邮箱已被占用或参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/register": { - "post": { - "description": "使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。", - "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": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "400": { - "description": "参数错误、用户名已存在或注册已关闭", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "服务内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/self": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取当前登录用户信息", - "responses": { - "200": { - "description": "用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/send-email-code": { - "post": { - "description": "向指定邮箱发送验证码(用于注册场景)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "发送邮箱验证码", - "parameters": [ - { - "description": "发送验证码请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.sendEmailCodeRequest" - } - } - ], - "responses": { - "200": { - "description": "发送成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, "/f/{id}": { "get": { "description": "根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回", @@ -6135,19 +4028,16 @@ const docTemplate = `{ } }, "definitions": { - "auth_source.AuthSourceRequest": { + "admin.CreateScheduleRequest": { "type": "object", + "required": [ + "cron", + "is_active", + "name", + "task_type" + ], "properties": { - "client_id": { - "type": "string" - }, - "client_secret": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "icon_url": { + "cron": { "type": "string" }, "is_active": { @@ -6156,75 +4046,84 @@ const docTemplate = `{ "name": { "type": "string" }, - "openid_discovery_url": { + "payload": { "type": "string" }, - "scopes": { + "task_type": { "type": "string" + } + } + }, + "admin.CreateSystemConfigRequest": { + "type": "object", + "required": [ + "key", + "type", + "value" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255 + }, + "key": { + "type": "string", + "maxLength": 64 }, "type": { + "type": "string", + "enum": [ + "system", + "business" + ] + }, + "value": { "type": "string" + }, + "visibility": { + "type": "integer", + "enum": [ + 0, + 1 + ] } } }, - "auth_source.ToggleAuthSourceRequest": { - "type": "object", - "properties": { - "is_active": { - "type": "boolean" - } - } - }, - "cache.updateCacheConfigRequest": { + "admin.CreateTemplateRequest": { "type": "object", "required": [ - "max_size_mb", - "ttl_minutes" + "content", + "key", + "name", + "type" ], "properties": { - "lru_enabled": { - "type": "boolean" + "content": { + "type": "string" }, - "max_size_mb": { - "type": "integer", - "minimum": 1 + "description": { + "type": "string", + "maxLength": 255 }, - "ttl_minutes": { - "type": "integer", - "minimum": 0 + "key": { + "type": "string", + "maxLength": 80 + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "subject": { + "type": "string", + "maxLength": 255 + }, + "type": { + "type": "string", + "maxLength": 20 } } }, - "cap.challengeRequest": { - "type": "object", - "properties": { - "scope": { - "type": "string" - } - } - }, - "cap.redeemRequest": { - "type": "object", - "required": [ - "solutions", - "token" - ], - "properties": { - "scope": { - "type": "string" - }, - "solutions": { - "type": "array", - "items": { - "type": "integer" - } - }, - "token": { - "type": "string" - } - } - }, - "db_manage.DBOverviewResponse": { + "admin.DBOverviewResponse": { "type": "object", "properties": { "connections": { @@ -6247,7 +4146,44 @@ const docTemplate = `{ } } }, - "db_manage.ExecuteSQLRequest": { + "admin.DatabaseInfoResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "admin.DispatchTaskRequest": { + "type": "object", + "required": [ + "task_type" + ], + "properties": { + "end_time": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "start_time": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "admin.ExecuteSQLRequest": { "type": "object", "required": [ "sql" @@ -6258,7 +4194,7 @@ const docTemplate = `{ } } }, - "db_manage.ExecuteSQLResponse": { + "admin.ExecuteSQLResponse": { "type": "object", "properties": { "affected_rows": { @@ -6286,763 +4222,30 @@ const docTemplate = `{ } } }, - "diskcache.Status": { + "admin.LogDatabaseStatus": { "type": "object", "properties": { - "base_path": { + "active_database": { "type": "string" }, - "keys_count": { - "type": "integer" + "available_targets": { + "type": "array", + "items": { + "type": "string" + } }, - "lru_enabled": { - "type": "boolean" + "migration": { + "type": "string" }, - "max_size_mb": { - "type": "integer" - }, - "total_size": { - "type": "integer" - }, - "ttl_minutes": { - "type": "integer" - } - } - }, - "github_com_Rain-kl_Wavelet_internal_apps_cap.ChallengeResponse": { - "type": "object", - "properties": { - "challenge": { + "retention_days": { "type": "object", - "properties": { - "c": { - "type": "integer" - }, - "d": { - "type": "integer" - }, - "s": { - "type": "integer" - } - } - }, - "expires": { - "description": "ms timestamp", - "type": "integer" - }, - "token": { - "type": "string" - } - } - }, - "github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "expires": { - "type": "integer" - }, - "success": { - "type": "boolean" - }, - "token": { - "type": "string" - } - } - }, - "handler.batchDownloadRequest": { - "type": "object", - "required": [ - "ids" - ], - "properties": { - "ids": { - "type": "array", - "minItems": 1, - "items": { - "type": "string" + "additionalProperties": { + "type": "integer" } } } }, - "handler.distributionItem": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "size": { - "type": "integer" - } - } - }, - "handler.fileStatsResponse": { - "type": "object", - "properties": { - "categories": { - "type": "array", - "items": { - "$ref": "#/definitions/handler.distributionItem" - } - }, - "total_count": { - "type": "integer" - }, - "total_size": { - "type": "integer" - }, - "trend": { - "type": "array", - "items": { - "$ref": "#/definitions/handler.trendItem" - } - }, - "types": { - "type": "array", - "items": { - "$ref": "#/definitions/handler.distributionItem" - } - } - } - }, - "handler.listFilesResponse": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Upload" - } - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - }, - "handler.listMyFilesResponse": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Upload" - } - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - }, - "handler.trendItem": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "date": { - "type": "string" - }, - "size": { - "type": "integer" - } - } - }, - "handler.updateMyFileRequest": { - "type": "object", - "properties": { - "access_mode": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "file_name": { - "type": "string", - "maxLength": 255 - } - } - }, - "logger.LogEntry": { - "type": "object", - "properties": { - "data": { - "description": "一行日志原文(含换行符)", - "type": "string" - }, - "index": { - "description": "全局递增序号", - "type": "integer" - } - } - }, - "logs.accessLogItem": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "headers": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "ip": { - "type": "string" - }, - "latency": { - "type": "integer" - }, - "method": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "path": { - "type": "string" - }, - "status": { - "type": "integer" - }, - "user_agent": { - "type": "string" - }, - "user_id": { - "type": "string", - "example": "0" - }, - "username": { - "type": "string" - } - } - }, - "logs.accessLogsResponse": { - "type": "object", - "properties": { - "list": { - "type": "array", - "items": { - "$ref": "#/definitions/logs.accessLogItem" - } - }, - "total": { - "type": "integer" - } - } - }, - "logs.browserItem": { - "type": "object", - "properties": { - "browser": { - "type": "string" - }, - "count": { - "type": "integer" - } - } - }, - "logs.logsAnalyticsResponse": { - "type": "object", - "properties": { - "browsers": { - "type": "array", - "items": { - "$ref": "#/definitions/logs.browserItem" - } - }, - "top_users": { - "type": "array", - "items": { - "$ref": "#/definitions/logs.topUserItem" - } - }, - "trend": { - "type": "array", - "items": { - "$ref": "#/definitions/logs.trendItem" - } - } - } - }, - "logs.logsResponse": { - "type": "object", - "properties": { - "has_more": { - "type": "boolean" - }, - "lines": { - "type": "array", - "items": { - "$ref": "#/definitions/logger.LogEntry" - } - }, - "next_cursor": { - "description": "用于加载更早日志的 cursor", - "type": "integer" - } - } - }, - "logs.topUserItem": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "nickname": { - "type": "string" - }, - "user_id": { - "type": "string", - "example": "0" - }, - "username": { - "type": "string" - } - } - }, - "logs.trendItem": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "date": { - "type": "string" - } - } - }, - "message_gateway.BindRequest": { - "type": "object", - "properties": { - "channel_id": { - "type": "string" - }, - "code": { - "type": "string" - } - } - }, - "message_gateway.BindingDTO": { - "type": "object", - "properties": { - "channel_id": { - "type": "string", - "example": "0" - }, - "channel_name": { - "type": "string" - }, - "channel_type": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "platform_user_id": { - "type": "string" - }, - "user_id": { - "type": "string", - "example": "0" - } - } - }, - "message_gateway.ChannelDTO": { - "type": "object", - "properties": { - "app_id": { - "type": "string" - }, - "app_secret": { - "type": "string" - }, - "base_url": { - "type": "string" - }, - "bot_token": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "id": { - "type": "string", - "example": "0" - }, - "name": { - "type": "string" - }, - "owner_scope": { - "type": "string" - }, - "portal_host": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "message_gateway.CreateChannelRequest": { - "type": "object", - "properties": { - "app_id": { - "type": "string" - }, - "app_secret": { - "type": "string" - }, - "base_url": { - "type": "string" - }, - "bot_token": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "portal_host": { - "type": "string" - }, - "sandbox": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "message_gateway.Definition": { - "type": "object", - "properties": { - "fields": { - "type": "array", - "items": { - "$ref": "#/definitions/message_gateway.Field" - } - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "message_gateway.Field": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "required": { - "type": "boolean" - }, - "type": { - "type": "string" - } - } - }, - "message_gateway.PublicChannelDTO": { - "type": "object", - "properties": { - "id": { - "type": "string", - "example": "0" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "message_gateway.UpdateChannelRequest": { - "type": "object", - "properties": { - "app_id": { - "type": "string" - }, - "app_secret": { - "type": "string" - }, - "base_url": { - "type": "string" - }, - "bot_token": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "portal_host": { - "type": "string" - }, - "sandbox": { - "type": "string" - } - } - }, - "model.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" - } - } - }, - "model.AuthSource": { - "type": "object", - "properties": { - "client_id": { - "type": "string" - }, - "client_secret_configured": { - "type": "boolean" - }, - "created_at": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "icon_url": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "openid_discovery_url": { - "type": "string" - }, - "scopes": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.ExternalAccountView": { - "type": "object", - "properties": { - "auth_source_id": { - "type": "integer" - }, - "auth_source_label": { - "type": "string" - }, - "auth_source_name": { - "type": "string" - }, - "auth_source_type": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "email": { - "type": "string" - }, - "external_username": { - "type": "string" - }, - "id": { - "type": "integer" - } - } - }, - "model.PushChannel": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "description": { - "description": "备注", - "type": "string" - }, - "enabled": { - "description": "通道是否启用", - "type": "boolean" - }, - "id": { - "type": "integer" - }, - "name": { - "description": "通道名称,仅英文字母和下划线,唯一", - "type": "string" - }, - "other": { - "description": "请求体/SMTP 密码等", - "type": "string" - }, - "token": { - "description": "鉴权令牌或发信用户名等", - "type": "string" - }, - "type": { - "description": "通道类型:custom, lark, email", - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "url": { - "description": "请求地址,HTTPS 协议或 SMTP 地址", - "type": "string" - } - } - }, - "model.PushEvent": { - "type": "object", - "properties": { - "channels": { - "description": "推送渠道列表,如 [\"lark\"]", - "type": "array", - "items": { - "type": "string" - } - }, - "created_at": { - "type": "string" - }, - "enabled": { - "description": "是否启用", - "type": "boolean" - }, - "event_key": { - "description": "如 admin_login", - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "description": "如 管理员登录", - "type": "string" - }, - "targets": { - "description": "推送目标用户/邮箱列表", - "type": "array", - "items": { - "type": "string" - } - }, - "task_type": { - "description": "关联的异步任务类型", - "type": "string" - }, - "template": { - "description": "消息模板 JSON", - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.PushHistory": { - "type": "object", - "properties": { - "channel": { - "type": "string" - }, - "content": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "error_msg": { - "type": "string" - }, - "event_key": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "level": { - "type": "string" - }, - "status": { - "description": "success / failed", - "type": "string" - }, - "target": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "model.Schedule": { + "admin.Schedule": { "type": "object", "properties": { "created_at": { @@ -7072,7 +4275,7 @@ const docTemplate = `{ } } }, - "model.SystemConfig": { + "admin.SystemConfig": { "type": "object", "properties": { "created_at": { @@ -7098,669 +4301,7 @@ const docTemplate = `{ } } }, - "model.TaskExecution": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "error_message": { - "type": "string" - }, - "finished_at": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "log": { - "type": "string" - }, - "max_retry": { - "type": "integer" - }, - "payload": { - "type": "string" - }, - "result": { - "type": "string" - }, - "retry_count": { - "type": "integer" - }, - "retryable": { - "type": "boolean" - }, - "started_at": { - "type": "string" - }, - "status": { - "$ref": "#/definitions/model.TaskExecutionStatus" - }, - "task_id": { - "type": "string" - }, - "task_name": { - "type": "string" - }, - "task_type": { - "type": "string" - }, - "triggered_by": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.TaskExecutionStatus": { - "type": "string", - "enum": [ - "pending", - "running", - "succeeded", - "failed" - ], - "x-enum-varnames": [ - "TaskExecutionStatusPending", - "TaskExecutionStatusRunning", - "TaskExecutionStatusSucceeded", - "TaskExecutionStatusFailed" - ] - }, - "model.Template": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "description": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_system": { - "type": "boolean" - }, - "key": { - "type": "string" - }, - "name": { - "type": "string" - }, - "subject": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.Upload": { - "type": "object", - "properties": { - "access_mode": { - "type": "integer" - }, - "created_at": { - "type": "string" - }, - "extension": { - "description": "文件后缀名 (不含点,如 png, pdf)", - "type": "string" - }, - "file_name": { - "description": "原始文件名 (例如: image.png)", - "type": "string" - }, - "file_path": { - "description": "文件相对路径 / S3 Key", - "type": "string" - }, - "file_size": { - "description": "文件大小(字节)", - "type": "integer" - }, - "hash": { - "description": "文件哈希 (SHA-256/MD5,可用于排重)", - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "metadata": { - "description": "业务扩展元数据", - "allOf": [ - { - "$ref": "#/definitions/model.UploadMetadata" - } - ] - }, - "mime_type": { - "description": "媒体类型 (MIME, 如 image/png)", - "type": "string" - }, - "status": { - "description": "状态", - "allOf": [ - { - "$ref": "#/definitions/model.UploadStatus" - } - ] - }, - "type": { - "description": "业务标识类型 (如 avatar, doc, attachment)", - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "user_id": { - "type": "string", - "example": "0" - } - } - }, - "model.UploadMetadata": { - "type": "object", - "properties": { - "bucket": { - "description": "存储桶名称 (适用于 S3 等)", - "type": "string" - }, - "client_ip": { - "description": "上传者 IP", - "type": "string" - }, - "duration": { - "description": "音视频时长 (s)", - "type": "number" - }, - "extra": { - "description": "其它任意业务自定义元数据", - "type": "object", - "additionalProperties": {} - }, - "height": { - "description": "图像/视频高度 (px)", - "type": "integer" - }, - "original_mime": { - "description": "原始 MIME 类型", - "type": "string" - }, - "user_agent": { - "description": "上传者的 UA", - "type": "string" - }, - "width": { - "description": "图像/视频宽度 (px)", - "type": "integer" - } - } - }, - "model.UploadStatus": { - "type": "string", - "enum": [ - "pending", - "used", - "deleted" - ], - "x-enum-comments": { - "UploadStatusDeleted": "已删除", - "UploadStatusPending": "待使用", - "UploadStatusUsed": "已使用" - }, - "x-enum-descriptions": [ - "待使用", - "已使用", - "已删除" - ], - "x-enum-varnames": [ - "UploadStatusPending", - "UploadStatusUsed", - "UploadStatusDeleted" - ] - }, - "oauth.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" - } - } - }, - "oauth.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" - } - } - }, - "oauth.CallbackRequest": { - "type": "object", - "required": [ - "code", - "state" - ], - "properties": { - "code": { - "type": "string" - }, - "state": { - "type": "string" - } - } - }, - "oauth.OAuthAuthorizeResponse": { - "type": "object", - "properties": { - "authorize_url": { - "type": "string" - } - } - }, - "oauth.OAuthCallbackResult": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "user": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - }, - "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" - } - } - }, - "push.CreateChannelRequest": { - "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" - } - } - }, - "push.CreateEventRequest": { - "type": "object", - "properties": { - "channels": { - "type": "array", - "items": { - "type": "string" - } - }, - "enabled": { - "type": "boolean" - }, - "event_key": { - "type": "string" - }, - "targets": { - "type": "array", - "items": { - "type": "string" - } - }, - "task_type": { - "description": "关联的异步任务类型", - "type": "string" - }, - "template": { - "type": "string" - } - } - }, - "push.Definition": { - "type": "object", - "properties": { - "description": { - "description": "short description", - "type": "string" - }, - "fields": { - "description": "form fields", - "type": "array", - "items": { - "$ref": "#/definitions/push.Field" - } - }, - "name": { - "description": "display name", - "type": "string" - }, - "type": { - "description": "channel type (e.g., custom, lark, email)", - "type": "string" - } - } - }, - "push.EventMetadata": { - "type": "object", - "properties": { - "default_template": { - "$ref": "#/definitions/push.NotificationMessage" - }, - "description": { - "type": "string" - }, - "key": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "push.Field": { - "type": "object", - "properties": { - "description": { - "description": "field explanation/help text", - "type": "string" - }, - "key": { - "description": "unique key for the field (e.g. url, token, other)", - "type": "string" - }, - "label": { - "description": "human readable label (e.g. \"Webhook 地址\")", - "type": "string" - }, - "placeholder": { - "description": "input placeholder", - "type": "string" - }, - "required": { - "description": "whether this field is required", - "type": "boolean" - }, - "type": { - "description": "input type: \"text\" | \"password\" | \"textarea\"", - "type": "string" - } - } - }, - "push.NotificationMessage": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "ext": { - "type": "object", - "additionalProperties": {} - }, - "level": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "push.TestChannelRequest": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "other": { - "type": "string" - }, - "target": { - "type": "string" - }, - "token": { - "type": "string" - }, - "type": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "push.TestPushRequest": { - "type": "object", - "required": [ - "config" - ], - "properties": { - "config": { - "$ref": "#/definitions/push.Config" - }, - "target": { - "type": "string" - } - } - }, - "push.UpdateChannelRequest": { - "type": "object", - "required": [ - "type" - ], - "properties": { - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "other": { - "type": "string" - }, - "token": { - "type": "string" - }, - "type": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "push.UpdateEventRequest": { - "type": "object", - "required": [ - "template" - ], - "properties": { - "channels": { - "type": "array", - "items": { - "type": "string" - } - }, - "enabled": { - "type": "boolean" - }, - "targets": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "string" - } - } - }, - "push.pushHistoriesResponse": { - "type": "object", - "properties": { - "results": { - "type": "array", - "items": { - "$ref": "#/definitions/model.PushHistory" - } - }, - "total": { - "type": "integer" - } - } - }, - "response.Any": { - "type": "object", - "properties": { - "data": {}, - "error_msg": { - "type": "string", - "example": "" - } - } - }, - "status.DatabaseInfoResponse": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "type": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "status.LogDatabaseStatus": { - "type": "object", - "properties": { - "active_database": { - "type": "string" - }, - "available_targets": { - "type": "array", - "items": { - "type": "string" - } - }, - "migration": { - "type": "string" - }, - "retention_days": { - "type": "object", - "additionalProperties": { - "type": "integer" - } - } - } - }, - "status.SystemStatusResponse": { + "admin.SystemStatusResponse": { "type": "object", "properties": { "alloc": { @@ -7849,42 +4390,117 @@ const docTemplate = `{ } } }, - "system_config.CreateSystemConfigRequest": { + "admin.TaskExecution": { "type": "object", - "required": [ - "key", - "type", - "value" - ], "properties": { - "description": { - "type": "string", - "maxLength": 255 - }, - "key": { - "type": "string", - "maxLength": 64 - }, - "type": { - "type": "string", - "enum": [ - "system", - "business" - ] - }, - "value": { + "created_at": { "type": "string" }, - "visibility": { - "type": "integer", - "enum": [ - 0, - 1 - ] + "duration": { + "type": "integer" + }, + "error_message": { + "type": "string" + }, + "finished_at": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "log": { + "type": "string" + }, + "max_retry": { + "type": "integer" + }, + "payload": { + "type": "string" + }, + "result": { + "type": "string" + }, + "retry_count": { + "type": "integer" + }, + "retryable": { + "type": "boolean" + }, + "started_at": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/admin.TaskExecutionStatus" + }, + "task_id": { + "type": "string" + }, + "task_name": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "triggered_by": { + "type": "string" + }, + "updated_at": { + "type": "string" } } }, - "system_config.TestSMTPRequest": { + "admin.TaskExecutionStatus": { + "type": "string", + "enum": [ + "pending", + "running", + "succeeded", + "failed" + ], + "x-enum-varnames": [ + "TaskExecutionStatusPending", + "TaskExecutionStatusRunning", + "TaskExecutionStatusSucceeded", + "TaskExecutionStatusFailed" + ] + }, + "admin.Template": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_system": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "admin.TestSMTPRequest": { "type": "object", "required": [ "smtp_host", @@ -7914,7 +4530,7 @@ const docTemplate = `{ } } }, - "system_config.TestSMTPResponse": { + "admin.TestSMTPResponse": { "type": "object", "properties": { "error": { @@ -7928,29 +4544,7 @@ const docTemplate = `{ } } }, - "system_config.UpdateSystemConfigRequest": { - "type": "object", - "required": [ - "value" - ], - "properties": { - "description": { - "type": "string", - "maxLength": 255 - }, - "value": { - "type": "string" - }, - "visibility": { - "type": "integer", - "enum": [ - 0, - 1 - ] - } - } - }, - "task.CreateScheduleRequest": { + "admin.UpdateScheduleRequest": { "type": "object", "required": [ "cron", @@ -7976,29 +4570,907 @@ const docTemplate = `{ } } }, - "task.DispatchTaskRequest": { + "admin.UpdateSystemConfigRequest": { "type": "object", "required": [ - "task_type" + "value" ], "properties": { - "end_time": { + "description": { + "type": "string", + "maxLength": 255 + }, + "value": { "type": "string" }, - "payload": { + "visibility": { + "type": "integer", + "enum": [ + 0, + 1 + ] + } + } + }, + "admin.UpdateTemplateRequest": { + "type": "object", + "required": [ + "content", + "name", + "type" + ], + "properties": { + "content": { "type": "string" }, - "start_time": { + "description": { + "type": "string", + "maxLength": 255 + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "subject": { + "type": "string", + "maxLength": 255 + }, + "type": { + "type": "string", + "maxLength": 20 + } + } + }, + "admin.UpdaterStatus": { + "type": "object", + "properties": { + "asset_name": { "type": "string" }, - "task_type": { + "build_time": { + "type": "string" + }, + "can_upgrade": { + "type": "boolean" + }, + "current_version": { + "type": "string" + }, + "latest_version": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "prerelease": { + "type": "boolean" + }, + "published_at": { + "type": "string" + }, + "release_name": { + "type": "string" + }, + "release_notes": { + "type": "string" + }, + "release_url": { + "type": "string" + }, + "update_available": { + "type": "boolean" + }, + "upstream_repository": { + "type": "string" + } + } + }, + "admin.accessLogItem": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "headers": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "ip": { + "type": "string" + }, + "latency": { + "type": "integer" + }, + "method": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "path": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "user_agent": { "type": "string" }, "user_id": { + "type": "string", + "example": "0" + }, + "username": { + "type": "string" + } + } + }, + "admin.accessLogsResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.accessLogItem" + } + }, + "total": { "type": "integer" } } }, + "admin.browserItem": { + "type": "object", + "properties": { + "browser": { + "type": "string" + }, + "count": { + "type": "integer" + } + } + }, + "admin.createUserRequest": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "email": { + "type": "string", + "maxLength": 255 + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "nickname": { + "type": "string", + "maxLength": 64 + }, + "password": { + "type": "string", + "maxLength": 64, + "minLength": 8 + }, + "username": { + "type": "string", + "maxLength": 64, + "minLength": 3 + } + } + }, + "admin.listUsersResponse": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.userResponse" + } + } + } + }, + "admin.logsAnalyticsResponse": { + "type": "object", + "properties": { + "browsers": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.browserItem" + } + }, + "top_users": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.topUserItem" + } + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.trendItem" + } + } + } + }, + "admin.logsResponse": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/definitions/logger.LogEntry" + } + }, + "next_cursor": { + "description": "用于加载更早日志的 cursor", + "type": "integer" + } + } + }, + "admin.topUserItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "nickname": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + }, + "username": { + "type": "string" + } + } + }, + "admin.trendItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "string" + } + } + }, + "admin.updateCacheConfigRequest": { + "type": "object", + "required": [ + "max_size_mb", + "ttl_minutes" + ], + "properties": { + "lru_enabled": { + "type": "boolean" + }, + "max_size_mb": { + "type": "integer", + "minimum": 1 + }, + "ttl_minutes": { + "type": "integer", + "minimum": 0 + } + } + }, + "admin.updateUserRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "maxLength": 255 + }, + "is_admin": { + "type": "boolean" + }, + "nickname": { + "type": "string", + "maxLength": 64 + }, + "password": { + "type": "string", + "maxLength": 64, + "minLength": 8 + } + } + }, + "admin.updateUserStatusRequest": { + "type": "object", + "properties": { + "is_active": { + "type": "boolean" + } + } + }, + "admin.userResponse": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "last_login_at": { + "type": "string" + }, + "location": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "cap.challengeRequest": { + "type": "object", + "properties": { + "scope": { + "type": "string" + } + } + }, + "cap.redeemRequest": { + "type": "object", + "required": [ + "solutions", + "token" + ], + "properties": { + "scope": { + "type": "string" + }, + "solutions": { + "type": "array", + "items": { + "type": "integer" + } + }, + "token": { + "type": "string" + } + } + }, + "diskcache.Status": { + "type": "object", + "properties": { + "base_path": { + "type": "string" + }, + "keys_count": { + "type": "integer" + }, + "lru_enabled": { + "type": "boolean" + }, + "max_size_mb": { + "type": "integer" + }, + "total_size": { + "type": "integer" + }, + "ttl_minutes": { + "type": "integer" + } + } + }, + "github_com_Rain-kl_Wavelet_plugins_domain_cap.ChallengeResponse": { + "type": "object", + "properties": { + "challenge": { + "type": "object", + "properties": { + "c": { + "type": "integer" + }, + "d": { + "type": "integer" + }, + "s": { + "type": "integer" + } + } + }, + "expires": { + "description": "ms timestamp", + "type": "integer" + }, + "token": { + "type": "string" + } + } + }, + "github_com_Rain-kl_Wavelet_plugins_domain_cap.RedeemResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "expires": { + "type": "integer" + }, + "success": { + "type": "boolean" + }, + "token": { + "type": "string" + } + } + }, + "handler.batchDownloadRequest": { + "type": "object", + "required": [ + "ids" + ], + "properties": { + "ids": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + }, + "handler.distributionItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, + "handler.fileStatsResponse": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.distributionItem" + } + }, + "total_count": { + "type": "integer" + }, + "total_size": { + "type": "integer" + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.trendItem" + } + }, + "types": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.distributionItem" + } + } + } + }, + "handler.listFilesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Upload" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handler.listMyFilesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Upload" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handler.trendItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, + "handler.updateMyFileRequest": { + "type": "object", + "properties": { + "access_mode": { + "type": "integer", + "enum": [ + 0, + 1 + ] + }, + "file_name": { + "type": "string", + "maxLength": 255 + } + } + }, + "logger.LogEntry": { + "type": "object", + "properties": { + "data": { + "description": "一行日志原文(含换行符)", + "type": "string" + }, + "index": { + "description": "全局递增序号", + "type": "integer" + } + } + }, + "message_gateway.BindRequest": { + "type": "object", + "properties": { + "channel_id": { + "type": "string" + }, + "code": { + "type": "string" + } + } + }, + "message_gateway.BindingDTO": { + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "example": "0" + }, + "channel_name": { + "type": "string" + }, + "channel_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "platform_user_id": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + } + } + }, + "message_gateway.ChannelDTO": { + "type": "object", + "properties": { + "credentials": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "extra": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "id": { + "type": "string", + "example": "0" + }, + "name": { + "type": "string" + }, + "owner_id": { + "type": "string", + "example": "0" + }, + "owner_scope": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "message_gateway.CreateChannelRequest": { + "type": "object", + "properties": { + "credentials": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "extra": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "message_gateway.Definition": { + "type": "object", + "properties": { + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/message_gateway.Field" + } + }, + "type": { + "type": "string" + } + } + }, + "message_gateway.Field": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "type": { + "type": "string" + } + } + }, + "message_gateway.PublicChannelDTO": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "0" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "message_gateway.UpdateChannelRequest": { + "type": "object", + "properties": { + "credentials": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "extra": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "name": { + "type": "string" + } + } + }, + "models.Upload": { + "type": "object", + "properties": { + "access_mode": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "extension": { + "type": "string" + }, + "file_name": { + "type": "string" + }, + "file_path": { + "type": "string" + }, + "file_size": { + "type": "integer" + }, + "hash": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "metadata": { + "$ref": "#/definitions/models.UploadMetadata" + }, + "mime_type": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/models.UploadStatus" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + } + } + }, + "models.UploadMetadata": { + "type": "object", + "properties": { + "bucket": { + "type": "string" + }, + "client_ip": { + "type": "string" + }, + "duration": { + "type": "number" + }, + "extra": { + "type": "object", + "additionalProperties": {} + }, + "height": { + "type": "integer" + }, + "original_mime": { + "type": "string" + }, + "user_agent": { + "type": "string" + }, + "width": { + "type": "integer" + } + } + }, + "models.UploadStatus": { + "type": "string", + "enum": [ + "pending", + "used", + "deleted" + ], + "x-enum-comments": { + "UploadStatusDeleted": "已删除", + "UploadStatusPending": "待使用", + "UploadStatusUsed": "已使用" + }, + "x-enum-descriptions": [ + "待使用", + "已使用", + "已删除" + ], + "x-enum-varnames": [ + "UploadStatusPending", + "UploadStatusUsed", + "UploadStatusDeleted" + ] + }, + "response.Any": { + "type": "object", + "properties": { + "data": {}, + "error_msg": { + "type": "string", + "example": "" + } + } + }, "task.TaskMeta": { "type": "object", "properties": { @@ -8063,384 +5535,6 @@ const docTemplate = `{ "type": "string" } } - }, - "task.UpdateScheduleRequest": { - "type": "object", - "required": [ - "cron", - "is_active", - "name", - "task_type" - ], - "properties": { - "cron": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "payload": { - "type": "string" - }, - "task_type": { - "type": "string" - } - } - }, - "template.CreateTemplateRequest": { - "type": "object", - "required": [ - "content", - "key", - "name", - "type" - ], - "properties": { - "content": { - "type": "string" - }, - "description": { - "type": "string", - "maxLength": 255 - }, - "key": { - "type": "string", - "maxLength": 80 - }, - "name": { - "type": "string", - "maxLength": 100 - }, - "subject": { - "type": "string", - "maxLength": 255 - }, - "type": { - "type": "string", - "maxLength": 20 - } - } - }, - "template.UpdateTemplateRequest": { - "type": "object", - "required": [ - "content", - "name", - "type" - ], - "properties": { - "content": { - "type": "string" - }, - "description": { - "type": "string", - "maxLength": 255 - }, - "name": { - "type": "string", - "maxLength": 100 - }, - "subject": { - "type": "string", - "maxLength": 255 - }, - "type": { - "type": "string", - "maxLength": 20 - } - } - }, - "updater.Status": { - "type": "object", - "properties": { - "asset_name": { - "type": "string" - }, - "build_time": { - "type": "string" - }, - "can_upgrade": { - "type": "boolean" - }, - "current_version": { - "type": "string" - }, - "latest_version": { - "type": "string" - }, - "platform": { - "type": "string" - }, - "prerelease": { - "type": "boolean" - }, - "published_at": { - "type": "string" - }, - "release_name": { - "type": "string" - }, - "release_notes": { - "type": "string" - }, - "release_url": { - "type": "string" - }, - "update_available": { - "type": "boolean" - }, - "upstream_repository": { - "type": "string" - } - } - }, - "user.changePasswordRequest": { - "type": "object", - "properties": { - "new_password": { - "type": "string" - }, - "old_password": { - "type": "string" - } - } - }, - "user.createTokenRequest": { - "type": "object", - "properties": { - "is_admin": { - "type": "boolean" - }, - "name": { - "type": "string" - } - } - }, - "user.createUserRequest": { - "type": "object", - "required": [ - "email", - "password", - "username" - ], - "properties": { - "email": { - "type": "string", - "maxLength": 255 - }, - "is_active": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - }, - "nickname": { - "type": "string", - "maxLength": 64 - }, - "password": { - "type": "string", - "maxLength": 64, - "minLength": 8 - }, - "username": { - "type": "string", - "maxLength": 64, - "minLength": 3 - } - } - }, - "user.listUsersResponse": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "users": { - "type": "array", - "items": { - "$ref": "#/definitions/user.user" - } - } - } - }, - "user.loginRequest": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "password": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "user.registerRequest": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "email": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "password": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "user.sendEmailCodeRequest": { - "type": "object", - "required": [ - "email", - "scene" - ], - "properties": { - "email": { - "type": "string" - }, - "scene": { - "type": "string" - } - } - }, - "user.tokenResponse": { - "type": "object", - "properties": { - "record": { - "$ref": "#/definitions/model.AccessToken" - }, - "token": { - "type": "string" - } - } - }, - "user.updateProfileRequest": { - "type": "object", - "properties": { - "avatar_url": { - "type": "string" - }, - "bio": { - "type": "string" - }, - "email": { - "type": "string" - }, - "gender": { - "type": "string" - }, - "location": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "phone": { - "type": "string" - }, - "website": { - "type": "string" - } - } - }, - "user.updateUserRequest": { - "type": "object", - "required": [ - "email" - ], - "properties": { - "email": { - "type": "string", - "maxLength": 255 - }, - "is_admin": { - "type": "boolean" - }, - "nickname": { - "type": "string", - "maxLength": 64 - }, - "password": { - "type": "string", - "maxLength": 64, - "minLength": 8 - } - } - }, - "user.updateUserStatusRequest": { - "type": "object", - "properties": { - "is_active": { - "type": "boolean" - } - } - }, - "user.user": { - "type": "object", - "properties": { - "avatar_url": { - "type": "string" - }, - "bio": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "email": { - "type": "string" - }, - "gender": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "is_active": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - }, - "last_login_at": { - "type": "string" - }, - "location": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "phone": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "username": { - "type": "string" - }, - "website": { - "type": "string" - } - } } }, "securityDefinitions": { diff --git a/docs/swagger.json b/docs/swagger.json index 3c73a47a..58f4df54 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -50,7 +50,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.ChallengeResponse" + "$ref": "#/definitions/github_com_Rain-kl_Wavelet_plugins_domain_cap.ChallengeResponse" } } } @@ -102,7 +102,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse" + "$ref": "#/definitions/github_com_Rain-kl_Wavelet_plugins_domain_cap.RedeemResponse" } } } @@ -124,386 +124,6 @@ } } }, - "/api/health": { - "get": { - "description": "检查服务是否正常运行,可用于负载均衡存活探测", - "produces": [ - "application/json" - ], - "tags": [ - "health" - ], - "summary": "健康检查", - "responses": { - "200": { - "description": "服务正常", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - } - }, - "/api/v1/admin/auth-sources": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "获取认证源列表", - "responses": { - "200": { - "description": "认证源列表", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.AuthSource" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "创建认证源", - "parameters": [ - { - "description": "创建认证源参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth_source.AuthSourceRequest" - } - } - ], - "responses": { - "200": { - "description": "创建成功,返回认证源信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.AuthSource" - } - } - } - ] - } - }, - "400": { - "description": "参数错误或验证失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/auth-sources/{id}": { - "put": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "更新指定 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 - }, - { - "description": "更新认证源参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth_source.AuthSourceRequest" - } - } - ], - "responses": { - "200": { - "description": "更新成功,返回更新后的认证源信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.AuthSource" - } - } - } - ] - } - }, - "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": [ - { - "$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" - } - }, - "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 - }, - { - "description": "启用状态", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth_source.ToggleAuthSourceRequest" - } - } - ], - "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" - } - } - } - } - }, "/api/v1/admin/cache/clear": { "post": { "security": [ @@ -572,7 +192,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/cache.updateCacheConfigRequest" + "$ref": "#/definitions/admin.updateCacheConfigRequest" } } ], @@ -735,7 +355,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/status.DatabaseInfoResponse" + "$ref": "#/definitions/admin.DatabaseInfoResponse" } } } @@ -784,7 +404,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/db_manage.DBOverviewResponse" + "$ref": "#/definitions/admin.DBOverviewResponse" } } } @@ -837,7 +457,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/db_manage.ExecuteSQLRequest" + "$ref": "#/definitions/admin.ExecuteSQLRequest" } } ], @@ -853,7 +473,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/db_manage.ExecuteSQLResponse" + "$ref": "#/definitions/admin.ExecuteSQLResponse" } } } @@ -988,7 +608,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/logs.logsResponse" + "$ref": "#/definitions/admin.logsResponse" } } } @@ -1077,7 +697,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/logs.accessLogsResponse" + "$ref": "#/definitions/admin.accessLogsResponse" } } } @@ -1138,7 +758,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/logs.logsAnalyticsResponse" + "$ref": "#/definitions/admin.logsAnalyticsResponse" } } } @@ -1465,657 +1085,6 @@ } } }, - "/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/push.CreateChannelRequest" - } - } - ], - "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": "返回系统支持的所有消息通道类型(如飞书、邮件、自定义、Telegram)的动态表单定义,需要管理员权限", - "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/push.Definition" - } - } - } - } - ] - } - } - } - } - }, - "/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/push.TestChannelRequest" - } - } - ], - "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/push.UpdateChannelRequest" - } - } - ], - "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/push.CreateEventRequest" - } - } - ], - "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": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/push.EventMetadata" - } - } - } - } - ] - } - } - } - } - }, - "/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/push.UpdateEventRequest" - } - } - ], - "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": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/push.pushHistoriesResponse" - } - } - } - ] - } - } - } - } - }, - "/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/push.TestPushRequest" - } - } - ], - "responses": { - "200": { - "description": "测试成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - } - }, "/api/v1/admin/status": { "get": { "security": [ @@ -2143,7 +1112,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/status.SystemStatusResponse" + "$ref": "#/definitions/admin.SystemStatusResponse" } } } @@ -2192,7 +1161,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/status.LogDatabaseStatus" + "$ref": "#/definitions/admin.LogDatabaseStatus" } } } @@ -2257,7 +1226,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.SystemConfig" + "$ref": "#/definitions/admin.SystemConfig" } } } @@ -2309,7 +1278,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/system_config.CreateSystemConfigRequest" + "$ref": "#/definitions/admin.CreateSystemConfigRequest" } } ], @@ -2384,7 +1353,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/system_config.TestSMTPRequest" + "$ref": "#/definitions/admin.TestSMTPRequest" } } ], @@ -2400,7 +1369,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/system_config.TestSMTPResponse" + "$ref": "#/definitions/admin.TestSMTPResponse" } } } @@ -2452,7 +1421,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.SystemConfig" + "$ref": "#/definitions/admin.SystemConfig" } } } @@ -2516,7 +1485,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/system_config.UpdateSystemConfigRequest" + "$ref": "#/definitions/admin.UpdateSystemConfigRequest" } } ], @@ -2597,7 +1566,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/task.DispatchTaskRequest" + "$ref": "#/definitions/admin.DispatchTaskRequest" } } ], @@ -2760,7 +1729,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.TaskExecution" + "$ref": "#/definitions/admin.TaskExecution" } } } @@ -2899,7 +1868,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.Schedule" + "$ref": "#/definitions/admin.Schedule" } } } @@ -2945,7 +1914,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/task.CreateScheduleRequest" + "$ref": "#/definitions/admin.CreateScheduleRequest" } } ], @@ -2961,7 +1930,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Schedule" + "$ref": "#/definitions/admin.Schedule" } } } @@ -3027,7 +1996,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/task.UpdateScheduleRequest" + "$ref": "#/definitions/admin.UpdateScheduleRequest" } } ], @@ -3043,7 +2012,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Schedule" + "$ref": "#/definitions/admin.Schedule" } } } @@ -3232,7 +2201,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.Template" + "$ref": "#/definitions/admin.Template" } } } @@ -3284,7 +2253,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/template.CreateTemplateRequest" + "$ref": "#/definitions/admin.CreateTemplateRequest" } } ], @@ -3370,7 +2339,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Template" + "$ref": "#/definitions/admin.Template" } } } @@ -3434,7 +2403,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/template.UpdateTemplateRequest" + "$ref": "#/definitions/admin.UpdateTemplateRequest" } } ], @@ -3450,7 +2419,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Template" + "$ref": "#/definitions/admin.Template" } } } @@ -3591,7 +2560,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/updater.Status" + "$ref": "#/definitions/admin.UpdaterStatus" } } } @@ -3668,94 +2637,6 @@ } } }, - "/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", - "format": "int64", - "description": "上传用户 ID", - "name": "user_id", - "in": "query" - } - ], - "responses": { - "200": { - "description": "查询成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/handler.listFilesResponse" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, "/api/v1/admin/uploads/download/batch": { "post": { "security": [ @@ -3865,6 +2746,127 @@ } } }, + "/api/v1/admin/uploads/files": { + "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/files/{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/uploads/stats": { "get": { "security": [ @@ -3927,17 +2929,17 @@ "SessionCookie": [] } ], - "description": "返回数据库中所有已上传文件实际拥有的业务类型列表", + "description": "查询系统内所有不重复的上传业务分类标识(如 avatar, doc 等)", "produces": [ "application/json" ], "tags": [ "admin" ], - "summary": "获取文件业务类型列表", + "summary": "获取业务分类列表", "responses": { "200": { - "description": "业务类型列表", + "description": "查询成功", "schema": { "allOf": [ { @@ -3956,70 +2958,6 @@ } ] } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "403": { - "description": "无管理员权限", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/admin/uploads/{id}": { - "delete": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "将文件状态置为 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" - } - }, - "403": { - "description": "无权操作", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "404": { - "description": "文件不存在", - "schema": { - "$ref": "#/definitions/response.Any" - } } } } @@ -4081,7 +3019,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/user.listUsersResponse" + "$ref": "#/definitions/admin.listUsersResponse" } } } @@ -4138,7 +3076,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/user.createUserRequest" + "$ref": "#/definitions/admin.createUserRequest" } } ], @@ -4154,7 +3092,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/user.user" + "$ref": "#/definitions/admin.userResponse" } } } @@ -4224,7 +3162,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/user.user" + "$ref": "#/definitions/admin.userResponse" } } } @@ -4294,7 +3232,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/user.updateUserRequest" + "$ref": "#/definitions/admin.updateUserRequest" } } ], @@ -4456,7 +3394,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/user.updateUserStatusRequest" + "$ref": "#/definitions/admin.updateUserStatusRequest" } } ], @@ -4535,38 +3473,6 @@ } } }, - "/api/v1/custom/hello": { - "get": { - "description": "Scaffold demo API; product APIs use semantic paths under apps/\u003cdomain\u003e", - "produces": [ - "application/json" - ], - "tags": [ - "custom" - ], - "summary": "Sample Hello API", - "responses": { - "200": { - "description": "成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "string" - } - } - } - ] - } - } - } - } - }, "/api/v1/message-gateway/bindings": { "get": { "security": [ @@ -4766,413 +3672,6 @@ } } }, - "/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/oauth.CallbackRequest" - } - } - ], - "responses": { - "200": { - "description": "登录或绑定成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.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": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/definitions/model.ExternalAccountView" - } - } - } - } - ] - } - }, - "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/oauth.OAuthAuthorizeResponse" - } - } - } - ] - } - }, - "400": { - "description": "认证源不存在或未配置", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "Redis 异常 or 构造 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/oauth.AuthSourceView" - } - } - } - } - ] - } - } - } - } - }, - "/api/v1/oauth/user-info": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取当前登录用户信息", - "responses": { - "200": { - "description": "用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.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/oauth.OAuthAuthorizeResponse" - } - } - } - ] - } - }, - "400": { - "description": "认证源不存在或未启用", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "Redis 异常或构造 URL 失败", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, "/api/v1/upload": { "post": { "security": [ @@ -5224,7 +3723,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Upload" + "$ref": "#/definitions/models.Upload" } } } @@ -5375,7 +3874,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/definitions/model.Upload" + "$ref": "#/definitions/models.Upload" } } } @@ -5441,612 +3940,6 @@ } } }, - "/api/v1/user-info": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取当前登录用户信息", - "responses": { - "200": { - "description": "用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.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/model.AccessToken" - } - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - }, - "post": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。可通过 is_admin 字段赋予令牌管理员权限(仅管理员用户可设置)。", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "创建一个新的 AccessToken", - "parameters": [ - { - "description": "令牌名称", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.createTokenRequest" - } - } - ], - "responses": { - "200": { - "description": "新建令牌成功", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/user.tokenResponse" - } - } - } - ] - } - }, - "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": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/user.tokenResponse" - } - } - } - ] - } - }, - "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": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "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": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "400": { - "description": "邮箱已被占用或参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/register": { - "post": { - "description": "使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。", - "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": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "400": { - "description": "参数错误、用户名已存在或注册已关闭", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "500": { - "description": "服务内部错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/self": { - "get": { - "security": [ - { - "SessionCookie": [] - } - ], - "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", - "produces": [ - "application/json" - ], - "tags": [ - "oauth" - ], - "summary": "获取当前登录用户信息", - "responses": { - "200": { - "description": "用户信息", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Any" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - } - ] - } - }, - "401": { - "description": "未登录", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, - "/api/v1/user/send-email-code": { - "post": { - "description": "向指定邮箱发送验证码(用于注册场景)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "user" - ], - "summary": "发送邮箱验证码", - "parameters": [ - { - "description": "发送验证码请求参数", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/user.sendEmailCodeRequest" - } - } - ], - "responses": { - "200": { - "description": "发送成功", - "schema": { - "$ref": "#/definitions/response.Any" - } - }, - "400": { - "description": "参数错误", - "schema": { - "$ref": "#/definitions/response.Any" - } - } - } - } - }, "/f/{id}": { "get": { "description": "根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回", @@ -6128,19 +4021,16 @@ } }, "definitions": { - "auth_source.AuthSourceRequest": { + "admin.CreateScheduleRequest": { "type": "object", + "required": [ + "cron", + "is_active", + "name", + "task_type" + ], "properties": { - "client_id": { - "type": "string" - }, - "client_secret": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "icon_url": { + "cron": { "type": "string" }, "is_active": { @@ -6149,75 +4039,84 @@ "name": { "type": "string" }, - "openid_discovery_url": { + "payload": { "type": "string" }, - "scopes": { + "task_type": { "type": "string" + } + } + }, + "admin.CreateSystemConfigRequest": { + "type": "object", + "required": [ + "key", + "type", + "value" + ], + "properties": { + "description": { + "type": "string", + "maxLength": 255 + }, + "key": { + "type": "string", + "maxLength": 64 }, "type": { + "type": "string", + "enum": [ + "system", + "business" + ] + }, + "value": { "type": "string" + }, + "visibility": { + "type": "integer", + "enum": [ + 0, + 1 + ] } } }, - "auth_source.ToggleAuthSourceRequest": { - "type": "object", - "properties": { - "is_active": { - "type": "boolean" - } - } - }, - "cache.updateCacheConfigRequest": { + "admin.CreateTemplateRequest": { "type": "object", "required": [ - "max_size_mb", - "ttl_minutes" + "content", + "key", + "name", + "type" ], "properties": { - "lru_enabled": { - "type": "boolean" + "content": { + "type": "string" }, - "max_size_mb": { - "type": "integer", - "minimum": 1 + "description": { + "type": "string", + "maxLength": 255 }, - "ttl_minutes": { - "type": "integer", - "minimum": 0 + "key": { + "type": "string", + "maxLength": 80 + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "subject": { + "type": "string", + "maxLength": 255 + }, + "type": { + "type": "string", + "maxLength": 20 } } }, - "cap.challengeRequest": { - "type": "object", - "properties": { - "scope": { - "type": "string" - } - } - }, - "cap.redeemRequest": { - "type": "object", - "required": [ - "solutions", - "token" - ], - "properties": { - "scope": { - "type": "string" - }, - "solutions": { - "type": "array", - "items": { - "type": "integer" - } - }, - "token": { - "type": "string" - } - } - }, - "db_manage.DBOverviewResponse": { + "admin.DBOverviewResponse": { "type": "object", "properties": { "connections": { @@ -6240,7 +4139,44 @@ } } }, - "db_manage.ExecuteSQLRequest": { + "admin.DatabaseInfoResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "admin.DispatchTaskRequest": { + "type": "object", + "required": [ + "task_type" + ], + "properties": { + "end_time": { + "type": "string" + }, + "payload": { + "type": "string" + }, + "start_time": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, + "admin.ExecuteSQLRequest": { "type": "object", "required": [ "sql" @@ -6251,7 +4187,7 @@ } } }, - "db_manage.ExecuteSQLResponse": { + "admin.ExecuteSQLResponse": { "type": "object", "properties": { "affected_rows": { @@ -6279,763 +4215,30 @@ } } }, - "diskcache.Status": { + "admin.LogDatabaseStatus": { "type": "object", "properties": { - "base_path": { + "active_database": { "type": "string" }, - "keys_count": { - "type": "integer" + "available_targets": { + "type": "array", + "items": { + "type": "string" + } }, - "lru_enabled": { - "type": "boolean" + "migration": { + "type": "string" }, - "max_size_mb": { - "type": "integer" - }, - "total_size": { - "type": "integer" - }, - "ttl_minutes": { - "type": "integer" - } - } - }, - "github_com_Rain-kl_Wavelet_internal_apps_cap.ChallengeResponse": { - "type": "object", - "properties": { - "challenge": { + "retention_days": { "type": "object", - "properties": { - "c": { - "type": "integer" - }, - "d": { - "type": "integer" - }, - "s": { - "type": "integer" - } - } - }, - "expires": { - "description": "ms timestamp", - "type": "integer" - }, - "token": { - "type": "string" - } - } - }, - "github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "expires": { - "type": "integer" - }, - "success": { - "type": "boolean" - }, - "token": { - "type": "string" - } - } - }, - "handler.batchDownloadRequest": { - "type": "object", - "required": [ - "ids" - ], - "properties": { - "ids": { - "type": "array", - "minItems": 1, - "items": { - "type": "string" + "additionalProperties": { + "type": "integer" } } } }, - "handler.distributionItem": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "size": { - "type": "integer" - } - } - }, - "handler.fileStatsResponse": { - "type": "object", - "properties": { - "categories": { - "type": "array", - "items": { - "$ref": "#/definitions/handler.distributionItem" - } - }, - "total_count": { - "type": "integer" - }, - "total_size": { - "type": "integer" - }, - "trend": { - "type": "array", - "items": { - "$ref": "#/definitions/handler.trendItem" - } - }, - "types": { - "type": "array", - "items": { - "$ref": "#/definitions/handler.distributionItem" - } - } - } - }, - "handler.listFilesResponse": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Upload" - } - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - }, - "handler.listMyFilesResponse": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/model.Upload" - } - }, - "page": { - "type": "integer" - }, - "page_size": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - }, - "handler.trendItem": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "date": { - "type": "string" - }, - "size": { - "type": "integer" - } - } - }, - "handler.updateMyFileRequest": { - "type": "object", - "properties": { - "access_mode": { - "type": "integer", - "enum": [ - 0, - 1 - ] - }, - "file_name": { - "type": "string", - "maxLength": 255 - } - } - }, - "logger.LogEntry": { - "type": "object", - "properties": { - "data": { - "description": "一行日志原文(含换行符)", - "type": "string" - }, - "index": { - "description": "全局递增序号", - "type": "integer" - } - } - }, - "logs.accessLogItem": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "headers": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "ip": { - "type": "string" - }, - "latency": { - "type": "integer" - }, - "method": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "path": { - "type": "string" - }, - "status": { - "type": "integer" - }, - "user_agent": { - "type": "string" - }, - "user_id": { - "type": "string", - "example": "0" - }, - "username": { - "type": "string" - } - } - }, - "logs.accessLogsResponse": { - "type": "object", - "properties": { - "list": { - "type": "array", - "items": { - "$ref": "#/definitions/logs.accessLogItem" - } - }, - "total": { - "type": "integer" - } - } - }, - "logs.browserItem": { - "type": "object", - "properties": { - "browser": { - "type": "string" - }, - "count": { - "type": "integer" - } - } - }, - "logs.logsAnalyticsResponse": { - "type": "object", - "properties": { - "browsers": { - "type": "array", - "items": { - "$ref": "#/definitions/logs.browserItem" - } - }, - "top_users": { - "type": "array", - "items": { - "$ref": "#/definitions/logs.topUserItem" - } - }, - "trend": { - "type": "array", - "items": { - "$ref": "#/definitions/logs.trendItem" - } - } - } - }, - "logs.logsResponse": { - "type": "object", - "properties": { - "has_more": { - "type": "boolean" - }, - "lines": { - "type": "array", - "items": { - "$ref": "#/definitions/logger.LogEntry" - } - }, - "next_cursor": { - "description": "用于加载更早日志的 cursor", - "type": "integer" - } - } - }, - "logs.topUserItem": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "nickname": { - "type": "string" - }, - "user_id": { - "type": "string", - "example": "0" - }, - "username": { - "type": "string" - } - } - }, - "logs.trendItem": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "date": { - "type": "string" - } - } - }, - "message_gateway.BindRequest": { - "type": "object", - "properties": { - "channel_id": { - "type": "string" - }, - "code": { - "type": "string" - } - } - }, - "message_gateway.BindingDTO": { - "type": "object", - "properties": { - "channel_id": { - "type": "string", - "example": "0" - }, - "channel_name": { - "type": "string" - }, - "channel_type": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "platform_user_id": { - "type": "string" - }, - "user_id": { - "type": "string", - "example": "0" - } - } - }, - "message_gateway.ChannelDTO": { - "type": "object", - "properties": { - "app_id": { - "type": "string" - }, - "app_secret": { - "type": "string" - }, - "base_url": { - "type": "string" - }, - "bot_token": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "id": { - "type": "string", - "example": "0" - }, - "name": { - "type": "string" - }, - "owner_scope": { - "type": "string" - }, - "portal_host": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "message_gateway.CreateChannelRequest": { - "type": "object", - "properties": { - "app_id": { - "type": "string" - }, - "app_secret": { - "type": "string" - }, - "base_url": { - "type": "string" - }, - "bot_token": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "portal_host": { - "type": "string" - }, - "sandbox": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "message_gateway.Definition": { - "type": "object", - "properties": { - "fields": { - "type": "array", - "items": { - "$ref": "#/definitions/message_gateway.Field" - } - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "message_gateway.Field": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "required": { - "type": "boolean" - }, - "type": { - "type": "string" - } - } - }, - "message_gateway.PublicChannelDTO": { - "type": "object", - "properties": { - "id": { - "type": "string", - "example": "0" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - } - } - }, - "message_gateway.UpdateChannelRequest": { - "type": "object", - "properties": { - "app_id": { - "type": "string" - }, - "app_secret": { - "type": "string" - }, - "base_url": { - "type": "string" - }, - "bot_token": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "portal_host": { - "type": "string" - }, - "sandbox": { - "type": "string" - } - } - }, - "model.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" - } - } - }, - "model.AuthSource": { - "type": "object", - "properties": { - "client_id": { - "type": "string" - }, - "client_secret_configured": { - "type": "boolean" - }, - "created_at": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "icon_url": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "openid_discovery_url": { - "type": "string" - }, - "scopes": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.ExternalAccountView": { - "type": "object", - "properties": { - "auth_source_id": { - "type": "integer" - }, - "auth_source_label": { - "type": "string" - }, - "auth_source_name": { - "type": "string" - }, - "auth_source_type": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "email": { - "type": "string" - }, - "external_username": { - "type": "string" - }, - "id": { - "type": "integer" - } - } - }, - "model.PushChannel": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "description": { - "description": "备注", - "type": "string" - }, - "enabled": { - "description": "通道是否启用", - "type": "boolean" - }, - "id": { - "type": "integer" - }, - "name": { - "description": "通道名称,仅英文字母和下划线,唯一", - "type": "string" - }, - "other": { - "description": "请求体/SMTP 密码等", - "type": "string" - }, - "token": { - "description": "鉴权令牌或发信用户名等", - "type": "string" - }, - "type": { - "description": "通道类型:custom, lark, email", - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "url": { - "description": "请求地址,HTTPS 协议或 SMTP 地址", - "type": "string" - } - } - }, - "model.PushEvent": { - "type": "object", - "properties": { - "channels": { - "description": "推送渠道列表,如 [\"lark\"]", - "type": "array", - "items": { - "type": "string" - } - }, - "created_at": { - "type": "string" - }, - "enabled": { - "description": "是否启用", - "type": "boolean" - }, - "event_key": { - "description": "如 admin_login", - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "description": "如 管理员登录", - "type": "string" - }, - "targets": { - "description": "推送目标用户/邮箱列表", - "type": "array", - "items": { - "type": "string" - } - }, - "task_type": { - "description": "关联的异步任务类型", - "type": "string" - }, - "template": { - "description": "消息模板 JSON", - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.PushHistory": { - "type": "object", - "properties": { - "channel": { - "type": "string" - }, - "content": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "error_msg": { - "type": "string" - }, - "event_key": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "level": { - "type": "string" - }, - "status": { - "description": "success / failed", - "type": "string" - }, - "target": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "model.Schedule": { + "admin.Schedule": { "type": "object", "properties": { "created_at": { @@ -7065,7 +4268,7 @@ } } }, - "model.SystemConfig": { + "admin.SystemConfig": { "type": "object", "properties": { "created_at": { @@ -7091,669 +4294,7 @@ } } }, - "model.TaskExecution": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "error_message": { - "type": "string" - }, - "finished_at": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "log": { - "type": "string" - }, - "max_retry": { - "type": "integer" - }, - "payload": { - "type": "string" - }, - "result": { - "type": "string" - }, - "retry_count": { - "type": "integer" - }, - "retryable": { - "type": "boolean" - }, - "started_at": { - "type": "string" - }, - "status": { - "$ref": "#/definitions/model.TaskExecutionStatus" - }, - "task_id": { - "type": "string" - }, - "task_name": { - "type": "string" - }, - "task_type": { - "type": "string" - }, - "triggered_by": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.TaskExecutionStatus": { - "type": "string", - "enum": [ - "pending", - "running", - "succeeded", - "failed" - ], - "x-enum-varnames": [ - "TaskExecutionStatusPending", - "TaskExecutionStatusRunning", - "TaskExecutionStatusSucceeded", - "TaskExecutionStatusFailed" - ] - }, - "model.Template": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "description": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "is_system": { - "type": "boolean" - }, - "key": { - "type": "string" - }, - "name": { - "type": "string" - }, - "subject": { - "type": "string" - }, - "type": { - "type": "string" - }, - "updated_at": { - "type": "string" - } - } - }, - "model.Upload": { - "type": "object", - "properties": { - "access_mode": { - "type": "integer" - }, - "created_at": { - "type": "string" - }, - "extension": { - "description": "文件后缀名 (不含点,如 png, pdf)", - "type": "string" - }, - "file_name": { - "description": "原始文件名 (例如: image.png)", - "type": "string" - }, - "file_path": { - "description": "文件相对路径 / S3 Key", - "type": "string" - }, - "file_size": { - "description": "文件大小(字节)", - "type": "integer" - }, - "hash": { - "description": "文件哈希 (SHA-256/MD5,可用于排重)", - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "metadata": { - "description": "业务扩展元数据", - "allOf": [ - { - "$ref": "#/definitions/model.UploadMetadata" - } - ] - }, - "mime_type": { - "description": "媒体类型 (MIME, 如 image/png)", - "type": "string" - }, - "status": { - "description": "状态", - "allOf": [ - { - "$ref": "#/definitions/model.UploadStatus" - } - ] - }, - "type": { - "description": "业务标识类型 (如 avatar, doc, attachment)", - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "user_id": { - "type": "string", - "example": "0" - } - } - }, - "model.UploadMetadata": { - "type": "object", - "properties": { - "bucket": { - "description": "存储桶名称 (适用于 S3 等)", - "type": "string" - }, - "client_ip": { - "description": "上传者 IP", - "type": "string" - }, - "duration": { - "description": "音视频时长 (s)", - "type": "number" - }, - "extra": { - "description": "其它任意业务自定义元数据", - "type": "object", - "additionalProperties": {} - }, - "height": { - "description": "图像/视频高度 (px)", - "type": "integer" - }, - "original_mime": { - "description": "原始 MIME 类型", - "type": "string" - }, - "user_agent": { - "description": "上传者的 UA", - "type": "string" - }, - "width": { - "description": "图像/视频宽度 (px)", - "type": "integer" - } - } - }, - "model.UploadStatus": { - "type": "string", - "enum": [ - "pending", - "used", - "deleted" - ], - "x-enum-comments": { - "UploadStatusDeleted": "已删除", - "UploadStatusPending": "待使用", - "UploadStatusUsed": "已使用" - }, - "x-enum-descriptions": [ - "待使用", - "已使用", - "已删除" - ], - "x-enum-varnames": [ - "UploadStatusPending", - "UploadStatusUsed", - "UploadStatusDeleted" - ] - }, - "oauth.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" - } - } - }, - "oauth.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" - } - } - }, - "oauth.CallbackRequest": { - "type": "object", - "required": [ - "code", - "state" - ], - "properties": { - "code": { - "type": "string" - }, - "state": { - "type": "string" - } - } - }, - "oauth.OAuthAuthorizeResponse": { - "type": "object", - "properties": { - "authorize_url": { - "type": "string" - } - } - }, - "oauth.OAuthCallbackResult": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "user": { - "$ref": "#/definitions/oauth.BasicUserInfo" - } - } - }, - "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" - } - } - }, - "push.CreateChannelRequest": { - "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" - } - } - }, - "push.CreateEventRequest": { - "type": "object", - "properties": { - "channels": { - "type": "array", - "items": { - "type": "string" - } - }, - "enabled": { - "type": "boolean" - }, - "event_key": { - "type": "string" - }, - "targets": { - "type": "array", - "items": { - "type": "string" - } - }, - "task_type": { - "description": "关联的异步任务类型", - "type": "string" - }, - "template": { - "type": "string" - } - } - }, - "push.Definition": { - "type": "object", - "properties": { - "description": { - "description": "short description", - "type": "string" - }, - "fields": { - "description": "form fields", - "type": "array", - "items": { - "$ref": "#/definitions/push.Field" - } - }, - "name": { - "description": "display name", - "type": "string" - }, - "type": { - "description": "channel type (e.g., custom, lark, email)", - "type": "string" - } - } - }, - "push.EventMetadata": { - "type": "object", - "properties": { - "default_template": { - "$ref": "#/definitions/push.NotificationMessage" - }, - "description": { - "type": "string" - }, - "key": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "push.Field": { - "type": "object", - "properties": { - "description": { - "description": "field explanation/help text", - "type": "string" - }, - "key": { - "description": "unique key for the field (e.g. url, token, other)", - "type": "string" - }, - "label": { - "description": "human readable label (e.g. \"Webhook 地址\")", - "type": "string" - }, - "placeholder": { - "description": "input placeholder", - "type": "string" - }, - "required": { - "description": "whether this field is required", - "type": "boolean" - }, - "type": { - "description": "input type: \"text\" | \"password\" | \"textarea\"", - "type": "string" - } - } - }, - "push.NotificationMessage": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "ext": { - "type": "object", - "additionalProperties": {} - }, - "level": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "push.TestChannelRequest": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "other": { - "type": "string" - }, - "target": { - "type": "string" - }, - "token": { - "type": "string" - }, - "type": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "push.TestPushRequest": { - "type": "object", - "required": [ - "config" - ], - "properties": { - "config": { - "$ref": "#/definitions/push.Config" - }, - "target": { - "type": "string" - } - } - }, - "push.UpdateChannelRequest": { - "type": "object", - "required": [ - "type" - ], - "properties": { - "description": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "other": { - "type": "string" - }, - "token": { - "type": "string" - }, - "type": { - "type": "string" - }, - "url": { - "type": "string" - } - } - }, - "push.UpdateEventRequest": { - "type": "object", - "required": [ - "template" - ], - "properties": { - "channels": { - "type": "array", - "items": { - "type": "string" - } - }, - "enabled": { - "type": "boolean" - }, - "targets": { - "type": "array", - "items": { - "type": "string" - } - }, - "template": { - "type": "string" - } - } - }, - "push.pushHistoriesResponse": { - "type": "object", - "properties": { - "results": { - "type": "array", - "items": { - "$ref": "#/definitions/model.PushHistory" - } - }, - "total": { - "type": "integer" - } - } - }, - "response.Any": { - "type": "object", - "properties": { - "data": {}, - "error_msg": { - "type": "string", - "example": "" - } - } - }, - "status.DatabaseInfoResponse": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "type": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "status.LogDatabaseStatus": { - "type": "object", - "properties": { - "active_database": { - "type": "string" - }, - "available_targets": { - "type": "array", - "items": { - "type": "string" - } - }, - "migration": { - "type": "string" - }, - "retention_days": { - "type": "object", - "additionalProperties": { - "type": "integer" - } - } - } - }, - "status.SystemStatusResponse": { + "admin.SystemStatusResponse": { "type": "object", "properties": { "alloc": { @@ -7842,42 +4383,117 @@ } } }, - "system_config.CreateSystemConfigRequest": { + "admin.TaskExecution": { "type": "object", - "required": [ - "key", - "type", - "value" - ], "properties": { - "description": { - "type": "string", - "maxLength": 255 - }, - "key": { - "type": "string", - "maxLength": 64 - }, - "type": { - "type": "string", - "enum": [ - "system", - "business" - ] - }, - "value": { + "created_at": { "type": "string" }, - "visibility": { - "type": "integer", - "enum": [ - 0, - 1 - ] + "duration": { + "type": "integer" + }, + "error_message": { + "type": "string" + }, + "finished_at": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "log": { + "type": "string" + }, + "max_retry": { + "type": "integer" + }, + "payload": { + "type": "string" + }, + "result": { + "type": "string" + }, + "retry_count": { + "type": "integer" + }, + "retryable": { + "type": "boolean" + }, + "started_at": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/admin.TaskExecutionStatus" + }, + "task_id": { + "type": "string" + }, + "task_name": { + "type": "string" + }, + "task_type": { + "type": "string" + }, + "triggered_by": { + "type": "string" + }, + "updated_at": { + "type": "string" } } }, - "system_config.TestSMTPRequest": { + "admin.TaskExecutionStatus": { + "type": "string", + "enum": [ + "pending", + "running", + "succeeded", + "failed" + ], + "x-enum-varnames": [ + "TaskExecutionStatusPending", + "TaskExecutionStatusRunning", + "TaskExecutionStatusSucceeded", + "TaskExecutionStatusFailed" + ] + }, + "admin.Template": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_system": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "admin.TestSMTPRequest": { "type": "object", "required": [ "smtp_host", @@ -7907,7 +4523,7 @@ } } }, - "system_config.TestSMTPResponse": { + "admin.TestSMTPResponse": { "type": "object", "properties": { "error": { @@ -7921,29 +4537,7 @@ } } }, - "system_config.UpdateSystemConfigRequest": { - "type": "object", - "required": [ - "value" - ], - "properties": { - "description": { - "type": "string", - "maxLength": 255 - }, - "value": { - "type": "string" - }, - "visibility": { - "type": "integer", - "enum": [ - 0, - 1 - ] - } - } - }, - "task.CreateScheduleRequest": { + "admin.UpdateScheduleRequest": { "type": "object", "required": [ "cron", @@ -7969,29 +4563,907 @@ } } }, - "task.DispatchTaskRequest": { + "admin.UpdateSystemConfigRequest": { "type": "object", "required": [ - "task_type" + "value" ], "properties": { - "end_time": { + "description": { + "type": "string", + "maxLength": 255 + }, + "value": { "type": "string" }, - "payload": { + "visibility": { + "type": "integer", + "enum": [ + 0, + 1 + ] + } + } + }, + "admin.UpdateTemplateRequest": { + "type": "object", + "required": [ + "content", + "name", + "type" + ], + "properties": { + "content": { "type": "string" }, - "start_time": { + "description": { + "type": "string", + "maxLength": 255 + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "subject": { + "type": "string", + "maxLength": 255 + }, + "type": { + "type": "string", + "maxLength": 20 + } + } + }, + "admin.UpdaterStatus": { + "type": "object", + "properties": { + "asset_name": { "type": "string" }, - "task_type": { + "build_time": { + "type": "string" + }, + "can_upgrade": { + "type": "boolean" + }, + "current_version": { + "type": "string" + }, + "latest_version": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "prerelease": { + "type": "boolean" + }, + "published_at": { + "type": "string" + }, + "release_name": { + "type": "string" + }, + "release_notes": { + "type": "string" + }, + "release_url": { + "type": "string" + }, + "update_available": { + "type": "boolean" + }, + "upstream_repository": { + "type": "string" + } + } + }, + "admin.accessLogItem": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "headers": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "ip": { + "type": "string" + }, + "latency": { + "type": "integer" + }, + "method": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "path": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "user_agent": { "type": "string" }, "user_id": { + "type": "string", + "example": "0" + }, + "username": { + "type": "string" + } + } + }, + "admin.accessLogsResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.accessLogItem" + } + }, + "total": { "type": "integer" } } }, + "admin.browserItem": { + "type": "object", + "properties": { + "browser": { + "type": "string" + }, + "count": { + "type": "integer" + } + } + }, + "admin.createUserRequest": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "email": { + "type": "string", + "maxLength": 255 + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "nickname": { + "type": "string", + "maxLength": 64 + }, + "password": { + "type": "string", + "maxLength": 64, + "minLength": 8 + }, + "username": { + "type": "string", + "maxLength": 64, + "minLength": 3 + } + } + }, + "admin.listUsersResponse": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.userResponse" + } + } + } + }, + "admin.logsAnalyticsResponse": { + "type": "object", + "properties": { + "browsers": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.browserItem" + } + }, + "top_users": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.topUserItem" + } + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.trendItem" + } + } + } + }, + "admin.logsResponse": { + "type": "object", + "properties": { + "has_more": { + "type": "boolean" + }, + "lines": { + "type": "array", + "items": { + "$ref": "#/definitions/logger.LogEntry" + } + }, + "next_cursor": { + "description": "用于加载更早日志的 cursor", + "type": "integer" + } + } + }, + "admin.topUserItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "nickname": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + }, + "username": { + "type": "string" + } + } + }, + "admin.trendItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "string" + } + } + }, + "admin.updateCacheConfigRequest": { + "type": "object", + "required": [ + "max_size_mb", + "ttl_minutes" + ], + "properties": { + "lru_enabled": { + "type": "boolean" + }, + "max_size_mb": { + "type": "integer", + "minimum": 1 + }, + "ttl_minutes": { + "type": "integer", + "minimum": 0 + } + } + }, + "admin.updateUserRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "maxLength": 255 + }, + "is_admin": { + "type": "boolean" + }, + "nickname": { + "type": "string", + "maxLength": 64 + }, + "password": { + "type": "string", + "maxLength": 64, + "minLength": 8 + } + } + }, + "admin.updateUserStatusRequest": { + "type": "object", + "properties": { + "is_active": { + "type": "boolean" + } + } + }, + "admin.userResponse": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "last_login_at": { + "type": "string" + }, + "location": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + }, + "website": { + "type": "string" + } + } + }, + "cap.challengeRequest": { + "type": "object", + "properties": { + "scope": { + "type": "string" + } + } + }, + "cap.redeemRequest": { + "type": "object", + "required": [ + "solutions", + "token" + ], + "properties": { + "scope": { + "type": "string" + }, + "solutions": { + "type": "array", + "items": { + "type": "integer" + } + }, + "token": { + "type": "string" + } + } + }, + "diskcache.Status": { + "type": "object", + "properties": { + "base_path": { + "type": "string" + }, + "keys_count": { + "type": "integer" + }, + "lru_enabled": { + "type": "boolean" + }, + "max_size_mb": { + "type": "integer" + }, + "total_size": { + "type": "integer" + }, + "ttl_minutes": { + "type": "integer" + } + } + }, + "github_com_Rain-kl_Wavelet_plugins_domain_cap.ChallengeResponse": { + "type": "object", + "properties": { + "challenge": { + "type": "object", + "properties": { + "c": { + "type": "integer" + }, + "d": { + "type": "integer" + }, + "s": { + "type": "integer" + } + } + }, + "expires": { + "description": "ms timestamp", + "type": "integer" + }, + "token": { + "type": "string" + } + } + }, + "github_com_Rain-kl_Wavelet_plugins_domain_cap.RedeemResponse": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "expires": { + "type": "integer" + }, + "success": { + "type": "boolean" + }, + "token": { + "type": "string" + } + } + }, + "handler.batchDownloadRequest": { + "type": "object", + "required": [ + "ids" + ], + "properties": { + "ids": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + }, + "handler.distributionItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, + "handler.fileStatsResponse": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.distributionItem" + } + }, + "total_count": { + "type": "integer" + }, + "total_size": { + "type": "integer" + }, + "trend": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.trendItem" + } + }, + "types": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.distributionItem" + } + } + } + }, + "handler.listFilesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Upload" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handler.listMyFilesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Upload" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "handler.trendItem": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + }, + "handler.updateMyFileRequest": { + "type": "object", + "properties": { + "access_mode": { + "type": "integer", + "enum": [ + 0, + 1 + ] + }, + "file_name": { + "type": "string", + "maxLength": 255 + } + } + }, + "logger.LogEntry": { + "type": "object", + "properties": { + "data": { + "description": "一行日志原文(含换行符)", + "type": "string" + }, + "index": { + "description": "全局递增序号", + "type": "integer" + } + } + }, + "message_gateway.BindRequest": { + "type": "object", + "properties": { + "channel_id": { + "type": "string" + }, + "code": { + "type": "string" + } + } + }, + "message_gateway.BindingDTO": { + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "example": "0" + }, + "channel_name": { + "type": "string" + }, + "channel_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "platform_user_id": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + } + } + }, + "message_gateway.ChannelDTO": { + "type": "object", + "properties": { + "credentials": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "extra": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "id": { + "type": "string", + "example": "0" + }, + "name": { + "type": "string" + }, + "owner_id": { + "type": "string", + "example": "0" + }, + "owner_scope": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "message_gateway.CreateChannelRequest": { + "type": "object", + "properties": { + "credentials": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "extra": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "message_gateway.Definition": { + "type": "object", + "properties": { + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/message_gateway.Field" + } + }, + "type": { + "type": "string" + } + } + }, + "message_gateway.Field": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "type": { + "type": "string" + } + } + }, + "message_gateway.PublicChannelDTO": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "0" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "message_gateway.UpdateChannelRequest": { + "type": "object", + "properties": { + "credentials": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "extra": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "name": { + "type": "string" + } + } + }, + "models.Upload": { + "type": "object", + "properties": { + "access_mode": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "extension": { + "type": "string" + }, + "file_name": { + "type": "string" + }, + "file_path": { + "type": "string" + }, + "file_size": { + "type": "integer" + }, + "hash": { + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "metadata": { + "$ref": "#/definitions/models.UploadMetadata" + }, + "mime_type": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/models.UploadStatus" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + } + } + }, + "models.UploadMetadata": { + "type": "object", + "properties": { + "bucket": { + "type": "string" + }, + "client_ip": { + "type": "string" + }, + "duration": { + "type": "number" + }, + "extra": { + "type": "object", + "additionalProperties": {} + }, + "height": { + "type": "integer" + }, + "original_mime": { + "type": "string" + }, + "user_agent": { + "type": "string" + }, + "width": { + "type": "integer" + } + } + }, + "models.UploadStatus": { + "type": "string", + "enum": [ + "pending", + "used", + "deleted" + ], + "x-enum-comments": { + "UploadStatusDeleted": "已删除", + "UploadStatusPending": "待使用", + "UploadStatusUsed": "已使用" + }, + "x-enum-descriptions": [ + "待使用", + "已使用", + "已删除" + ], + "x-enum-varnames": [ + "UploadStatusPending", + "UploadStatusUsed", + "UploadStatusDeleted" + ] + }, + "response.Any": { + "type": "object", + "properties": { + "data": {}, + "error_msg": { + "type": "string", + "example": "" + } + } + }, "task.TaskMeta": { "type": "object", "properties": { @@ -8056,384 +5528,6 @@ "type": "string" } } - }, - "task.UpdateScheduleRequest": { - "type": "object", - "required": [ - "cron", - "is_active", - "name", - "task_type" - ], - "properties": { - "cron": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "payload": { - "type": "string" - }, - "task_type": { - "type": "string" - } - } - }, - "template.CreateTemplateRequest": { - "type": "object", - "required": [ - "content", - "key", - "name", - "type" - ], - "properties": { - "content": { - "type": "string" - }, - "description": { - "type": "string", - "maxLength": 255 - }, - "key": { - "type": "string", - "maxLength": 80 - }, - "name": { - "type": "string", - "maxLength": 100 - }, - "subject": { - "type": "string", - "maxLength": 255 - }, - "type": { - "type": "string", - "maxLength": 20 - } - } - }, - "template.UpdateTemplateRequest": { - "type": "object", - "required": [ - "content", - "name", - "type" - ], - "properties": { - "content": { - "type": "string" - }, - "description": { - "type": "string", - "maxLength": 255 - }, - "name": { - "type": "string", - "maxLength": 100 - }, - "subject": { - "type": "string", - "maxLength": 255 - }, - "type": { - "type": "string", - "maxLength": 20 - } - } - }, - "updater.Status": { - "type": "object", - "properties": { - "asset_name": { - "type": "string" - }, - "build_time": { - "type": "string" - }, - "can_upgrade": { - "type": "boolean" - }, - "current_version": { - "type": "string" - }, - "latest_version": { - "type": "string" - }, - "platform": { - "type": "string" - }, - "prerelease": { - "type": "boolean" - }, - "published_at": { - "type": "string" - }, - "release_name": { - "type": "string" - }, - "release_notes": { - "type": "string" - }, - "release_url": { - "type": "string" - }, - "update_available": { - "type": "boolean" - }, - "upstream_repository": { - "type": "string" - } - } - }, - "user.changePasswordRequest": { - "type": "object", - "properties": { - "new_password": { - "type": "string" - }, - "old_password": { - "type": "string" - } - } - }, - "user.createTokenRequest": { - "type": "object", - "properties": { - "is_admin": { - "type": "boolean" - }, - "name": { - "type": "string" - } - } - }, - "user.createUserRequest": { - "type": "object", - "required": [ - "email", - "password", - "username" - ], - "properties": { - "email": { - "type": "string", - "maxLength": 255 - }, - "is_active": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - }, - "nickname": { - "type": "string", - "maxLength": 64 - }, - "password": { - "type": "string", - "maxLength": 64, - "minLength": 8 - }, - "username": { - "type": "string", - "maxLength": 64, - "minLength": 3 - } - } - }, - "user.listUsersResponse": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "users": { - "type": "array", - "items": { - "$ref": "#/definitions/user.user" - } - } - } - }, - "user.loginRequest": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "password": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "user.registerRequest": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "email": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "password": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "user.sendEmailCodeRequest": { - "type": "object", - "required": [ - "email", - "scene" - ], - "properties": { - "email": { - "type": "string" - }, - "scene": { - "type": "string" - } - } - }, - "user.tokenResponse": { - "type": "object", - "properties": { - "record": { - "$ref": "#/definitions/model.AccessToken" - }, - "token": { - "type": "string" - } - } - }, - "user.updateProfileRequest": { - "type": "object", - "properties": { - "avatar_url": { - "type": "string" - }, - "bio": { - "type": "string" - }, - "email": { - "type": "string" - }, - "gender": { - "type": "string" - }, - "location": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "phone": { - "type": "string" - }, - "website": { - "type": "string" - } - } - }, - "user.updateUserRequest": { - "type": "object", - "required": [ - "email" - ], - "properties": { - "email": { - "type": "string", - "maxLength": 255 - }, - "is_admin": { - "type": "boolean" - }, - "nickname": { - "type": "string", - "maxLength": 64 - }, - "password": { - "type": "string", - "maxLength": 64, - "minLength": 8 - } - } - }, - "user.updateUserStatusRequest": { - "type": "object", - "properties": { - "is_active": { - "type": "boolean" - } - } - }, - "user.user": { - "type": "object", - "properties": { - "avatar_url": { - "type": "string" - }, - "bio": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "email": { - "type": "string" - }, - "gender": { - "type": "string" - }, - "id": { - "type": "string", - "example": "0" - }, - "is_active": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - }, - "last_login_at": { - "type": "string" - }, - "location": { - "type": "string" - }, - "nickname": { - "type": "string" - }, - "phone": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "username": { - "type": "string" - }, - "website": { - "type": "string" - } - } } }, "securityDefinitions": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d8c83c93..f9880bba 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,65 +1,74 @@ basePath: / definitions: - auth_source.AuthSourceRequest: + admin.CreateScheduleRequest: properties: - client_id: - type: string - client_secret: - type: string - display_name: - type: string - icon_url: + cron: type: string is_active: type: boolean name: type: string - openid_discovery_url: + payload: type: string - scopes: + task_type: + type: string + required: + - cron + - is_active + - name + - task_type + type: object + admin.CreateSystemConfigRequest: + properties: + description: + maxLength: 255 + type: string + key: + maxLength: 64 type: string type: + enum: + - system + - business type: string - type: object - auth_source.ToggleAuthSourceRequest: - properties: - is_active: - type: boolean - type: object - cache.updateCacheConfigRequest: - properties: - lru_enabled: - type: boolean - max_size_mb: - minimum: 1 - type: integer - ttl_minutes: - minimum: 0 + value: + type: string + visibility: + enum: + - 0 + - 1 type: integer required: - - max_size_mb - - ttl_minutes + - key + - type + - value type: object - cap.challengeRequest: + admin.CreateTemplateRequest: properties: - scope: + content: type: string - type: object - cap.redeemRequest: - properties: - scope: + description: + maxLength: 255 type: string - solutions: - items: - type: integer - type: array - token: + key: + maxLength: 80 + type: string + name: + maxLength: 100 + type: string + subject: + maxLength: 255 + type: string + type: + maxLength: 20 type: string required: - - solutions - - token + - content + - key + - name + - type type: object - db_manage.DBOverviewResponse: + admin.DBOverviewResponse: properties: connections: type: integer @@ -74,14 +83,38 @@ definitions: version: type: string type: object - db_manage.ExecuteSQLRequest: + admin.DatabaseInfoResponse: + properties: + name: + type: string + type: + type: string + version: + type: string + type: object + admin.DispatchTaskRequest: + properties: + end_time: + type: string + payload: + type: string + start_time: + type: string + task_type: + type: string + user_id: + type: integer + required: + - task_type + type: object + admin.ExecuteSQLRequest: properties: sql: type: string required: - sql type: object - db_manage.ExecuteSQLResponse: + admin.ExecuteSQLResponse: properties: affected_rows: type: integer @@ -100,510 +133,22 @@ definitions: description: '"select" 或 "exec"' type: string type: object - diskcache.Status: + admin.LogDatabaseStatus: properties: - base_path: + active_database: type: string - keys_count: - type: integer - lru_enabled: - type: boolean - max_size_mb: - type: integer - total_size: - type: integer - ttl_minutes: - type: integer - type: object - github_com_Rain-kl_Wavelet_internal_apps_cap.ChallengeResponse: - properties: - challenge: - properties: - c: - type: integer - d: - type: integer - s: - type: integer + available_targets: + items: + type: string + type: array + migration: + type: string + retention_days: + additionalProperties: + type: integer type: object - expires: - description: ms timestamp - type: integer - token: - type: string type: object - github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse: - properties: - error: - type: string - expires: - type: integer - success: - type: boolean - token: - type: string - type: object - handler.batchDownloadRequest: - properties: - ids: - items: - type: string - minItems: 1 - type: array - required: - - ids - type: object - handler.distributionItem: - properties: - count: - type: integer - name: - type: string - size: - type: integer - type: object - handler.fileStatsResponse: - properties: - categories: - items: - $ref: '#/definitions/handler.distributionItem' - type: array - total_count: - type: integer - total_size: - type: integer - trend: - items: - $ref: '#/definitions/handler.trendItem' - type: array - types: - items: - $ref: '#/definitions/handler.distributionItem' - type: array - type: object - handler.listFilesResponse: - properties: - items: - items: - $ref: '#/definitions/model.Upload' - type: array - page: - type: integer - page_size: - type: integer - total: - type: integer - type: object - handler.listMyFilesResponse: - properties: - items: - items: - $ref: '#/definitions/model.Upload' - type: array - page: - type: integer - page_size: - type: integer - total: - type: integer - type: object - handler.trendItem: - properties: - count: - type: integer - date: - type: string - size: - type: integer - type: object - handler.updateMyFileRequest: - properties: - access_mode: - enum: - - 0 - - 1 - type: integer - file_name: - maxLength: 255 - type: string - type: object - logger.LogEntry: - properties: - data: - description: 一行日志原文(含换行符) - type: string - index: - description: 全局递增序号 - type: integer - type: object - logs.accessLogItem: - properties: - created_at: - type: string - headers: - type: string - id: - example: "0" - type: string - ip: - type: string - latency: - type: integer - method: - type: string - nickname: - type: string - path: - type: string - status: - type: integer - user_agent: - type: string - user_id: - example: "0" - type: string - username: - type: string - type: object - logs.accessLogsResponse: - properties: - list: - items: - $ref: '#/definitions/logs.accessLogItem' - type: array - total: - type: integer - type: object - logs.browserItem: - properties: - browser: - type: string - count: - type: integer - type: object - logs.logsAnalyticsResponse: - properties: - browsers: - items: - $ref: '#/definitions/logs.browserItem' - type: array - top_users: - items: - $ref: '#/definitions/logs.topUserItem' - type: array - trend: - items: - $ref: '#/definitions/logs.trendItem' - type: array - type: object - logs.logsResponse: - properties: - has_more: - type: boolean - lines: - items: - $ref: '#/definitions/logger.LogEntry' - type: array - next_cursor: - description: 用于加载更早日志的 cursor - type: integer - type: object - logs.topUserItem: - properties: - count: - type: integer - nickname: - type: string - user_id: - example: "0" - type: string - username: - type: string - type: object - logs.trendItem: - properties: - count: - type: integer - date: - type: string - type: object - message_gateway.BindRequest: - properties: - channel_id: - type: string - code: - type: string - type: object - message_gateway.BindingDTO: - properties: - channel_id: - example: "0" - type: string - channel_name: - type: string - channel_type: - type: string - created_at: - type: string - id: - example: "0" - type: string - platform_user_id: - type: string - user_id: - example: "0" - type: string - type: object - message_gateway.ChannelDTO: - properties: - app_id: - type: string - app_secret: - type: string - base_url: - type: string - bot_token: - type: string - created_at: - type: string - enabled: - type: boolean - id: - example: "0" - type: string - name: - type: string - owner_scope: - type: string - portal_host: - type: string - type: - type: string - updated_at: - type: string - type: object - message_gateway.CreateChannelRequest: - properties: - app_id: - type: string - app_secret: - type: string - base_url: - type: string - bot_token: - type: string - enabled: - type: boolean - name: - type: string - portal_host: - type: string - sandbox: - type: string - type: - type: string - type: object - message_gateway.Definition: - properties: - fields: - items: - $ref: '#/definitions/message_gateway.Field' - type: array - name: - type: string - type: - type: string - type: object - message_gateway.Field: - properties: - key: - type: string - required: - type: boolean - type: - type: string - type: object - message_gateway.PublicChannelDTO: - properties: - id: - example: "0" - type: string - name: - type: string - type: - type: string - type: object - message_gateway.UpdateChannelRequest: - properties: - app_id: - type: string - app_secret: - type: string - base_url: - type: string - bot_token: - type: string - enabled: - type: boolean - name: - type: string - portal_host: - type: string - sandbox: - type: string - type: object - model.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 - model.AuthSource: - properties: - client_id: - type: string - client_secret_configured: - type: boolean - created_at: - type: string - display_name: - type: string - icon_url: - type: string - id: - type: integer - is_active: - type: boolean - name: - type: string - openid_discovery_url: - type: string - scopes: - type: string - type: - type: string - updated_at: - type: string - type: object - model.ExternalAccountView: - properties: - auth_source_id: - type: integer - auth_source_label: - type: string - auth_source_name: - type: string - auth_source_type: - type: string - created_at: - type: string - email: - type: string - external_username: - type: string - id: - type: integer - type: object - model.PushChannel: - properties: - created_at: - type: string - description: - description: 备注 - type: string - enabled: - description: 通道是否启用 - type: boolean - id: - type: integer - name: - description: 通道名称,仅英文字母和下划线,唯一 - type: string - other: - description: 请求体/SMTP 密码等 - type: string - token: - description: 鉴权令牌或发信用户名等 - type: string - type: - description: 通道类型:custom, lark, email - type: string - updated_at: - type: string - url: - description: 请求地址,HTTPS 协议或 SMTP 地址 - type: string - type: object - model.PushEvent: - properties: - channels: - description: 推送渠道列表,如 ["lark"] - items: - type: string - type: array - created_at: - type: string - enabled: - description: 是否启用 - type: boolean - event_key: - description: 如 admin_login - type: string - id: - type: integer - name: - description: 如 管理员登录 - type: string - targets: - description: 推送目标用户/邮箱列表 - items: - type: string - type: array - task_type: - description: 关联的异步任务类型 - type: string - template: - description: 消息模板 JSON - type: string - updated_at: - type: string - type: object - model.PushHistory: - properties: - channel: - type: string - content: - type: string - created_at: - type: string - error_msg: - type: string - event_key: - type: string - id: - type: integer - level: - type: string - status: - description: success / failed - type: string - target: - type: string - title: - type: string - type: object - model.Schedule: + admin.Schedule: properties: created_at: type: string @@ -623,7 +168,7 @@ definitions: updated_at: type: string type: object - model.SystemConfig: + admin.SystemConfig: properties: created_at: type: string @@ -640,456 +185,7 @@ definitions: visibility: type: integer type: object - model.TaskExecution: - properties: - created_at: - type: string - duration: - type: integer - error_message: - type: string - finished_at: - type: string - id: - example: "0" - type: string - log: - type: string - max_retry: - type: integer - payload: - type: string - result: - type: string - retry_count: - type: integer - retryable: - type: boolean - started_at: - type: string - status: - $ref: '#/definitions/model.TaskExecutionStatus' - task_id: - type: string - task_name: - type: string - task_type: - type: string - triggered_by: - type: string - updated_at: - type: string - type: object - model.TaskExecutionStatus: - enum: - - pending - - running - - succeeded - - failed - type: string - x-enum-varnames: - - TaskExecutionStatusPending - - TaskExecutionStatusRunning - - TaskExecutionStatusSucceeded - - TaskExecutionStatusFailed - model.Template: - properties: - content: - type: string - created_at: - type: string - description: - type: string - id: - type: integer - is_system: - type: boolean - key: - type: string - name: - type: string - subject: - type: string - type: - type: string - updated_at: - type: string - type: object - model.Upload: - properties: - access_mode: - type: integer - created_at: - type: string - extension: - description: 文件后缀名 (不含点,如 png, pdf) - type: string - file_name: - description: '原始文件名 (例如: image.png)' - type: string - file_path: - description: 文件相对路径 / S3 Key - type: string - file_size: - description: 文件大小(字节) - type: integer - hash: - description: 文件哈希 (SHA-256/MD5,可用于排重) - type: string - id: - example: "0" - type: string - metadata: - allOf: - - $ref: '#/definitions/model.UploadMetadata' - description: 业务扩展元数据 - mime_type: - description: 媒体类型 (MIME, 如 image/png) - type: string - status: - allOf: - - $ref: '#/definitions/model.UploadStatus' - description: 状态 - type: - description: 业务标识类型 (如 avatar, doc, attachment) - type: string - updated_at: - type: string - user_id: - example: "0" - type: string - type: object - model.UploadMetadata: - properties: - bucket: - description: 存储桶名称 (适用于 S3 等) - type: string - client_ip: - description: 上传者 IP - type: string - duration: - description: 音视频时长 (s) - type: number - extra: - additionalProperties: {} - description: 其它任意业务自定义元数据 - type: object - height: - description: 图像/视频高度 (px) - type: integer - original_mime: - description: 原始 MIME 类型 - type: string - user_agent: - description: 上传者的 UA - type: string - width: - description: 图像/视频宽度 (px) - type: integer - type: object - model.UploadStatus: - enum: - - pending - - used - - deleted - type: string - x-enum-comments: - UploadStatusDeleted: 已删除 - UploadStatusPending: 待使用 - UploadStatusUsed: 已使用 - x-enum-descriptions: - - 待使用 - - 已使用 - - 已删除 - x-enum-varnames: - - UploadStatusPending - - UploadStatusUsed - - UploadStatusDeleted - oauth.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 - oauth.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 - oauth.CallbackRequest: - properties: - code: - type: string - state: - type: string - required: - - code - - state - type: object - oauth.OAuthAuthorizeResponse: - properties: - authorize_url: - type: string - type: object - oauth.OAuthCallbackResult: - properties: - status: - type: string - user: - $ref: '#/definitions/oauth.BasicUserInfo' - type: object - 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 - push.CreateChannelRequest: - 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 - push.CreateEventRequest: - properties: - channels: - items: - type: string - type: array - enabled: - type: boolean - event_key: - type: string - targets: - items: - type: string - type: array - task_type: - description: 关联的异步任务类型 - type: string - template: - type: string - type: object - push.Definition: - properties: - description: - description: short description - type: string - fields: - description: form fields - items: - $ref: '#/definitions/push.Field' - type: array - name: - description: display name - type: string - type: - description: channel type (e.g., custom, lark, email) - type: string - type: object - push.EventMetadata: - properties: - default_template: - $ref: '#/definitions/push.NotificationMessage' - description: - type: string - key: - type: string - name: - type: string - type: object - push.Field: - properties: - description: - description: field explanation/help text - type: string - key: - description: unique key for the field (e.g. url, token, other) - type: string - label: - description: human readable label (e.g. "Webhook 地址") - type: string - placeholder: - description: input placeholder - type: string - required: - description: whether this field is required - type: boolean - type: - description: 'input type: "text" | "password" | "textarea"' - type: string - type: object - push.NotificationMessage: - properties: - content: - type: string - ext: - additionalProperties: {} - type: object - level: - type: string - title: - type: string - type: object - push.TestChannelRequest: - properties: - name: - type: string - other: - type: string - target: - type: string - token: - type: string - type: - type: string - url: - type: string - type: object - push.TestPushRequest: - properties: - config: - $ref: '#/definitions/push.Config' - target: - type: string - required: - - config - type: object - push.UpdateChannelRequest: - properties: - description: - type: string - enabled: - type: boolean - other: - type: string - token: - type: string - type: - type: string - url: - type: string - required: - - type - type: object - push.UpdateEventRequest: - properties: - channels: - items: - type: string - type: array - enabled: - type: boolean - targets: - items: - type: string - type: array - template: - type: string - required: - - template - type: object - push.pushHistoriesResponse: - properties: - results: - items: - $ref: '#/definitions/model.PushHistory' - type: array - total: - type: integer - type: object - response.Any: - properties: - data: {} - error_msg: - example: "" - type: string - type: object - status.DatabaseInfoResponse: - properties: - name: - type: string - type: - type: string - version: - type: string - type: object - status.LogDatabaseStatus: - properties: - active_database: - type: string - available_targets: - items: - type: string - type: array - migration: - type: string - retention_days: - additionalProperties: - type: integer - type: object - type: object - status.SystemStatusResponse: + admin.SystemStatusResponse: properties: alloc: type: string @@ -1148,32 +244,82 @@ definitions: uptime: type: string type: object - system_config.CreateSystemConfigRequest: + admin.TaskExecution: properties: - description: - maxLength: 255 + created_at: type: string + duration: + type: integer + error_message: + type: string + finished_at: + type: string + id: + example: "0" + type: string + log: + type: string + max_retry: + type: integer + payload: + type: string + result: + type: string + retry_count: + type: integer + retryable: + type: boolean + started_at: + type: string + status: + $ref: '#/definitions/admin.TaskExecutionStatus' + task_id: + type: string + task_name: + type: string + task_type: + type: string + triggered_by: + type: string + updated_at: + type: string + type: object + admin.TaskExecutionStatus: + enum: + - pending + - running + - succeeded + - failed + type: string + x-enum-varnames: + - TaskExecutionStatusPending + - TaskExecutionStatusRunning + - TaskExecutionStatusSucceeded + - TaskExecutionStatusFailed + admin.Template: + properties: + content: + type: string + created_at: + type: string + description: + type: string + id: + type: integer + is_system: + type: boolean key: - maxLength: 64 + type: string + name: + type: string + subject: type: string type: - enum: - - system - - business type: string - value: + updated_at: type: string - visibility: - enum: - - 0 - - 1 - type: integer - required: - - key - - type - - value type: object - system_config.TestSMTPRequest: + admin.TestSMTPRequest: properties: smtp_host: maxLength: 255 @@ -1195,7 +341,7 @@ definitions: - smtp_username - to type: object - system_config.TestSMTPResponse: + admin.TestSMTPResponse: properties: error: type: string @@ -1204,22 +350,7 @@ definitions: success: type: boolean type: object - system_config.UpdateSystemConfigRequest: - properties: - description: - maxLength: 255 - type: string - value: - type: string - visibility: - enum: - - 0 - - 1 - type: integer - required: - - value - type: object - task.CreateScheduleRequest: + admin.UpdateScheduleRequest: properties: cron: type: string @@ -1237,20 +368,610 @@ definitions: - name - task_type type: object - task.DispatchTaskRequest: + admin.UpdateSystemConfigRequest: properties: - end_time: + description: + maxLength: 255 type: string - payload: + value: type: string - start_time: - type: string - task_type: - type: string - user_id: + visibility: + enum: + - 0 + - 1 type: integer required: - - task_type + - value + type: object + admin.UpdateTemplateRequest: + properties: + content: + type: string + description: + maxLength: 255 + type: string + name: + maxLength: 100 + type: string + subject: + maxLength: 255 + type: string + type: + maxLength: 20 + type: string + required: + - content + - name + - type + type: object + admin.UpdaterStatus: + properties: + asset_name: + type: string + build_time: + type: string + can_upgrade: + type: boolean + current_version: + type: string + latest_version: + type: string + platform: + type: string + prerelease: + type: boolean + published_at: + type: string + release_name: + type: string + release_notes: + type: string + release_url: + type: string + update_available: + type: boolean + upstream_repository: + type: string + type: object + admin.accessLogItem: + properties: + created_at: + type: string + headers: + type: string + id: + example: "0" + type: string + ip: + type: string + latency: + type: integer + method: + type: string + nickname: + type: string + path: + type: string + status: + type: integer + user_agent: + type: string + user_id: + example: "0" + type: string + username: + type: string + type: object + admin.accessLogsResponse: + properties: + list: + items: + $ref: '#/definitions/admin.accessLogItem' + type: array + total: + type: integer + type: object + admin.browserItem: + properties: + browser: + type: string + count: + type: integer + type: object + admin.createUserRequest: + properties: + email: + maxLength: 255 + type: string + is_active: + type: boolean + is_admin: + type: boolean + nickname: + maxLength: 64 + type: string + password: + maxLength: 64 + minLength: 8 + type: string + username: + maxLength: 64 + minLength: 3 + type: string + required: + - email + - password + - username + type: object + admin.listUsersResponse: + properties: + total: + type: integer + users: + items: + $ref: '#/definitions/admin.userResponse' + type: array + type: object + admin.logsAnalyticsResponse: + properties: + browsers: + items: + $ref: '#/definitions/admin.browserItem' + type: array + top_users: + items: + $ref: '#/definitions/admin.topUserItem' + type: array + trend: + items: + $ref: '#/definitions/admin.trendItem' + type: array + type: object + admin.logsResponse: + properties: + has_more: + type: boolean + lines: + items: + $ref: '#/definitions/logger.LogEntry' + type: array + next_cursor: + description: 用于加载更早日志的 cursor + type: integer + type: object + admin.topUserItem: + properties: + count: + type: integer + nickname: + type: string + user_id: + example: "0" + type: string + username: + type: string + type: object + admin.trendItem: + properties: + count: + type: integer + date: + type: string + type: object + admin.updateCacheConfigRequest: + properties: + lru_enabled: + type: boolean + max_size_mb: + minimum: 1 + type: integer + ttl_minutes: + minimum: 0 + type: integer + required: + - max_size_mb + - ttl_minutes + type: object + admin.updateUserRequest: + properties: + email: + maxLength: 255 + type: string + is_admin: + type: boolean + nickname: + maxLength: 64 + type: string + password: + maxLength: 64 + minLength: 8 + type: string + required: + - email + type: object + admin.updateUserStatusRequest: + properties: + is_active: + type: boolean + type: object + admin.userResponse: + properties: + avatar_url: + type: string + bio: + type: string + created_at: + type: string + email: + type: string + gender: + type: string + id: + example: "0" + type: string + is_active: + type: boolean + is_admin: + type: boolean + last_login_at: + type: string + location: + type: string + nickname: + type: string + phone: + type: string + updated_at: + type: string + username: + type: string + website: + type: string + type: object + cap.challengeRequest: + properties: + scope: + type: string + type: object + cap.redeemRequest: + properties: + scope: + type: string + solutions: + items: + type: integer + type: array + token: + type: string + required: + - solutions + - token + type: object + diskcache.Status: + properties: + base_path: + type: string + keys_count: + type: integer + lru_enabled: + type: boolean + max_size_mb: + type: integer + total_size: + type: integer + ttl_minutes: + type: integer + type: object + github_com_Rain-kl_Wavelet_plugins_domain_cap.ChallengeResponse: + properties: + challenge: + properties: + c: + type: integer + d: + type: integer + s: + type: integer + type: object + expires: + description: ms timestamp + type: integer + token: + type: string + type: object + github_com_Rain-kl_Wavelet_plugins_domain_cap.RedeemResponse: + properties: + error: + type: string + expires: + type: integer + success: + type: boolean + token: + type: string + type: object + handler.batchDownloadRequest: + properties: + ids: + items: + type: string + minItems: 1 + type: array + required: + - ids + type: object + handler.distributionItem: + properties: + count: + type: integer + name: + type: string + size: + type: integer + type: object + handler.fileStatsResponse: + properties: + categories: + items: + $ref: '#/definitions/handler.distributionItem' + type: array + total_count: + type: integer + total_size: + type: integer + trend: + items: + $ref: '#/definitions/handler.trendItem' + type: array + types: + items: + $ref: '#/definitions/handler.distributionItem' + type: array + type: object + handler.listFilesResponse: + properties: + items: + items: + $ref: '#/definitions/models.Upload' + type: array + page: + type: integer + page_size: + type: integer + total: + type: integer + type: object + handler.listMyFilesResponse: + properties: + items: + items: + $ref: '#/definitions/models.Upload' + type: array + page: + type: integer + page_size: + type: integer + total: + type: integer + type: object + handler.trendItem: + properties: + count: + type: integer + date: + type: string + size: + type: integer + type: object + handler.updateMyFileRequest: + properties: + access_mode: + enum: + - 0 + - 1 + type: integer + file_name: + maxLength: 255 + type: string + type: object + logger.LogEntry: + properties: + data: + description: 一行日志原文(含换行符) + type: string + index: + description: 全局递增序号 + type: integer + type: object + message_gateway.BindRequest: + properties: + channel_id: + type: string + code: + type: string + type: object + message_gateway.BindingDTO: + properties: + channel_id: + example: "0" + type: string + channel_name: + type: string + channel_type: + type: string + created_at: + type: string + id: + example: "0" + type: string + platform_user_id: + type: string + user_id: + example: "0" + type: string + type: object + message_gateway.ChannelDTO: + properties: + credentials: + additionalProperties: + type: string + type: object + enabled: + type: boolean + extra: + additionalProperties: + type: string + type: object + id: + example: "0" + type: string + name: + type: string + owner_id: + example: "0" + type: string + owner_scope: + type: string + type: + type: string + type: object + message_gateway.CreateChannelRequest: + properties: + credentials: + additionalProperties: + type: string + type: object + enabled: + type: boolean + extra: + additionalProperties: + type: string + type: object + name: + type: string + type: + type: string + type: object + message_gateway.Definition: + properties: + fields: + items: + $ref: '#/definitions/message_gateway.Field' + type: array + type: + type: string + type: object + message_gateway.Field: + properties: + key: + type: string + required: + type: boolean + type: + type: string + type: object + message_gateway.PublicChannelDTO: + properties: + id: + example: "0" + type: string + name: + type: string + type: + type: string + type: object + message_gateway.UpdateChannelRequest: + properties: + credentials: + additionalProperties: + type: string + type: object + enabled: + type: boolean + extra: + additionalProperties: + type: string + type: object + name: + type: string + type: object + models.Upload: + properties: + access_mode: + type: integer + created_at: + type: string + extension: + type: string + file_name: + type: string + file_path: + type: string + file_size: + type: integer + hash: + type: string + id: + example: "0" + type: string + metadata: + $ref: '#/definitions/models.UploadMetadata' + mime_type: + type: string + status: + $ref: '#/definitions/models.UploadStatus' + type: + type: string + updated_at: + type: string + user_id: + example: "0" + type: string + type: object + models.UploadMetadata: + properties: + bucket: + type: string + client_ip: + type: string + duration: + type: number + extra: + additionalProperties: {} + type: object + height: + type: integer + original_mime: + type: string + user_agent: + type: string + width: + type: integer + type: object + models.UploadStatus: + enum: + - pending + - used + - deleted + type: string + x-enum-comments: + UploadStatusDeleted: 已删除 + UploadStatusPending: 待使用 + UploadStatusUsed: 已使用 + x-enum-descriptions: + - 待使用 + - 已使用 + - 已删除 + x-enum-varnames: + - UploadStatusPending + - UploadStatusUsed + - UploadStatusDeleted + response.Any: + properties: + data: {} + error_msg: + example: "" + type: string type: object task.TaskMeta: properties: @@ -1297,263 +1018,6 @@ definitions: description: 类型:string, text, number, boolean type: string type: object - task.UpdateScheduleRequest: - properties: - cron: - type: string - is_active: - type: boolean - name: - type: string - payload: - type: string - task_type: - type: string - required: - - cron - - is_active - - name - - task_type - type: object - template.CreateTemplateRequest: - properties: - content: - type: string - description: - maxLength: 255 - type: string - key: - maxLength: 80 - type: string - name: - maxLength: 100 - type: string - subject: - maxLength: 255 - type: string - type: - maxLength: 20 - type: string - required: - - content - - key - - name - - type - type: object - template.UpdateTemplateRequest: - properties: - content: - type: string - description: - maxLength: 255 - type: string - name: - maxLength: 100 - type: string - subject: - maxLength: 255 - type: string - type: - maxLength: 20 - type: string - required: - - content - - name - - type - type: object - updater.Status: - properties: - asset_name: - type: string - build_time: - type: string - can_upgrade: - type: boolean - current_version: - type: string - latest_version: - type: string - platform: - type: string - prerelease: - type: boolean - published_at: - type: string - release_name: - type: string - release_notes: - type: string - release_url: - type: string - update_available: - type: boolean - upstream_repository: - type: string - type: object - user.changePasswordRequest: - properties: - new_password: - type: string - old_password: - type: string - type: object - user.createTokenRequest: - properties: - is_admin: - type: boolean - name: - type: string - type: object - user.createUserRequest: - properties: - email: - maxLength: 255 - type: string - is_active: - type: boolean - is_admin: - type: boolean - nickname: - maxLength: 64 - type: string - password: - maxLength: 64 - minLength: 8 - type: string - username: - maxLength: 64 - minLength: 3 - type: string - required: - - email - - password - - username - type: object - user.listUsersResponse: - properties: - total: - type: integer - users: - items: - $ref: '#/definitions/user.user' - type: array - type: object - user.loginRequest: - properties: - code: - type: string - password: - type: string - username: - type: string - type: object - user.registerRequest: - properties: - code: - type: string - display_name: - type: string - email: - type: string - nickname: - type: string - password: - type: string - username: - type: string - type: object - user.sendEmailCodeRequest: - properties: - email: - type: string - scene: - type: string - required: - - email - - scene - type: object - user.tokenResponse: - properties: - record: - $ref: '#/definitions/model.AccessToken' - token: - type: string - type: object - user.updateProfileRequest: - properties: - avatar_url: - type: string - bio: - type: string - email: - type: string - gender: - type: string - location: - type: string - nickname: - type: string - phone: - type: string - website: - type: string - type: object - user.updateUserRequest: - properties: - email: - maxLength: 255 - type: string - is_admin: - type: boolean - nickname: - maxLength: 64 - type: string - password: - maxLength: 64 - minLength: 8 - type: string - required: - - email - type: object - user.updateUserStatusRequest: - properties: - is_active: - type: boolean - type: object - user.user: - properties: - avatar_url: - type: string - bio: - type: string - created_at: - type: string - email: - type: string - gender: - type: string - id: - example: "0" - type: string - is_active: - type: boolean - is_admin: - type: boolean - last_login_at: - type: string - location: - type: string - nickname: - type: string - phone: - type: string - updated_at: - type: string - username: - type: string - website: - type: string - type: object info: contact: name: Wavelet @@ -1586,7 +1050,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.ChallengeResponse' + $ref: '#/definitions/github_com_Rain-kl_Wavelet_plugins_domain_cap.ChallengeResponse' type: object "500": description: 内部服务错误 @@ -1617,7 +1081,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/github_com_Rain-kl_Wavelet_internal_apps_cap.RedeemResponse' + $ref: '#/definitions/github_com_Rain-kl_Wavelet_plugins_domain_cap.RedeemResponse' type: object "400": description: 参数错误或核销失败 @@ -1630,234 +1094,6 @@ paths: summary: 校验人机验证解答 tags: - cap - /api/health: - get: - description: 检查服务是否正常运行,可用于负载均衡存活探测 - produces: - - application/json - responses: - "200": - description: 服务正常 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - summary: 健康检查 - tags: - - health - /api/v1/admin/auth-sources: - get: - description: 返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 认证源列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/model.AuthSource' - type: array - type: object - "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: - - admin - post: - consumes: - - application/json - description: 创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限 - parameters: - - description: 创建认证源参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/auth_source.AuthSourceRequest' - produces: - - application/json - responses: - "200": - description: 创建成功,返回认证源信息 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.AuthSource' - 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/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: - type: string - type: object - "400": - 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/auth_source.AuthSourceRequest' - produces: - - application/json - responses: - "200": - description: 更新成功,返回更新后的认证源信息 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/model.AuthSource' - type: object - "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' - security: - - SessionCookie: [] - summary: 更新认证源 - tags: - - 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 - - description: 启用状态 - in: body - name: request - required: true - schema: - $ref: '#/definitions/auth_source.ToggleAuthSourceRequest' - 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 追踪数据 @@ -1896,7 +1132,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/cache.updateCacheConfigRequest' + $ref: '#/definitions/admin.updateCacheConfigRequest' produces: - application/json responses: @@ -1997,7 +1233,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/status.DatabaseInfoResponse' + $ref: '#/definitions/admin.DatabaseInfoResponse' type: object "401": description: 未登录 @@ -2025,7 +1261,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/db_manage.DBOverviewResponse' + $ref: '#/definitions/admin.DBOverviewResponse' type: object "401": description: 未登录 @@ -2055,7 +1291,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/db_manage.ExecuteSQLRequest' + $ref: '#/definitions/admin.ExecuteSQLRequest' produces: - application/json responses: @@ -2066,7 +1302,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/db_manage.ExecuteSQLResponse' + $ref: '#/definitions/admin.ExecuteSQLResponse' type: object "400": description: SQL 语句错误 @@ -2147,7 +1383,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/logs.logsResponse' + $ref: '#/definitions/admin.logsResponse' type: object "401": description: 未登录 @@ -2202,7 +1438,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/logs.accessLogsResponse' + $ref: '#/definitions/admin.accessLogsResponse' type: object "400": description: 参数错误 @@ -2238,7 +1474,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/logs.logsAnalyticsResponse' + $ref: '#/definitions/admin.logsAnalyticsResponse' type: object "401": description: 未登录 @@ -2435,381 +1671,6 @@ 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/push.CreateChannelRequest' - 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/push.UpdateChannelRequest' - 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: 返回系统支持的所有消息通道类型(如飞书、邮件、自定义、Telegram)的动态表单定义,需要管理员权限 - produces: - - application/json - responses: - "200": - description: 通道配置定义列表 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/push.Definition' - type: array - type: object - 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/push.TestChannelRequest' - 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/push.CreateEventRequest' - 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/push.UpdateEventRequest' - 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: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/push.EventMetadata' - type: array - type: object - 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: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/push.pushHistoriesResponse' - type: object - 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/push.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、内存指标等详细统计数据,需要管理员权限 @@ -2823,7 +1684,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/status.SystemStatusResponse' + $ref: '#/definitions/admin.SystemStatusResponse' type: object "401": description: 未登录 @@ -2851,7 +1712,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/status.LogDatabaseStatus' + $ref: '#/definitions/admin.LogDatabaseStatus' type: object "401": description: 未登录 @@ -2889,7 +1750,7 @@ paths: - properties: data: items: - $ref: '#/definitions/model.SystemConfig' + $ref: '#/definitions/admin.SystemConfig' type: array type: object "401": @@ -2919,7 +1780,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/system_config.CreateSystemConfigRequest' + $ref: '#/definitions/admin.CreateSystemConfigRequest' produces: - application/json responses: @@ -2972,7 +1833,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/model.SystemConfig' + $ref: '#/definitions/admin.SystemConfig' type: object "401": description: 未登录 @@ -3010,7 +1871,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/system_config.UpdateSystemConfigRequest' + $ref: '#/definitions/admin.UpdateSystemConfigRequest' produces: - application/json responses: @@ -3059,7 +1920,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/system_config.TestSMTPRequest' + $ref: '#/definitions/admin.TestSMTPRequest' produces: - application/json responses: @@ -3070,7 +1931,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/system_config.TestSMTPResponse' + $ref: '#/definitions/admin.TestSMTPResponse' type: object "400": description: 参数错误 @@ -3092,7 +1953,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/task.DispatchTaskRequest' + $ref: '#/definitions/admin.DispatchTaskRequest' produces: - application/json responses: @@ -3192,7 +2053,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/model.TaskExecution' + $ref: '#/definitions/admin.TaskExecution' type: object "400": description: 参数错误 @@ -3275,7 +2136,7 @@ paths: - properties: data: items: - $ref: '#/definitions/model.Schedule' + $ref: '#/definitions/admin.Schedule' type: array type: object "401": @@ -3301,7 +2162,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/task.CreateScheduleRequest' + $ref: '#/definitions/admin.CreateScheduleRequest' produces: - application/json responses: @@ -3312,7 +2173,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/model.Schedule' + $ref: '#/definitions/admin.Schedule' type: object "400": description: Cron 表达式无效、异步任务类型不存在或参数错误 @@ -3392,7 +2253,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/task.UpdateScheduleRequest' + $ref: '#/definitions/admin.UpdateScheduleRequest' produces: - application/json responses: @@ -3403,7 +2264,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/model.Schedule' + $ref: '#/definitions/admin.Schedule' type: object "400": description: Cron 表达式无效、参数错误 @@ -3474,7 +2335,7 @@ paths: - properties: data: items: - $ref: '#/definitions/model.Template' + $ref: '#/definitions/admin.Template' type: array type: object "401": @@ -3504,7 +2365,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/template.CreateTemplateRequest' + $ref: '#/definitions/admin.CreateTemplateRequest' produces: - application/json responses: @@ -3602,7 +2463,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/model.Template' + $ref: '#/definitions/admin.Template' type: object "401": description: 未登录 @@ -3640,7 +2501,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/template.UpdateTemplateRequest' + $ref: '#/definitions/admin.UpdateTemplateRequest' produces: - application/json responses: @@ -3651,7 +2512,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/model.Template' + $ref: '#/definitions/admin.Template' type: object "400": description: 参数错误 @@ -3691,7 +2552,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/updater.Status' + $ref: '#/definitions/admin.UpdaterStatus' type: object "401": description: 未登录 @@ -3741,89 +2602,6 @@ 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 - format: int64 - 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 - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取文件列表 - tags: - - admin - /api/v1/admin/uploads/{id}: - delete: - description: 将文件状态置为 deleted(软删除),不会立即清理底层存储对象 - parameters: - - description: 文件 ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: 删除成功 - schema: - $ref: '#/definitions/response.Any' - "403": - 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) 强制开启客户端浏览器下载 @@ -3893,6 +2671,80 @@ paths: summary: 批量打包下载 tags: - admin + /api/v1/admin/uploads/files: + 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/files/{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/stats: get: description: 返回系统级的总文件数、占用大小、最近 7 天新增趋势、文件类型/格式分布等数据 @@ -3927,12 +2779,12 @@ paths: - admin /api/v1/admin/uploads/types: get: - description: 返回数据库中所有已上传文件实际拥有的业务类型列表 + description: 查询系统内所有不重复的上传业务分类标识(如 avatar, doc 等) produces: - application/json responses: "200": - description: 业务类型列表 + description: 查询成功 schema: allOf: - $ref: '#/definitions/response.Any' @@ -3942,21 +2794,9 @@ paths: type: string type: array type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - "403": - description: 无管理员权限 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 内部错误 - schema: - $ref: '#/definitions/response.Any' security: - SessionCookie: [] - summary: 获取文件业务类型列表 + summary: 获取业务分类列表 tags: - admin /api/v1/admin/users: @@ -3991,7 +2831,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/user.listUsersResponse' + $ref: '#/definitions/admin.listUsersResponse' type: object "400": description: 参数错误 @@ -4024,7 +2864,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/user.createUserRequest' + $ref: '#/definitions/admin.createUserRequest' produces: - application/json responses: @@ -4035,7 +2875,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/user.user' + $ref: '#/definitions/admin.userResponse' type: object "400": description: 参数错误或用户名已存在 @@ -4122,7 +2962,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/user.user' + $ref: '#/definitions/admin.userResponse' type: object "400": description: 参数错误 @@ -4164,7 +3004,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/user.updateUserRequest' + $ref: '#/definitions/admin.updateUserRequest' produces: - application/json responses: @@ -4218,7 +3058,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/user.updateUserStatusRequest' + $ref: '#/definitions/admin.updateUserStatusRequest' produces: - application/json responses: @@ -4271,24 +3111,6 @@ paths: summary: 获取公共配置 tags: - config - /api/v1/custom/hello: - get: - description: Scaffold demo API; product APIs use semantic paths under apps/ - produces: - - application/json - responses: - "200": - description: 成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - type: string - type: object - summary: Sample Hello API - tags: - - custom /api/v1/message-gateway/bindings: get: description: Returns the current user's bound messaging channels @@ -4407,245 +3229,6 @@ 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/oauth.OAuthAuthorizeResponse' - type: object - "400": - description: 认证源不存在或未启用 - schema: - $ref: '#/definitions/response.Any' - "500": - description: Redis 异常或构造 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/oauth.CallbackRequest' - produces: - - application/json - responses: - "200": - description: 登录或绑定成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/oauth.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: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - items: - $ref: '#/definitions/model.ExternalAccountView' - type: array - type: object - "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/oauth.OAuthAuthorizeResponse' - type: object - "400": - description: 认证源不存在或未配置 - schema: - $ref: '#/definitions/response.Any' - "500": - description: Redis 异常 or 构造 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/oauth.AuthSourceView' - type: array - type: object - summary: 获取可用登录源 - tags: - - oauth - /api/v1/oauth/user-info: - get: - description: 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。 - produces: - - application/json - responses: - "200": - description: 用户信息 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/oauth.BasicUserInfo' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取当前登录用户信息 - tags: - - oauth /api/v1/upload: post: consumes: @@ -4675,7 +3258,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/model.Upload' + $ref: '#/definitions/models.Upload' type: object "400": description: 请求参数错误或文件受限 @@ -4749,7 +3332,7 @@ paths: - $ref: '#/definitions/response.Any' - properties: data: - $ref: '#/definitions/model.Upload' + $ref: '#/definitions/models.Upload' type: object "403": description: 无权操作 @@ -4809,362 +3392,6 @@ paths: summary: 获取我的文件列表 tags: - upload - /api/v1/user-info: - get: - description: 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。 - produces: - - application/json - responses: - "200": - description: 用户信息 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/oauth.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/model.AccessToken' - type: array - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取当前用户的 AccessToken 列表 - tags: - - user - post: - consumes: - - application/json - description: 为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。可通过 is_admin 字段赋予令牌管理员权限(仅管理员用户可设置)。 - parameters: - - description: 令牌名称 - in: body - name: request - required: true - schema: - $ref: '#/definitions/user.createTokenRequest' - produces: - - application/json - responses: - "200": - description: 新建令牌成功 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/user.tokenResponse' - type: object - "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: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/user.tokenResponse' - type: object - "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: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/oauth.BasicUserInfo' - type: object - "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: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/oauth.BasicUserInfo' - type: object - "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。密码长度不能少于 8 位。 - parameters: - - description: 注册请求参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/user.registerRequest' - produces: - - application/json - responses: - "200": - description: 注册并登录成功,返回用户信息 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/oauth.BasicUserInfo' - type: object - "400": - description: 参数错误、用户名已存在或注册已关闭 - schema: - $ref: '#/definitions/response.Any' - "500": - description: 服务内部错误 - schema: - $ref: '#/definitions/response.Any' - summary: 用户注册 - tags: - - user - /api/v1/user/self: - get: - description: 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。 - produces: - - application/json - responses: - "200": - description: 用户信息 - schema: - allOf: - - $ref: '#/definitions/response.Any' - - properties: - data: - $ref: '#/definitions/oauth.BasicUserInfo' - type: object - "401": - description: 未登录 - schema: - $ref: '#/definitions/response.Any' - security: - - SessionCookie: [] - summary: 获取当前登录用户信息 - tags: - - oauth - /api/v1/user/send-email-code: - post: - consumes: - - application/json - description: 向指定邮箱发送验证码(用于注册场景) - parameters: - - description: 发送验证码请求参数 - in: body - name: request - required: true - schema: - $ref: '#/definitions/user.sendEmailCodeRequest' - 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/internal/listener/admin_login.go b/internal/listener/admin_login.go deleted file mode 100644 index 454e0eb7..00000000 --- a/internal/listener/admin_login.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package listener provides domain event dispatch for cross-module integration. -// Core domains emit events here; operational modules (push, webhooks, etc.) -// subscribe at the application composition root. -package listener - -import ( - "context" - - "github.com/Rain-kl/Wavelet/internal/model" -) - -// AdminLoggedIn is emitted when an administrator successfully authenticates. -type AdminLoggedIn struct { - User *model.User - IP string -} - -// AdminLoggedInHandler handles administrator login domain events. -type AdminLoggedInHandler func(ctx context.Context, event AdminLoggedIn) - -var adminLoggedInHandlers []AdminLoggedInHandler - -// OnAdminLoggedIn registers a handler for administrator login events. -// Handlers must be registered during application bootstrap before serving traffic. -func OnAdminLoggedIn(handler AdminLoggedInHandler) { - adminLoggedInHandlers = append(adminLoggedInHandlers, handler) -} - -// EmitAdminLoggedIn dispatches an administrator login event to all registered handlers. -func EmitAdminLoggedIn(ctx context.Context, user *model.User, ip string) { - if user == nil || !user.IsAdmin { - return - } - - event := AdminLoggedIn{User: user, IP: ip} - for _, handler := range adminLoggedInHandlers { - handler(ctx, event) - } -} diff --git a/internal/listener/message_gateway.go b/internal/listener/message_gateway.go deleted file mode 100644 index 575c85a0..00000000 --- a/internal/listener/message_gateway.go +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package listener - -import ( - "context" - - "github.com/Rain-kl/Wavelet/pkg/message_gateway" -) - -// EventMessageGatewayInbound is the domain event name for authorized inbound messages. -const EventMessageGatewayInbound = "message_gateway.inbound" - -// MessageGatewayInbound is emitted when a bound user sends a private message. -type MessageGatewayInbound struct { - Msg message_gateway.InboundMessage -} - -// MessageGatewayInboundHandler handles inbound messaging events. -type MessageGatewayInboundHandler func(ctx context.Context, event MessageGatewayInbound) - -var messageGatewayInboundHandlers []MessageGatewayInboundHandler - -// OnMessageGatewayInbound registers a handler. Call from bootstrap only. -func OnMessageGatewayInbound(handler MessageGatewayInboundHandler) { - messageGatewayInboundHandlers = append(messageGatewayInboundHandlers, handler) -} - -// EmitMessageGatewayInbound dispatches a bound inbound message. -func EmitMessageGatewayInbound(ctx context.Context, msg message_gateway.InboundMessage) { - if msg.BindingUserID == nil { - return - } - event := MessageGatewayInbound{Msg: msg} - for _, handler := range messageGatewayInboundHandlers { - handler(ctx, event) - } -} diff --git a/internal/listener/message_gateway_test.go b/internal/listener/message_gateway_test.go deleted file mode 100644 index edba8d97..00000000 --- a/internal/listener/message_gateway_test.go +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package listener - -import ( - "context" - "testing" - - "github.com/Rain-kl/Wavelet/pkg/message_gateway" -) - -func TestEmitMessageGatewayInbound_SkipsUnbound(t *testing.T) { - called := 0 - OnMessageGatewayInbound(func(ctx context.Context, ev MessageGatewayInbound) { called++ }) - EmitMessageGatewayInbound(context.Background(), message_gateway.InboundMessage{Text: "x"}) - if called != 0 { - t.Fatal("unbound must not emit") - } - uid := uint64(9) - EmitMessageGatewayInbound(context.Background(), message_gateway.InboundMessage{BindingUserID: &uid, Text: "x"}) - if called != 1 { - t.Fatalf("called=%d", called) - } -} diff --git a/internal/model/access_token.go b/internal/model/access_token.go deleted file mode 100644 index 91993a25..00000000 --- a/internal/model/access_token.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package model 定义数据模型与 GORM 实体 -package model - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "fmt" - "time" -) - -const ( - tokenByteLength = 24 // Token 随机字节长度 - maskThreshold = 8 // 脱敏显示阈值 -) - -// AccessToken 个人访问令牌实体 -type AccessToken struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` - UserID uint64 `json:"user_id" gorm:"index;not null"` - Name string `json:"name" gorm:"size:128;not null"` - TokenHash string `json:"-" gorm:"size:64;uniqueIndex;not null"` - MaskedToken string `json:"masked_token" gorm:"size:64;not null"` - IsAdmin bool `json:"is_admin" gorm:"default:false"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` -} - -// TableName 表名 -func (AccessToken) TableName() string { - return "w_access_tokens" -} - -// GenerateTokenString 生成加密安全的随机 Token 值 -func GenerateTokenString() (string, error) { - bytes := make([]byte, tokenByteLength) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - return fmt.Sprintf("at_%s", hex.EncodeToString(bytes)), nil -} - -// HashToken 计算 Token 的 SHA-256 哈希值用于数据库存储与查询 -func HashToken(token string) string { - h := sha256.New() - h.Write([]byte(token)) - return hex.EncodeToString(h.Sum(nil)) -} - -// MaskTokenString 生成脱敏显示的 Token,仅保留前缀和最后四位 -func MaskTokenString(token string) string { - if len(token) <= maskThreshold { - return "at_****" - } - return fmt.Sprintf("%s...%s", token[:7], token[len(token)-4:]) -} diff --git a/internal/model/auth_source.go b/internal/model/auth_source.go deleted file mode 100644 index 00996a9e..00000000 --- a/internal/model/auth_source.go +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "errors" - "regexp" - "strings" - "time" -) - -// 认证源类型 -const ( - AuthSourceTypeOIDC = "oidc" -) - -var authSourceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$`) - -// AuthSource 认证源实体 -type AuthSource struct { - ID uint64 `json:"id" gorm:"primaryKey"` - Name string `json:"name" gorm:"uniqueIndex;size:80;not null"` - Type string `json:"type" gorm:"size:20;not null"` - DisplayName string `json:"display_name" gorm:"size:100"` - IsActive bool `json:"is_active" gorm:"index;not null;default:false"` - ClientID string `json:"client_id" gorm:"size:255"` - ClientSecret string `json:"-" gorm:"size:1024"` - OpenIDDiscoveryURL string `json:"openid_discovery_url" gorm:"column:openid_discovery_url;size:1024"` - Scopes string `json:"scopes" gorm:"size:255"` - IconURL string `json:"icon_url" gorm:"size:1024"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ClientSecretConfigured bool `json:"client_secret_configured" gorm:"-"` -} - -// TableName 表名 -func (AuthSource) TableName() string { - return "w_auth_sources" -} - -// ExternalAccount 外部账号绑定实体 -type ExternalAccount struct { - ID uint64 `json:"id" gorm:"primaryKey"` - AuthSourceID uint64 `json:"auth_source_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:1;index"` - UserID uint64 `json:"user_id" gorm:"index;not null"` - ExternalID string `json:"external_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:2;size:255;not null"` - ExternalUsername string `json:"external_username" gorm:"size:255"` - Email string `json:"email" gorm:"size:255"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// TableName 表名 -func (ExternalAccount) TableName() string { - return "w_external_accounts" -} - -// ExternalAccountView 外部帐号绑定视图(脱敏展示用) -type ExternalAccountView struct { - ID uint64 `json:"id"` - AuthSourceID uint64 `json:"auth_source_id"` - AuthSourceName string `json:"auth_source_name"` - AuthSourceType string `json:"auth_source_type"` - AuthSourceLabel string `json:"auth_source_label"` - ExternalUsername string `json:"external_username"` - Email string `json:"email"` - CreatedAt time.Time `json:"created_at"` -} - -// Normalize 对认证源字段进行标准化处理 -func (source *AuthSource) Normalize() { - source.Type = strings.ToLower(strings.TrimSpace(source.Type)) - source.Name = strings.TrimSpace(source.Name) - source.DisplayName = strings.TrimSpace(source.DisplayName) - source.ClientID = strings.TrimSpace(source.ClientID) - source.ClientSecret = strings.TrimSpace(source.ClientSecret) - source.OpenIDDiscoveryURL = strings.TrimSpace(source.OpenIDDiscoveryURL) - source.Scopes = strings.TrimSpace(source.Scopes) - source.IconURL = strings.TrimSpace(source.IconURL) - if source.DisplayName == "" { - source.DisplayName = source.Name - } - if source.Type == AuthSourceTypeOIDC && source.Scopes == "" { - source.Scopes = "openid profile email" - } -} - -// Validate 校验认证源字段合法性 -func (source *AuthSource) Validate() error { - source.Normalize() - if source.Name == "" { - return errors.New(errAuthSourceNameRequired) - } - if !authSourceNamePattern.MatchString(source.Name) { - return errors.New(errAuthSourceNameInvalid) - } - if source.Type != AuthSourceTypeOIDC { - return errors.New(errAuthSourceTypeUnsupported) - } - if source.OpenIDDiscoveryURL == "" { - return errors.New(errAuthSourceDiscoveryURLRequired) - } - if source.IsActive && (source.ClientID == "" || source.ClientSecret == "") { - return errors.New(errAuthSourceClientCredentialsRequired) - } - return nil -} - -// Sanitize 脱敏处理,将 ClientSecret 清空并设置 ClientSecretConfigured 标志 -func (source *AuthSource) Sanitize() { - source.ClientSecretConfigured = source.ClientSecret != "" - source.ClientSecret = "" -} diff --git a/internal/model/errs.go b/internal/model/errs.go deleted file mode 100644 index 51da0c48..00000000 --- a/internal/model/errs.go +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -// Domain validation messages used by model.Validate and other no-IO rules. -// Persistence / data-access messages belong in internal/repository (do not import repository). -const ( - errTemplateKeyRequired = "模板标识符不能为空" - errTemplateNameRequired = "模板名称不能为空" - errTemplateContentRequired = "模板内容不能为空" - errAuthSourceNameRequired = "认证源名称不能为空" - errAuthSourceNameInvalid = "认证源名称只能包含字母、数字、短横线或下划线,且必须以字母或数字开头" - errAuthSourceTypeUnsupported = "认证源类型仅支持 oidc" - errAuthSourceDiscoveryURLRequired = "OIDC 认证源必须配置 Discovery URL" - errAuthSourceClientCredentialsRequired = "启用认证源前必须配置 Client ID 和 Client Secret" //nolint:gosec // false positive: this is an error message, not hardcoded credentials -) diff --git a/internal/model/message_gateway.go b/internal/model/message_gateway.go deleted file mode 100644 index 644cec01..00000000 --- a/internal/model/message_gateway.go +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import "time" - -const ( - // MessageChannelTypeTelegram is a Telegram bot channel. - MessageChannelTypeTelegram = "telegram" - // MessageChannelTypeQQ is an official QQ bot channel. - MessageChannelTypeQQ = "qq" - // MessageOwnerScopeSystem is an instance-level shared bot. - MessageOwnerScopeSystem = "system" -) - -// MessageChannel is an admin-configured messaging adapter. -type MessageChannel struct { - ID uint64 `json:"id,string" gorm:"primaryKey"` - Name string `json:"name" gorm:"size:128;not null"` - Type string `json:"type" gorm:"size:32;not null;index"` - OwnerScope string `json:"owner_scope" gorm:"size:16;not null;default:system"` - OwnerID *uint64 `json:"owner_id,string"` - Enabled bool `json:"enabled" gorm:"not null;default:true"` - Credentials string `json:"-" gorm:"type:text"` - Extra string `json:"extra" gorm:"type:text"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` -} - -// TableName returns w_message_channels. -func (MessageChannel) TableName() string { return "w_message_channels" } - -// MessageBinding maps a platform user to a Wavelet user on one channel. -type MessageBinding struct { - ID uint64 `json:"id,string" gorm:"primaryKey"` - UserID uint64 `json:"user_id,string" gorm:"not null;index"` - ChannelID uint64 `json:"channel_id,string" gorm:"not null;index"` - PlatformUserID string `json:"platform_user_id" gorm:"size:128;not null"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` -} - -// TableName returns w_message_bindings. -func (MessageBinding) TableName() string { return "w_message_bindings" } - -// MessagePairingCode is a one-time bind code. -type MessagePairingCode struct { - Code string `json:"code" gorm:"primaryKey;size:16"` - ChannelID uint64 `json:"channel_id,string" gorm:"not null"` - PlatformUserID string `json:"platform_user_id" gorm:"size:128;not null"` - ExpiresAt time.Time `json:"expires_at" gorm:"not null"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` -} - -// TableName returns w_message_pairing_codes. -func (MessagePairingCode) TableName() string { return "w_message_pairing_codes" } diff --git a/internal/model/push_channel.go b/internal/model/push_channel.go deleted file mode 100644 index b9217e64..00000000 --- a/internal/model/push_channel.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "encoding/json" - "errors" - "regexp" - "strings" - "time" -) - -const ( - // TypeCustom 自定义消息通道类型 - TypeCustom = "custom" - // TypeEmail 邮件推送消息通道类型 - TypeEmail = "email" - // TypeTelegram 电报机器人推送消息通道类型 - TypeTelegram = "telegram" -) - -// PushChannel 消息通道模型 -type PushChannel struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` - Name string `json:"name" gorm:"uniqueIndex;size:80;not null"` // 通道名称,仅英文字母和下划线,唯一 - Description string `json:"description" gorm:"size:255"` // 备注 - Type string `json:"type" gorm:"size:50;not null;default:'custom'"` // 通道类型:custom, lark, email - Token string `json:"token" gorm:"size:100"` // 鉴权令牌或发信用户名等 - URL string `json:"url" gorm:"type:text;not null"` // 请求地址,HTTPS 协议或 SMTP 地址 - Other string `json:"other" gorm:"type:text;not null"` // 请求体/SMTP 密码等 - Enabled bool `json:"enabled" gorm:"index;not null;default:true"` // 通道是否启用 - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` -} - -// TableName 指定 GORM 表名 -func (PushChannel) TableName() string { - return "w_push_channels" -} - -var nameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]+$`) - -// Validate 参数合法性与 JSON 格式校验 -func (pc *PushChannel) Validate() error { - pc.Name = strings.TrimSpace(pc.Name) - pc.URL = strings.TrimSpace(pc.URL) - pc.Other = strings.TrimSpace(pc.Other) - pc.Type = strings.TrimSpace(pc.Type) - - if pc.Type == "" { - pc.Type = TypeCustom - } - - if pc.Type == TypeTelegram && pc.URL == "" { - pc.URL = "https://api.telegram.org" - } - - if pc.Name == "" { - return errors.New("channel name is required") - } - if !nameRegex.MatchString(pc.Name) { - return errors.New("channel name can only contain letters, numbers, and underscores") - } - if pc.Type != TypeEmail && pc.URL == "" { - return errors.New("request URL/address is required") - } - - if pc.Type != TypeEmail && !strings.HasPrefix(pc.URL, "https://") { - return errors.New("request URL must use HTTPS protocol for security reasons") - } - - switch pc.Type { - case TypeCustom: - if pc.Other == "" { - return errors.New("payload schema (request body) is required") - } - return validateJSON(pc.Other) - case TypeEmail: - // Email channel SMTP configs fall back to global settings, so they are not required to be filled. - case TypeTelegram: - if pc.Token == "" { - return errors.New("telegram bot token is required") - } - } - return nil -} - -func validateJSON(s string) error { - var jsonTest map[string]any - if err := json.Unmarshal([]byte(s), &jsonTest); err == nil { - return nil - } - var jsonArr []any - if err := json.Unmarshal([]byte(s), &jsonArr); err == nil { - return nil - } - return errors.New("payload schema must be a valid JSON format") -} diff --git a/internal/model/push_event.go b/internal/model/push_event.go deleted file mode 100644 index 1307d30b..00000000 --- a/internal/model/push_event.go +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "errors" - "strings" - "time" -) - -// PushEvent 系统通知事件模型 -type PushEvent struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` - EventKey string `json:"event_key" gorm:"uniqueIndex;size:80;not null"` // 如 admin_login - Name string `json:"name" gorm:"size:100;not null"` // 如 管理员登录 - TaskType string `json:"task_type" gorm:"size:100;index;not null;default:''"` // 关联的异步任务类型 - Channels []string `json:"channels" gorm:"type:text;serializer:json;not null"` // 推送渠道列表,如 ["lark"] - Targets []string `json:"targets" gorm:"type:text;serializer:json;not null"` // 推送目标用户/邮箱列表 - Template string `json:"template" gorm:"type:text;not null"` // 消息模板 JSON - Enabled bool `json:"enabled" gorm:"index;not null;default:false"` // 是否启用 - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` -} - -// TableName 指定 GORM 表名 -func (PushEvent) TableName() string { - return "w_push_events" -} - -// Validate 基础校验 -func (pe *PushEvent) Validate() error { - pe.EventKey = strings.TrimSpace(pe.EventKey) - pe.Name = strings.TrimSpace(pe.Name) - pe.Template = strings.TrimSpace(pe.Template) - - if pe.EventKey == "" { - return errors.New("event key is required") - } - if pe.Name == "" { - return errors.New("event name is required") - } - if pe.Template == "" { - return errors.New("event template is required") - } - if pe.Enabled && len(pe.Channels) == 0 { - return errors.New("cannot enable event without any push channels configured") - } - return nil -} diff --git a/internal/model/push_history.go b/internal/model/push_history.go deleted file mode 100644 index f36e806f..00000000 --- a/internal/model/push_history.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "time" -) - -// PushHistory 推送日志/历史实体 -type PushHistory struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` - EventKey string `json:"event_key" gorm:"size:80;not null;index"` - Channel string `json:"channel" gorm:"size:50;not null"` - Target string `json:"target" gorm:"size:255;not null"` - Title string `json:"title" gorm:"size:255;not null"` - Content string `json:"content" gorm:"type:text;not null"` - Level string `json:"level" gorm:"size:20;not null"` - Status string `json:"status" gorm:"size:20;not null"` // success / failed - ErrorMsg string `json:"error_msg" gorm:"type:text"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` -} - -// TableName 指定表名 -func (PushHistory) TableName() string { - return "w_push_histories" -} diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go deleted file mode 100644 index 86493926..00000000 --- a/internal/model/system_configs.go +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import "time" - -// 配置键常量 - 所有系统配置的 key 定义 -const ( - ConfigKeyUploadAllowedExtensions = "upload_allowed_extensions" // 允许上传的文件扩展名,逗号分隔 - ConfigKeySiteName = "site_name" // 站点名称 - ConfigKeyPasswordLoginEnabled = "password_login_enabled" // 是否允许密码登录 - ConfigKeyRegistrationEnabled = "registration_enabled" // 是否允许注册 - ConfigKeyPasswordRegisterEnabled = "password_register_enabled" // 是否允许密码注册 - ConfigKeyOIDCLoginEnabled = "oidc_login_enabled" // 是否允许 OIDC 登录 - ConfigKeyMaxAPIKeysPerUser = "max_api_keys_per_user" //nolint:gosec // false positive: config key name. 每个用户最大 API Key 数量 - ConfigKeyCapLoginEnabled = "cap_login_enabled" // 是否启用登录人机验证 - ConfigKeyCapAutoSolve = "cap_auto_solve" // 打开页面后是否自动开始计算(false 则需用户手动点击) - ConfigKeyCapChallengeCount = "cap_challenge_count" // 客户端需求解的 PoW 难题总数,默认 1,推荐 1~5 - ConfigKeyCapChallengeSize = "cap_challenge_size" // 人机验证盐值长度 - ConfigKeyCapChallengeDifficulty = "cap_challenge_difficulty" // 人机验证 PoW 难度(目标前缀长度) - ConfigKeyCapChallengeTTL = "cap_challenge_ttl_seconds" // 人机验证难题有效时间(秒) - ConfigKeyCapTokenTTL = "cap_token_ttl_seconds" //nolint:gosec // false positive: config key name. 人机验证兑换凭证有效时间(秒) - ConfigKeyServerAddress = "server_address" // 服务器地址 - ConfigKeySMTPHost = "smtp_host" // SMTP 服务器地址 - ConfigKeySMTPPort = "smtp_port" // SMTP 端口 - ConfigKeySMTPUsername = "smtp_username" // SMTP 账户 - ConfigKeySMTPPassword = "smtp_password" // SMTP 访问凭证 - ConfigKeyEmailLoginVerificationEnabled = "email_login_verification_enabled" // 是否启用邮箱登录验证 - ConfigKeyEmailRegisterVerificationEnabled = "email_register_verification_enabled" // 是否启用邮箱注册验证 - ConfigKeyMenuDisplayConfig = "menu_display_config" // 目录显示配置 (JSON 字符串) - ConfigKeySearchEngineIndexingEnabled = "search_engine_indexing_enabled" // 是否允许搜索引擎检索 - ConfigKeyFileAccessWhitelist = "file_access_whitelist" // 免登录访问的文件业务类型白名单 (JSON 数组格式) - ConfigKeyDiskCacheMaxSizeMB = "disk_cache_max_size_mb" // 磁盘缓存最大空间大小 (MB) - ConfigKeyDiskCacheTTLMinutes = "disk_cache_ttl_minutes" // 磁盘缓存默认有效期 (分钟) - ConfigKeyDiskCacheLRUEnabled = "disk_cache_lru_enabled" // 是否启用 LRU 淘汰机制 - ConfigKeyLoginSessionTTLHours = "login_session_ttl_hours" // 登录会话过期时间 (小时,0表示浏览器关闭后自动退出登录,-1表示永不过期) - ConfigKeyUpdateUpstreamRepository = "update_upstream_repository" // GitHub Actions Release 上游仓库 - ConfigKeyStorageConfig = "storage_config" // 文件存储配置 (JSON) - ConfigKeyLogDatabase = "log_database" // 当前日志主库(postgres/sqlite/clickhouse),受保护 - ConfigKeyLogDBMigration = "log_db_migration" // 日志库迁移冻结标记(空/migrating),受保护 - ConfigKeyLogRetentionDaysPostgres = "log_retention_days_postgres" // PostgreSQL 用户访问日志保留天数 - ConfigKeyLogRetentionDaysSQLite = "log_retention_days_sqlite" // SQLite 用户访问日志保留天数 - ConfigKeyLogRetentionDaysClickHouse = "log_retention_days_clickhouse" // ClickHouse 用户访问日志保留天数 -) - -const ( - // ConfigVisibilityHidden 表示配置不通过公共配置接口暴露 - ConfigVisibilityHidden = 0 - // ConfigVisibilityVisible 表示配置通过公共配置接口暴露 - ConfigVisibilityVisible = 1 -) - -// SystemConfig 系统配置实体 -type SystemConfig struct { - Key string `json:"key" gorm:"primaryKey;size:64;not null"` - Value string `json:"value" gorm:"type:text;not null"` - Type string `json:"type" gorm:"size:32;not null;default:'system'"` - Visibility int `json:"visibility" gorm:"not null;default:0"` - Description string `json:"description" gorm:"size:255"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` -} - -// TableName 表名 -func (SystemConfig) TableName() string { - return "w_system_configs" -} diff --git a/internal/model/task_execution.go b/internal/model/task_execution.go deleted file mode 100644 index 2ecb4a80..00000000 --- a/internal/model/task_execution.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "time" -) - -// TaskExecutionStatus 任务执行状态 -type TaskExecutionStatus string - -// 任务执行状态 -const ( - TaskExecutionStatusPending TaskExecutionStatus = "pending" - TaskExecutionStatusRunning TaskExecutionStatus = "running" - TaskExecutionStatusSucceeded TaskExecutionStatus = "succeeded" - TaskExecutionStatusFailed TaskExecutionStatus = "failed" -) - -// TaskExecution 任务执行记录 -type TaskExecution struct { - ID uint64 `json:"id,string" gorm:"primaryKey"` - TaskID string `json:"task_id" gorm:"size:128;uniqueIndex;not null"` - TaskType string `json:"task_type" gorm:"size:64;index;not null"` - TaskName string `json:"task_name" gorm:"size:128"` - Status TaskExecutionStatus `json:"status" gorm:"size:32;index;not null"` - Retryable bool `json:"retryable" gorm:"not null;default:false"` - MaxRetry int `json:"max_retry" gorm:"not null;default:0"` - RetryCount int `json:"retry_count" gorm:"not null;default:0"` - Log string `json:"log" gorm:"type:text"` - ErrorMessage string `json:"error_message" gorm:"type:text"` - Result string `json:"result" gorm:"type:text"` - StartedAt *time.Time `json:"started_at" gorm:"index"` - FinishedAt *time.Time `json:"finished_at"` - Duration int64 `json:"duration" gorm:"comment:耗时毫秒"` - Payload string `json:"payload" gorm:"type:text"` - TriggeredBy string `json:"triggered_by" gorm:"size:32;not null;default:system"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` -} - -// TaskExecutionCleanupStats describes task execution log cleanup results. -type TaskExecutionCleanupStats struct { - HighFrequencyDeleted int64 - LowFrequencyDeleted int64 -} - -// TableName 表名 -func (TaskExecution) TableName() string { - return "w_task_executions" -} - -// ListTaskExecutionsRequest 查询任务执行记录列表请求 -type ListTaskExecutionsRequest struct { - Status string `form:"status"` - TaskType string `form:"task_type"` - TaskTypePrefix string `form:"task_type_prefix"` - // TaskTypes is a comma-separated list of exact asynq task types (IN filter). - // Used when TaskType is empty; takes precedence over TaskTypePrefix. - TaskTypes string `form:"task_types"` - Page int `form:"page"` - PageSize int `form:"page_size"` -} diff --git a/internal/model/templates.go b/internal/model/templates.go deleted file mode 100644 index 28f78ad1..00000000 --- a/internal/model/templates.go +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "bytes" - "errors" - "strings" - "text/template" - "time" -) - -// Template 邮件/消息模板实体 -type Template struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` - Key string `json:"key" gorm:"uniqueIndex;size:80;not null"` - Name string `json:"name" gorm:"size:100;not null"` - Type string `json:"type" gorm:"size:20;not null;default:'email'"` - Subject string `json:"subject" gorm:"size:255"` - Content string `json:"content" gorm:"type:text;not null"` - Description string `json:"description" gorm:"size:255"` - IsSystem bool `json:"is_system" gorm:"index;not null;default:false"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` -} - -// TableName 表名 -func (Template) TableName() string { - return "w_templates" -} - -// Normalize 规范化模板字段 -func (t *Template) Normalize() { - t.Key = strings.TrimSpace(t.Key) - t.Name = strings.TrimSpace(t.Name) - t.Type = strings.ToLower(strings.TrimSpace(t.Type)) - t.Subject = strings.TrimSpace(t.Subject) - t.Content = strings.TrimSpace(t.Content) - t.Description = strings.TrimSpace(t.Description) - if t.Type == "" { - t.Type = "email" - } -} - -// Validate 校验模板必填字段 -func (t *Template) Validate() error { - t.Normalize() - if t.Key == "" { - return errors.New(errTemplateKeyRequired) - } - if t.Name == "" { - return errors.New(errTemplateNameRequired) - } - if t.Content == "" { - return errors.New(errTemplateContentRequired) - } - return nil -} - -// Render 渲染模板的 Subject 和 Content -func (t *Template) Render(data any) (string, string, error) { - // Render Subject - var subject string - if t.Subject != "" { - tmplSubject, err := template.New(t.Key + "_subject").Parse(t.Subject) - if err != nil { - return "", "", err - } - var subBuf bytes.Buffer - if err := tmplSubject.Execute(&subBuf, data); err != nil { - return "", "", err - } - subject = subBuf.String() - } - - // Render Content - tmplContent, err := template.New(t.Key + "_content").Parse(t.Content) - if err != nil { - return "", "", err - } - var bodyBuf bytes.Buffer - if err := tmplContent.Execute(&bodyBuf, data); err != nil { - return "", "", err - } - - return subject, bodyBuf.String(), nil -} diff --git a/internal/model/upload_stats.go b/internal/model/upload_stats.go deleted file mode 100644 index 26cac104..00000000 --- a/internal/model/upload_stats.go +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import "time" - -// Upload stats dimension keys stored in w_upload_stats.dimension. -const ( - UploadStatDimensionTotal = "total" - UploadStatDimensionType = "type" - UploadStatDimensionCategory = "category" - UploadStatDimensionTrend = "trend" -) - -// UploadStat stores incremental upload statistics keyed by dimension and stat_key. -type UploadStat struct { - Dimension string `json:"dimension" gorm:"primaryKey;size:32;not null"` - StatKey string `json:"stat_key" gorm:"primaryKey;size:64;not null;default:''"` - FileCount int64 `json:"file_count" gorm:"not null;default:0"` - FileSize int64 `json:"file_size" gorm:"not null;default:0"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` -} - -// TableName returns the upload stats table name. -func (UploadStat) TableName() string { - return "w_upload_stats" -} diff --git a/internal/model/uploads.go b/internal/model/uploads.go deleted file mode 100644 index 7adbce78..00000000 --- a/internal/model/uploads.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "time" -) - -// UploadStatus 上传状态 -type UploadStatus string - -// 上传状态 -const ( - UploadStatusPending UploadStatus = "pending" // 待使用 - UploadStatusUsed UploadStatus = "used" // 已使用 - UploadStatusDeleted UploadStatus = "deleted" // 已删除 -) - -// UploadMetadata 自定义可扩展的 JSON 字段存储非核心或可选的文件元数据 -type UploadMetadata struct { - Width int `json:"width,omitempty"` // 图像/视频宽度 (px) - Height int `json:"height,omitempty"` // 图像/视频高度 (px) - Duration float64 `json:"duration,omitempty"` // 音视频时长 (s) - OriginalMime string `json:"original_mime,omitempty"` // 原始 MIME 类型 - UserAgent string `json:"user_agent,omitempty"` // 上传者的 UA - ClientIP string `json:"client_ip,omitempty"` // 上传者 IP - Bucket string `json:"bucket,omitempty"` // 存储桶名称 (适用于 S3 等) - Extra map[string]any `json:"extra,omitempty"` // 其它任意业务自定义元数据 -} - -// Upload 上传文件记录 -type Upload struct { - ID uint64 `json:"id,string" gorm:"primaryKey"` - UserID uint64 `json:"user_id,string" gorm:"index;not null"` - FileName string `json:"file_name" gorm:"size:255;not null"` // 原始文件名 (例如: image.png) - FilePath string `json:"file_path" gorm:"size:500;not null;index"` // 文件相对路径 / S3 Key - FileSize int64 `json:"file_size" gorm:"not null"` // 文件大小(字节) - MimeType string `json:"mime_type" gorm:"size:100;not null"` // 媒体类型 (MIME, 如 image/png) - Extension string `json:"extension" gorm:"size:50;not null"` // 文件后缀名 (不含点,如 png, pdf) - Hash string `json:"hash" gorm:"size:64;index"` // 文件哈希 (SHA-256/MD5,可用于排重) - Type string `json:"type" gorm:"column:type;size:50;not null;index"` // 业务标识类型 (如 avatar, doc, attachment) - Status UploadStatus `json:"status" gorm:"type:varchar(20);not null"` // 状态 - AccessMode int `json:"access_mode" gorm:"column:access_mode;not null;default:0"` - Metadata UploadMetadata `json:"metadata" gorm:"serializer:json;type:jsonb"` // 业务扩展元数据 - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` -} - -// TableName 表名 -func (Upload) TableName() string { - return "w_uploads" -} diff --git a/internal/model/users.go b/internal/model/users.go deleted file mode 100644 index 2a717f06..00000000 --- a/internal/model/users.go +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package model - -import ( - "errors" - "strconv" - "strings" - "time" - - "github.com/Rain-kl/Wavelet/internal/shared" - "github.com/Rain-kl/Wavelet/pkg/util" -) - -// OAuthUserInfo 用户信息结构(同时支持 OIDC ID Token claims 和 UserEndpoint 响应) -type OAuthUserInfo struct { - ID uint64 `json:"id"` - Sub string `json:"sub"` - Username string `json:"username"` - PreferredUsername string `json:"preferred_username"` - Email string `json:"email"` - Name string `json:"name"` - Active bool `json:"active"` - AvatarURL string `json:"avatar_url"` -} - -// GetID 获取用户 ID -func (u *OAuthUserInfo) GetID() uint64 { - if u.ID != 0 { - return u.ID - } - // 从 sub 解析(OIDC 格式) - if u.Sub != "" { - if id, err := strconv.ParseUint(u.Sub, 10, 64); err == nil { - return id - } - } - return 0 -} - -// User 用户表实体 -type User struct { - ID uint64 `json:"id,string" gorm:"primaryKey;not null"` - Username string `json:"username" gorm:"size:64;uniqueIndex"` - Password string `json:"password,omitempty" gorm:"size:255"` - Nickname string `json:"nickname" gorm:"size:255"` - Email string `json:"email" gorm:"size:255;index"` - AvatarURL string `json:"avatar_url" gorm:"size:255"` - IsActive bool `json:"is_active" gorm:"default:true;index"` - IsAdmin bool `json:"is_admin" gorm:"default:false"` - Bio string `json:"bio" gorm:"size:500"` - Phone string `json:"phone" gorm:"size:32"` - Gender string `json:"gender" gorm:"size:16"` - Website string `json:"website" gorm:"size:255"` - Location string `json:"location" gorm:"size:255"` - LastLoginAt time.Time `json:"last_login_at" gorm:"index"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` -} - -// TableName 表名 -func (User) TableName() string { - return "w_users" -} - -// SetPassword 设置明文密码 -func (u *User) SetPassword(password string) error { - u.Password = password - return nil -} - -// SetEncryptedPassword 设置加密密码 -func (u *User) SetEncryptedPassword(password string) error { - if password == "" { - u.Password = "" - return nil - } - hashed, err := util.HashPassword(password) - if err != nil { - return err - } - u.Password = hashed - return nil -} - -// IsPasswordEncrypted 检查密码是否已加密 -func (u *User) IsPasswordEncrypted() bool { - return strings.HasPrefix(u.Password, "$2a$") || strings.HasPrefix(u.Password, "$2b$") || strings.HasPrefix(u.Password, "$2y$") -} - -// CheckPassword 验证密码是否匹配 -func (u *User) CheckPassword(password string) bool { - if u.Password == "" || password == "" { - return false - } - if u.IsPasswordEncrypted() { - return util.CheckPasswordHash(u.Password, password) - } - return u.Password == password -} - -// UpdateFromOAuthInfo 根据 OAuth 信息更新用户数据 -func (u *User) UpdateFromOAuthInfo(oauthInfo *OAuthUserInfo) { - u.Username = oauthInfo.Username - u.Nickname = oauthInfo.Name - u.Email = oauthInfo.Email - u.AvatarURL = oauthInfo.AvatarURL - u.IsActive = oauthInfo.Active - u.LastLoginAt = time.Now() -} - -// CheckActive 检查用户账户是否激活,未激活则返回错误 -func (u *User) CheckActive() error { - if !u.IsActive { - return errors.New(shared.BannedAccount) - } - return nil -} diff --git a/internal/platform/lifecycle/lifecycle.go b/internal/platform/lifecycle/lifecycle.go deleted file mode 100644 index 6e44f3f1..00000000 --- a/internal/platform/lifecycle/lifecycle.go +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package lifecycle manages global application and business component shutdown hooks. -package lifecycle - -import ( - "context" - "log" - "sync" - - "github.com/Rain-kl/Wavelet/pkg/util" -) - -// ShutdownFunc defines the signature for a graceful shutdown callback. -type ShutdownFunc func(ctx context.Context) error - -type hook struct { - name string - fn ShutdownFunc -} - -var ( - hooks []hook - mu sync.Mutex -) - -// OnShutdown registers a callback to be run during graceful shutdown. -func OnShutdown(name string, fn ShutdownFunc) { - mu.Lock() - defer mu.Unlock() - hooks = append(hooks, hook{name: name, fn: fn}) -} - -// Stop executes all registered shutdown hooks concurrently and waits for completion or context timeout. -func Stop(ctx context.Context) { - mu.Lock() - localHooks := make([]hook, len(hooks)) - copy(localHooks, hooks) - mu.Unlock() - - var wg sync.WaitGroup - for _, h := range localHooks { - wg.Add(1) - name, fn := h.name, h.fn - util.Go(func() { - defer wg.Done() - log.Printf("[Lifecycle] stopping %s...\n", name) - if err := fn(ctx); err != nil { - log.Printf("[Lifecycle] stop %s failed: %v\n", name, err) - } else { - log.Printf("[Lifecycle] %s stopped successfully\n", name) - } - }) - } - - done := make(chan struct{}) - util.Go(func() { - wg.Wait() - close(done) - }) - - select { - case <-done: - log.Println("[Lifecycle] all services stopped gracefully") - case <-ctx.Done(): - log.Printf("[Lifecycle] shutdown timed out: %v\n", ctx.Err()) - } -} diff --git a/internal/repository/access_token.go b/internal/repository/access_token.go deleted file mode 100644 index ac5527dd..00000000 --- a/internal/repository/access_token.go +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package repository - -import ( - "context" - - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" -) - -// ListAccessTokensByUserID returns all access tokens for a user ordered by created_at desc. -func ListAccessTokensByUserID(ctx context.Context, userID uint64) ([]model.AccessToken, error) { - var tokens []model.AccessToken - if err := db.DB(ctx).Where("user_id = ?", userID).Order("created_at desc").Find(&tokens).Error; err != nil { - return nil, err - } - return tokens, nil -} - -// CountAccessTokensByUserID returns how many access tokens a user owns. -func CountAccessTokensByUserID(ctx context.Context, userID uint64) (int64, error) { - var count int64 - if err := db.DB(ctx).Model(&model.AccessToken{}).Where("user_id = ?", userID).Count(&count).Error; err != nil { - return 0, err - } - return count, nil -} - -// CreateAccessToken inserts a new access token record. -func CreateAccessToken(ctx context.Context, record *model.AccessToken) error { - return db.DB(ctx).Create(record).Error -} - -// GetAccessTokenByIDAndUserID loads a token owned by the given user. -func GetAccessTokenByIDAndUserID(ctx context.Context, id, userID uint64) (model.AccessToken, error) { - var token model.AccessToken - if err := db.DB(ctx).Where("id = ? AND user_id = ?", id, userID).First(&token).Error; err != nil { - return model.AccessToken{}, err - } - return token, nil -} - -// DeleteAccessTokenForUser deletes a token if it belongs to the user. -// Returns the number of rows affected. -func DeleteAccessTokenForUser(ctx context.Context, id, userID uint64) (int64, error) { - tx := db.DB(ctx).Where("id = ? AND user_id = ?", id, userID).Delete(&model.AccessToken{}) - return tx.RowsAffected, tx.Error -} - -// GetAccessTokenByHash loads an access token by its token hash. -func GetAccessTokenByHash(ctx context.Context, tokenHash string) (model.AccessToken, error) { - var token model.AccessToken - if err := db.DB(ctx).Where("token_hash = ?", tokenHash).First(&token).Error; err != nil { - return model.AccessToken{}, err - } - return token, nil -} - -// SaveAccessToken persists all fields of an existing access token. -func SaveAccessToken(ctx context.Context, record *model.AccessToken) error { - return db.DB(ctx).Save(record).Error -} - -// DeleteAccessTokensByUserID deletes all access tokens for a user. -func DeleteAccessTokensByUserID(ctx context.Context, userID uint64) error { - return db.DB(ctx).Where("user_id = ?", userID).Delete(&model.AccessToken{}).Error -} diff --git a/internal/repository/auth_source.go b/internal/repository/auth_source.go deleted file mode 100644 index f45cb146..00000000 --- a/internal/repository/auth_source.go +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package repository - -import ( - "context" - "errors" - "strings" - - "gorm.io/gorm" - - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" -) - -// GetAuthSources 获取所有认证源(已脱敏) -func GetAuthSources(ctx context.Context) ([]model.AuthSource, error) { - var sources []model.AuthSource - if err := db.DB(ctx).Order("id asc").Find(&sources).Error; err != nil { - return nil, err - } - for i := range sources { - sources[i].Sanitize() - } - return sources, nil -} - -// GetActiveAuthSources 获取所有已启用的认证源(已脱敏) -func GetActiveAuthSources(ctx context.Context) ([]model.AuthSource, error) { - var sources []model.AuthSource - if err := db.DB(ctx).Where("is_active = ?", true).Order("id asc").Find(&sources).Error; err != nil { - return nil, err - } - for i := range sources { - sources[i].Sanitize() - } - return sources, nil -} - -// GetAuthSourceByID 根据 ID 获取认证源 -func GetAuthSourceByID(ctx context.Context, id uint64) (*model.AuthSource, error) { - if id == 0 { - return nil, errors.New(errAuthSourceIDRequired) - } - var source model.AuthSource - if err := db.DB(ctx).First(&source, "id = ?", id).Error; err != nil { - return nil, err - } - source.ClientSecretConfigured = source.ClientSecret != "" - return &source, nil -} - -// GetAuthSourceByName 根据名称获取认证源(名称比较不区分大小写) -func GetAuthSourceByName(ctx context.Context, name string) (*model.AuthSource, error) { - name = strings.TrimSpace(name) - if name == "" { - return nil, errors.New(errAuthSourceNameRequired) - } - var source model.AuthSource - if err := db.DB(ctx).First(&source, "LOWER(name) = LOWER(?)", name).Error; err != nil { - return nil, err - } - source.ClientSecretConfigured = source.ClientSecret != "" - return &source, nil -} - -// CreateAuthSource 创建认证源 -func CreateAuthSource(ctx context.Context, source *model.AuthSource) error { - if err := source.Validate(); err != nil { - return err - } - return db.DB(ctx).Create(source).Error -} - -// UpdateAuthSource 更新认证源,keepSecret 为 true 时保留原密钥 -func UpdateAuthSource(ctx context.Context, source *model.AuthSource, keepSecret bool) error { - if source.ID == 0 { - return errors.New(errAuthSourceIDRequired) - } - var current model.AuthSource - if err := db.DB(ctx).First(¤t, "id = ?", source.ID).Error; err != nil { - return err - } - if keepSecret { - source.ClientSecret = current.ClientSecret - } - if err := source.Validate(); err != nil { - return err - } - return db.DB(ctx).Model(¤t).Updates(map[string]any{ - colName: source.Name, - "type": source.Type, - "display_name": source.DisplayName, - "is_active": source.IsActive, - "client_id": source.ClientID, - "client_secret": source.ClientSecret, - "openid_discovery_url": source.OpenIDDiscoveryURL, - "scopes": source.Scopes, - "icon_url": source.IconURL, - }).Error -} - -// ToggleAuthSource 切换认证源启用状态 -func ToggleAuthSource(ctx context.Context, id uint64, isActive bool) error { - source, err := GetAuthSourceByID(ctx, id) - if err != nil { - return err - } - source.IsActive = isActive - if err := source.Validate(); err != nil { - return err - } - return db.DB(ctx).Model(&model.AuthSource{}).Where("id = ?", id).Update("is_active", isActive).Error -} - -// DeleteAuthSource 删除认证源及其关联的外部帐号绑定 -func DeleteAuthSource(ctx context.Context, id uint64) error { - if id == 0 { - return errors.New(errAuthSourceIDRequired) - } - return db.DB(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("auth_source_id = ?", id).Delete(&model.ExternalAccount{}).Error; err != nil { - return err - } - return tx.Delete(&model.AuthSource{}, "id = ?", id).Error - }) -} - -// FindExternalAccount 查找外部帐号绑定记录 -func FindExternalAccount(ctx context.Context, sourceID uint64, externalID string) (*model.ExternalAccount, error) { - var account model.ExternalAccount - if err := db.DB(ctx).Where("auth_source_id = ? AND external_id = ?", sourceID, externalID).First(&account).Error; err != nil { - return nil, err - } - return &account, nil -} - -// BindExternalAccount 绑定外部帐号(已存在时更新用户名和邮箱) -func BindExternalAccount(ctx context.Context, account *model.ExternalAccount) error { - if account.UserID == 0 || strings.TrimSpace(account.ExternalID) == "" { - return errors.New(errExternalAccountBindingIncomplete) - } - account.ExternalID = strings.TrimSpace(account.ExternalID) - account.ExternalUsername = strings.TrimSpace(account.ExternalUsername) - account.Email = strings.TrimSpace(account.Email) - - return db.DB(ctx).Transaction(func(tx *gorm.DB) error { - var current model.ExternalAccount - err := tx.Where("auth_source_id = ? AND external_id = ?", account.AuthSourceID, account.ExternalID).First(¤t).Error - if err == nil { - if current.UserID != account.UserID { - return errors.New(errExternalAccountAlreadyBoundToAnother) - } - return tx.Model(¤t).Updates(map[string]any{ - "external_username": account.ExternalUsername, - "email": account.Email, - }).Error - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - return tx.Create(account).Error - }) -} - -// ListExternalAccountsByUserID 获取指定用户的所有外部帐号绑定视图 -func ListExternalAccountsByUserID(ctx context.Context, userID uint64) ([]model.ExternalAccountView, error) { - if userID == 0 { - return nil, errors.New(errUserIDRequired) - } - var accounts []model.ExternalAccount - if err := db.DB(ctx).Where("user_id = ?", userID).Order("id asc").Find(&accounts).Error; err != nil { - return nil, err - } - views := make([]model.ExternalAccountView, 0, len(accounts)) - for _, account := range accounts { - var name, sourceType, label string - if account.AuthSourceID == 0 { - name = "default" - sourceType = "oidc" - label = "历史认证源" - } else { - source, err := GetAuthSourceByID(ctx, account.AuthSourceID) - if err != nil { - continue - } - name = source.Name - sourceType = source.Type - label = source.DisplayName - if label == "" { - label = source.Name - } - } - views = append(views, model.ExternalAccountView{ - ID: account.ID, - AuthSourceID: account.AuthSourceID, - AuthSourceName: name, - AuthSourceType: sourceType, - AuthSourceLabel: label, - ExternalUsername: account.ExternalUsername, - Email: account.Email, - CreatedAt: account.CreatedAt, - }) - } - return views, nil -} - -// DeleteExternalAccountForUser 删除指定用户的外部帐号绑定 -func DeleteExternalAccountForUser(ctx context.Context, id uint64, userID uint64) error { - if id == 0 || userID == 0 { - return errors.New(errExternalAccountBindingIDRequired) - } - return db.DB(ctx).Where("id = ? AND user_id = ?", id, userID).Delete(&model.ExternalAccount{}).Error -} diff --git a/internal/repository/auth_source_cache.go b/internal/repository/auth_source_cache.go deleted file mode 100644 index f49042ce..00000000 --- a/internal/repository/auth_source_cache.go +++ /dev/null @@ -1,280 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package repository - -import ( - "context" - "fmt" - "strings" - "sync" - "time" - - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/cache/ram" - "github.com/Rain-kl/Wavelet/pkg/util" -) - -const ( - authSourceActiveRedisKey = "oauth:auth_sources:active" - authSourceByNameRedisKeyFmt = "oauth:auth_sources:by_name:%s" - authSourceByNameRedisPattern = "oauth:auth_sources:by_name:*" - authSourceActiveRAMKey = "active" - authSourceCacheTTL = time.Hour - authSourceRAMMaximumSize = 64 - authSourceInvalidationChannel = "oauth:auth_source_invalidation" -) - -// authSourceRedisRecord persists full auth source credentials in Redis. -type authSourceRedisRecord struct { - ID uint64 `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - DisplayName string `json:"display_name"` - IsActive bool `json:"is_active"` - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - OpenIDDiscoveryURL string `json:"openid_discovery_url"` - Scopes string `json:"scopes"` - IconURL string `json:"icon_url"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ClientSecretConfigured bool `json:"client_secret_configured"` -} - -var ( - authSourceActiveRAM = ram.MustNew[string, []model.AuthSource](ram.Options{MaximumSize: authSourceRAMMaximumSize}) - authSourceByNameRAM = ram.MustNew[string, model.AuthSource](ram.Options{MaximumSize: authSourceRAMMaximumSize}) - authSourceListenerOnce sync.Once - authSourceListenerCtx context.Context - authSourceListenerCancel context.CancelFunc - authSourceListenerDone chan struct{} -) - -func cloneAuthSources(sources []model.AuthSource) []model.AuthSource { - if len(sources) == 0 { - return nil - } - cloned := make([]model.AuthSource, len(sources)) - copy(cloned, sources) - return cloned -} - -func cloneAuthSource(source model.AuthSource) model.AuthSource { - return source -} - -func normalizeAuthSourceName(name string) string { - return strings.TrimSpace(strings.ToLower(name)) -} - -func authSourceByNameRedisKey(name string) string { - return fmt.Sprintf(authSourceByNameRedisKeyFmt, normalizeAuthSourceName(name)) -} - -func authSourceToRedisRecord(source model.AuthSource) authSourceRedisRecord { - return authSourceRedisRecord{ - ID: source.ID, - Name: source.Name, - Type: source.Type, - DisplayName: source.DisplayName, - IsActive: source.IsActive, - ClientID: source.ClientID, - ClientSecret: source.ClientSecret, - OpenIDDiscoveryURL: source.OpenIDDiscoveryURL, - Scopes: source.Scopes, - IconURL: source.IconURL, - CreatedAt: source.CreatedAt, - UpdatedAt: source.UpdatedAt, - ClientSecretConfigured: source.ClientSecretConfigured, - } -} - -func redisRecordToAuthSource(record authSourceRedisRecord) model.AuthSource { - return model.AuthSource{ - ID: record.ID, - Name: record.Name, - Type: record.Type, - DisplayName: record.DisplayName, - IsActive: record.IsActive, - ClientID: record.ClientID, - ClientSecret: record.ClientSecret, - OpenIDDiscoveryURL: record.OpenIDDiscoveryURL, - Scopes: record.Scopes, - IconURL: record.IconURL, - CreatedAt: record.CreatedAt, - UpdatedAt: record.UpdatedAt, - ClientSecretConfigured: record.ClientSecretConfigured, - } -} - -func ensureAuthSourceCacheListener() { - if db.Redis == nil { - return - } - authSourceListenerOnce.Do(startAuthSourceCacheInvalidationListener) -} - -func startAuthSourceCacheInvalidationListener() { - authSourceListenerCtx, authSourceListenerCancel = context.WithCancel(context.Background()) - authSourceListenerDone = make(chan struct{}) - - redisClient := db.Redis // 捕获当前客户端:goroutine 不读可变全局,避免与测试置空 db.Redis 竞争 - util.Go(func() { - listenerCtx := authSourceListenerCtx - defer close(authSourceListenerDone) - - pubsub := redisClient.Subscribe(listenerCtx, authSourceInvalidationChannel) - defer func() { - _ = pubsub.Close() - }() - - util.Go(func() { - <-listenerCtx.Done() - _ = pubsub.Close() - }) - - for range pubsub.Channel() { - authSourceActiveRAM.InvalidateAll() - authSourceByNameRAM.InvalidateAll() - } - }) -} - -func publishAuthSourceRAMInvalidation(ctx context.Context) { - if db.Redis == nil { - return - } - _ = db.Redis.Publish(ctx, authSourceInvalidationChannel, "reset").Err() -} - -func populateActiveAuthSourceCache(ctx context.Context, sources []model.AuthSource) { - cloned := cloneAuthSources(sources) - authSourceActiveRAM.Set(authSourceActiveRAMKey, cloned) - if db.Redis != nil { - _ = db.SetJSON(ctx, authSourceActiveRedisKey, cloned, authSourceCacheTTL) - } -} - -func populateAuthSourceByNameCache(ctx context.Context, name string, source *model.AuthSource) { - if source == nil { - return - } - cloned := cloneAuthSource(*source) - authSourceByNameRAM.Set(normalizeAuthSourceName(name), cloned) - if db.Redis != nil { - record := authSourceToRedisRecord(cloned) - _ = db.SetJSON(ctx, authSourceByNameRedisKey(name), record, authSourceCacheTTL) - } -} - -// GetActiveAuthSourcesCached returns active auth sources from RAM, Redis, or the database. -func GetActiveAuthSourcesCached(ctx context.Context) ([]model.AuthSource, error) { - ensureAuthSourceCacheListener() - - if sources, ok := authSourceActiveRAM.GetIfPresent(authSourceActiveRAMKey); ok { - return cloneAuthSources(sources), nil - } - - if db.Redis != nil { - var sources []model.AuthSource - if err := db.GetJSON(ctx, authSourceActiveRedisKey, &sources); err == nil { - populateActiveAuthSourceCache(ctx, sources) - return cloneAuthSources(sources), nil - } - } - - sources, err := GetActiveAuthSources(ctx) - if err != nil { - return nil, err - } - populateActiveAuthSourceCache(ctx, sources) - return cloneAuthSources(sources), nil -} - -// GetAuthSourceByNameCached returns an auth source by name from RAM, Redis, or the database. -func GetAuthSourceByNameCached(ctx context.Context, name string) (*model.AuthSource, error) { - ensureAuthSourceCacheListener() - - normalized := normalizeAuthSourceName(name) - if normalized == "" { - return GetAuthSourceByName(ctx, name) - } - - if source, ok := authSourceByNameRAM.GetIfPresent(normalized); ok { - cloned := cloneAuthSource(source) - return &cloned, nil - } - - if db.Redis != nil { - var record authSourceRedisRecord - if err := db.GetJSON(ctx, authSourceByNameRedisKey(name), &record); err == nil { - source := redisRecordToAuthSource(record) - populateAuthSourceByNameCache(ctx, name, &source) - cloned := cloneAuthSource(source) - return &cloned, nil - } - } - - source, err := GetAuthSourceByName(ctx, name) - if err != nil { - return nil, err - } - populateAuthSourceByNameCache(ctx, name, source) - cloned := cloneAuthSource(*source) - return &cloned, nil -} - -// InvalidateAuthSourceCache clears active and per-name auth source caches from RAM and Redis. -func InvalidateAuthSourceCache(ctx context.Context) error { - ensureAuthSourceCacheListener() - - authSourceActiveRAM.InvalidateAll() - authSourceByNameRAM.InvalidateAll() - - if db.Redis == nil { - return nil - } - - if err := db.Redis.Del(ctx, db.PrefixedKey(authSourceActiveRedisKey)).Err(); err != nil { - return err - } - - pattern := db.PrefixedKey(authSourceByNameRedisPattern) - iter := db.Redis.Scan(ctx, 0, pattern, 0).Iterator() - var keys []string - for iter.Next(ctx) { - keys = append(keys, iter.Val()) - } - if err := iter.Err(); err != nil { - return err - } - if len(keys) > 0 { - if err := db.Redis.Del(ctx, keys...).Err(); err != nil { - return err - } - } - - publishAuthSourceRAMInvalidation(ctx) - return nil -} - -// StopAuthSourceCacheListener stops the Redis Pub/Sub subscription listener and resets the sync.Once guard. -func StopAuthSourceCacheListener() { - if authSourceListenerCancel != nil { - authSourceListenerCancel() - if authSourceListenerDone != nil { - <-authSourceListenerDone - } - authSourceListenerCancel = nil - authSourceListenerDone = nil - } - authSourceListenerOnce = sync.Once{} -} - -// ResetAuthSourceRAMCacheForTest clears only the process-local RAM cache. -func ResetAuthSourceRAMCacheForTest() { - authSourceActiveRAM.InvalidateAll() - authSourceByNameRAM.InvalidateAll() -} diff --git a/internal/repository/auth_source_cache_test.go b/internal/repository/auth_source_cache_test.go deleted file mode 100644 index 6ba0c040..00000000 --- a/internal/repository/auth_source_cache_test.go +++ /dev/null @@ -1,240 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package repository - -import ( - "context" - "testing" - "time" - - "github.com/alicebob/miniredis/v2" - "github.com/glebarez/sqlite" - "github.com/redis/go-redis/v9" - "github.com/redis/go-redis/v9/maintnotifications" - "gorm.io/gorm" - - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" -) - -func setupAuthSourceCacheTest(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func()) { - t.Helper() - - sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ - DisableForeignKeyConstraintWhenMigrating: true, - }) - if err != nil { - t.Fatalf("failed to open in-memory SQLite db: %v", err) - } - if err := sqliteDB.AutoMigrate(&model.AuthSource{}); err != nil { - t.Fatalf("failed to migrate auth sources: %v", err) - } - - miniRedis, err := miniredis.Run() - if err != nil { - t.Fatalf("failed to start miniredis: %v", err) - } - - db.SetDB(sqliteDB) - db.Redis = redis.NewClient(&redis.Options{ - Addr: miniRedis.Addr(), - MaintNotificationsConfig: &maintnotifications.Config{ - Mode: maintnotifications.ModeDisabled, - }, - }) - - ResetAuthSourceRAMCacheForTest() - - cleanup := func() { - StopAuthSourceCacheListener() - ResetAuthSourceRAMCacheForTest() - db.Redis.Close() - miniRedis.Close() - db.Redis = nil - } - return sqliteDB, miniRedis, cleanup -} - -func TestGetActiveAuthSourcesCached_LoadsFromRedisBeforeDB(t *testing.T) { - dbConn, _, cleanup := setupAuthSourceCacheTest(t) - defer cleanup() - ctx := context.Background() - - if err := InvalidateAuthSourceCache(ctx); err != nil { - t.Fatalf("InvalidateAuthSourceCache() error = %v", err) - } - - source := model.AuthSource{ - Name: "cached-source", - Type: model.AuthSourceTypeOIDC, - DisplayName: "Cached Source", - IsActive: true, - ClientID: "client-id", - ClientSecret: "client-secret", - OpenIDDiscoveryURL: "https://issuer.example.com", - } - if err := CreateAuthSource(ctx, &source); err != nil { - t.Fatalf("CreateAuthSource() error = %v", err) - } - - warmed, err := GetActiveAuthSourcesCached(ctx) - if err != nil { - t.Fatalf("GetActiveAuthSourcesCached() warm error = %v", err) - } - if len(warmed) == 0 || warmed[0].Name != source.Name { - t.Fatalf("GetActiveAuthSourcesCached() warm = %#v, want source %q", warmed, source.Name) - } - - if err := dbConn.Delete(&model.AuthSource{}, "id = ?", source.ID).Error; err != nil { - t.Fatalf("Delete(auth source) error = %v", err) - } - - ResetAuthSourceRAMCacheForTest() - - cached, err := GetActiveAuthSourcesCached(ctx) - if err != nil { - t.Fatalf("GetActiveAuthSourcesCached() cached error = %v", err) - } - if len(cached) == 0 || cached[0].Name != source.Name { - t.Fatalf("GetActiveAuthSourcesCached() = %#v, want redis-backed source %q", cached, source.Name) - } -} - -func TestGetAuthSourceByNameCached_LoadsFromRedisBeforeDB(t *testing.T) { - dbConn, _, cleanup := setupAuthSourceCacheTest(t) - defer cleanup() - ctx := context.Background() - - if err := InvalidateAuthSourceCache(ctx); err != nil { - t.Fatalf("InvalidateAuthSourceCache() error = %v", err) - } - - source := model.AuthSource{ - Name: "by-name-source", - Type: model.AuthSourceTypeOIDC, - DisplayName: "By Name Source", - IsActive: true, - ClientID: "client-id", - ClientSecret: "client-secret", - OpenIDDiscoveryURL: "https://issuer.example.com", - } - if err := CreateAuthSource(ctx, &source); err != nil { - t.Fatalf("CreateAuthSource() error = %v", err) - } - - warmed, err := GetAuthSourceByNameCached(ctx, source.Name) - if err != nil { - t.Fatalf("GetAuthSourceByNameCached() warm error = %v", err) - } - if warmed.Name != source.Name || warmed.ClientSecret != source.ClientSecret { - t.Fatalf("GetAuthSourceByNameCached() warm = %#v, want %#v", warmed, source) - } - - if err := dbConn.Delete(&model.AuthSource{}, "id = ?", source.ID).Error; err != nil { - t.Fatalf("Delete(auth source) error = %v", err) - } - - ResetAuthSourceRAMCacheForTest() - - cached, err := GetAuthSourceByNameCached(ctx, source.Name) - if err != nil { - t.Fatalf("GetAuthSourceByNameCached() cached error = %v", err) - } - if cached.Name != source.Name || cached.ClientSecret != source.ClientSecret { - t.Fatalf("GetAuthSourceByNameCached() = %#v, want redis-backed source %#v", cached, source) - } -} - -func TestInvalidateAuthSourceCache_ClearsRedisKeys(t *testing.T) { - _, _, cleanup := setupAuthSourceCacheTest(t) - defer cleanup() - ctx := context.Background() - - if err := InvalidateAuthSourceCache(ctx); err != nil { - t.Fatalf("InvalidateAuthSourceCache() initial error = %v", err) - } - - source := model.AuthSource{ - Name: "invalidate-source", - Type: model.AuthSourceTypeOIDC, - DisplayName: "Invalidate Source", - IsActive: true, - ClientID: "client-id", - ClientSecret: "client-secret", - OpenIDDiscoveryURL: "https://issuer.example.com", - } - if err := CreateAuthSource(ctx, &source); err != nil { - t.Fatalf("CreateAuthSource() error = %v", err) - } - if _, err := GetActiveAuthSourcesCached(ctx); err != nil { - t.Fatalf("GetActiveAuthSourcesCached() warm error = %v", err) - } - if _, err := GetAuthSourceByNameCached(ctx, source.Name); err != nil { - t.Fatalf("GetAuthSourceByNameCached() warm error = %v", err) - } - - if err := InvalidateAuthSourceCache(ctx); err != nil { - t.Fatalf("InvalidateAuthSourceCache() error = %v", err) - } - - activeExists, err := db.Redis.Exists(ctx, db.PrefixedKey(authSourceActiveRedisKey)).Result() - if err != nil { - t.Fatalf("Exists(active key) error = %v", err) - } - if activeExists != 0 { - t.Fatalf("active redis key still exists after invalidation") - } - - byNameExists, err := db.Redis.Exists(ctx, db.PrefixedKey(authSourceByNameRedisKey(source.Name))).Result() - if err != nil { - t.Fatalf("Exists(by-name key) error = %v", err) - } - if byNameExists != 0 { - t.Fatalf("by-name redis key still exists after invalidation") - } -} - -func TestAuthSourceInvalidationPubSubClearsPeerRAM(t *testing.T) { - dbConn, _, cleanup := setupAuthSourceCacheTest(t) - defer cleanup() - ctx := context.Background() - - source := model.AuthSource{ - Name: "pubsub-source", - Type: model.AuthSourceTypeOIDC, - DisplayName: "PubSub Source", - IsActive: true, - ClientID: "client-id", - ClientSecret: "client-secret", - OpenIDDiscoveryURL: "https://issuer.example.com", - } - if err := CreateAuthSource(ctx, &source); err != nil { - t.Fatalf("CreateAuthSource() error = %v", err) - } - - if _, err := GetActiveAuthSourcesCached(ctx); err != nil { - t.Fatalf("GetActiveAuthSourcesCached() error = %v", err) - } - if err := dbConn.Delete(&model.AuthSource{}, "id = ?", source.ID).Error; err != nil { - t.Fatalf("Delete(auth source) error = %v", err) - } - if _, err := GetActiveAuthSourcesCached(ctx); err != nil { - t.Fatalf("expected RAM cache hit before pub/sub invalidation: %v", err) - } - - if err := db.Redis.Publish(ctx, authSourceInvalidationChannel, "reset").Err(); err != nil { - t.Fatalf("publish invalidation: %v", err) - } - - deadline := time.Now().Add(500 * time.Millisecond) - for time.Now().Before(deadline) { - if _, ok := authSourceActiveRAM.GetIfPresent(authSourceActiveRAMKey); !ok { - break - } - time.Sleep(10 * time.Millisecond) - } - if _, ok := authSourceActiveRAM.GetIfPresent(authSourceActiveRAMKey); ok { - t.Fatal("expected peer RAM cache to be cleared by pub/sub") - } -} diff --git a/internal/repository/errs.go b/internal/repository/errs.go deleted file mode 100644 index c8d4b29f..00000000 --- a/internal/repository/errs.go +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package repository - -// Persistence and repository-layer parameter messages live here (unexported). -// Domain field validation used by model.Validate stays in internal/model/errs.go; -// repository may call model.Validate and return those errors as-is. -// Keep wording aligned with model where the same user-facing phrase applies, -// but do not import or re-export model unexported consts (would require exporting). -const ( - errDatabaseNotInitialized = "database not initialized" - errConfigIntParseFailed = "配置 %s 的值 '%s' 无法转换为整数: %w" - errConfigDecimalParseFailed = "配置 %s 的值 '%s' 无法转换为decimal: %w" - errConfigBoolParseFailed = "配置 %s 的值 '%s' 无法转换为布尔值: %w" - errParseMenuDisplayConfigFailed = "解析目录显示配置失败: %w" - errAuthSourceNameRequired = "认证源名称不能为空" - errAuthSourceIDRequired = "认证源 ID 不能为空" - errExternalAccountBindingIncomplete = "外部账号绑定信息不完整" - errExternalAccountAlreadyBoundToAnother = "该外部账号已绑定到其他用户" - errUserIDRequired = "用户 ID 不能为空" - errExternalAccountBindingIDRequired = "绑定记录 ID 不能为空" -) - -const colName = "name" diff --git a/internal/repository/message_gateway.go b/internal/repository/message_gateway.go deleted file mode 100644 index 0fdcb25a..00000000 --- a/internal/repository/message_gateway.go +++ /dev/null @@ -1,152 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package repository - -import ( - "context" - "errors" - "time" - - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - "github.com/Rain-kl/Wavelet/internal/model" - "gorm.io/gorm" -) - -// CreateMessageChannel inserts a channel row. -func CreateMessageChannel(ctx context.Context, ch *model.MessageChannel) error { - if ch.ID == 0 { - ch.ID = idgen.NextUint64ID() - } - return db.DB(ctx).Create(ch).Error -} - -// UpdateMessageChannel saves a channel row. -func UpdateMessageChannel(ctx context.Context, ch *model.MessageChannel) error { - return db.DB(ctx).Save(ch).Error -} - -// GetMessageChannel loads a channel by id. -func GetMessageChannel(ctx context.Context, id uint64) (*model.MessageChannel, error) { - var ch model.MessageChannel - if err := db.DB(ctx).Where("id = ?", id).First(&ch).Error; err != nil { - return nil, err - } - return &ch, nil -} - -// ListMessageChannels returns all channels newest first. -func ListMessageChannels(ctx context.Context) ([]model.MessageChannel, error) { - var rows []model.MessageChannel - if err := db.DB(ctx).Order("id DESC").Find(&rows).Error; err != nil { - return nil, err - } - return rows, nil -} - -// DeleteMessageChannel removes pairings, bindings, then the channel. -func DeleteMessageChannel(ctx context.Context, id uint64) error { - return db.DB(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("channel_id = ?", id).Delete(&model.MessagePairingCode{}).Error; err != nil { - return err - } - if err := tx.Where("channel_id = ?", id).Delete(&model.MessageBinding{}).Error; err != nil { - return err - } - return tx.Delete(&model.MessageChannel{}, id).Error - }) -} - -// CreateMessageBinding inserts a binding. -func CreateMessageBinding(ctx context.Context, b *model.MessageBinding) error { - if b.ID == 0 { - b.ID = idgen.NextUint64ID() - } - return db.DB(ctx).Create(b).Error -} - -// GetBindingByChannelPlatform finds a binding for a platform user on a channel. -func GetBindingByChannelPlatform(ctx context.Context, channelID uint64, platformUserID string) (*model.MessageBinding, error) { - var b model.MessageBinding - err := db.DB(ctx).Where("channel_id = ? AND platform_user_id = ?", channelID, platformUserID).First(&b).Error - if err != nil { - return nil, err - } - return &b, nil -} - -// ListBindingsByUser lists bindings for a Wavelet user. -func ListBindingsByUser(ctx context.Context, userID uint64) ([]model.MessageBinding, error) { - var rows []model.MessageBinding - if err := db.DB(ctx).Where("user_id = ?", userID).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 - if err := db.DB(ctx).Where("id = ?", id).First(&b).Error; err != nil { - return nil, err - } - return &b, nil -} - -// DeleteMessageBinding deletes a binding by id. -func DeleteMessageBinding(ctx context.Context, id uint64) error { - return db.DB(ctx).Delete(&model.MessageBinding{}, id).Error -} - -// UpsertPairingCode reuses an unexpired code for the same channel+platform user. -func UpsertPairingCode(ctx context.Context, channelID uint64, platformUserID, code string, expiresAt time.Time) (*model.MessagePairingCode, error) { - var existing model.MessagePairingCode - err := db.DB(ctx). - Where("channel_id = ? AND platform_user_id = ? AND expires_at > ?", channelID, platformUserID, time.Now()). - First(&existing).Error - if err == nil { - return &existing, nil - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - row := &model.MessagePairingCode{ - Code: code, - ChannelID: channelID, - PlatformUserID: platformUserID, - ExpiresAt: expiresAt, - } - if err := db.DB(ctx).Create(row).Error; err != nil { - return nil, err - } - return row, nil -} - -// GetPairingCode loads a pairing code by normalized code string. -func GetPairingCode(ctx context.Context, code string) (*model.MessagePairingCode, error) { - var row model.MessagePairingCode - if err := db.DB(ctx).Where("code = ?", code).First(&row).Error; err != nil { - return nil, err - } - return &row, nil -} - -// DeletePairingCode removes a pairing code. -func DeletePairingCode(ctx context.Context, code string) error { - return db.DB(ctx).Where("code = ?", code).Delete(&model.MessagePairingCode{}).Error -} - -// DeleteExpiredPairingCodes removes expired pairing rows. -func DeleteExpiredPairingCodes(ctx context.Context) error { - return db.DB(ctx).Where("expires_at <= ?", time.Now()).Delete(&model.MessagePairingCode{}).Error -} - -// ListEnabledMessageChannels returns enabled channels. -func ListEnabledMessageChannels(ctx context.Context) ([]model.MessageChannel, error) { - var rows []model.MessageChannel - if err := db.DB(ctx).Where("enabled = ?", true).Find(&rows).Error; err != nil { - return nil, err - } - return rows, nil -} diff --git a/internal/repository/push_channel.go b/internal/repository/push_channel.go deleted file mode 100644 index 23e3cfb0..00000000 --- a/internal/repository/push_channel.go +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package repository - -import ( - "context" - "time" - - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" -) - -const activePushChannelCacheTTL = 24 * time.Hour - -// ListPushChannels returns all push channels ordered by creation time descending. -func ListPushChannels(ctx context.Context) ([]model.PushChannel, error) { - var channels []model.PushChannel - if err := db.DB(ctx).Order("created_at DESC").Find(&channels).Error; err != nil { - return nil, err - } - return channels, nil -} - -// GetPushChannelByID loads a push channel by primary key. -func GetPushChannelByID(ctx context.Context, id uint64) (model.PushChannel, error) { - var channel model.PushChannel - if err := db.DB(ctx).Where("id = ?", id).First(&channel).Error; err != nil { - return model.PushChannel{}, err - } - return channel, nil -} - -// GetPushChannelByName 根据名称获取消息通道。 -func GetPushChannelByName(ctx context.Context, name string) (*model.PushChannel, error) { - var channel model.PushChannel - if err := db.DB(ctx).Where("name = ?", name).First(&channel).Error; err != nil { - return nil, err - } - return &channel, nil -} - -// CountPushChannelsByName returns how many channels share the given name. -func CountPushChannelsByName(ctx context.Context, name string) (int64, error) { - var count int64 - if err := db.DB(ctx).Model(&model.PushChannel{}).Where("name = ?", name).Count(&count).Error; err != nil { - return 0, err - } - return count, nil -} - -// CreatePushChannel persists a new channel and invalidates cache. -func CreatePushChannel(ctx context.Context, channel *model.PushChannel) error { - if err := db.DB(ctx).Create(channel).Error; err != nil { - return err - } - DeleteActivePushChannelCache(ctx, channel.Name) - return nil -} - -// SavePushChannel updates a channel and invalidates cache. -func SavePushChannel(ctx context.Context, channel *model.PushChannel) error { - if err := db.DB(ctx).Save(channel).Error; err != nil { - return err - } - DeleteActivePushChannelCache(ctx, channel.Name) - return nil -} - -// DeletePushChannel removes a channel and invalidates cache. -func DeletePushChannel(ctx context.Context, channel *model.PushChannel) error { - if err := db.DB(ctx).Delete(channel).Error; err != nil { - return err - } - DeleteActivePushChannelCache(ctx, channel.Name) - return nil -} - -// GetActivePushChannelByName 根据名称获取启用的消息通道 (优先从 Redis 缓存获取)。 -func GetActivePushChannelByName(ctx context.Context, name string) (*model.PushChannel, error) { - cacheKey := "push:channel:active:" + name - var channel model.PushChannel - if db.Redis != nil { - if err := db.GetJSON(ctx, cacheKey, &channel); err == nil { - return &channel, nil - } - } - - if err := db.DB(ctx).Where("name = ? AND enabled = ?", name, true).First(&channel).Error; err != nil { - return nil, err - } - - if db.Redis != nil { - _ = db.SetJSON(ctx, cacheKey, channel, activePushChannelCacheTTL) - } - - return &channel, nil -} - -// DeleteActivePushChannelCache 清理启用消息通道的缓存。 -func DeleteActivePushChannelCache(ctx context.Context, name string) { - if db.Redis != nil { - _ = db.Redis.Del(ctx, db.PrefixedKey("push:channel:active:"+name)).Err() - } -} diff --git a/internal/repository/push_event.go b/internal/repository/push_event.go deleted file mode 100644 index 0ef22c1a..00000000 --- a/internal/repository/push_event.go +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package repository - -import ( - "context" - "time" - - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" -) - -const activePushEventCacheTTL = 24 * time.Hour - -// ListPushEvents returns all push events ordered by creation time descending. -func ListPushEvents(ctx context.Context) ([]model.PushEvent, error) { - var events []model.PushEvent - if err := db.DB(ctx).Order("created_at DESC").Find(&events).Error; err != nil { - return nil, err - } - return events, nil -} - -// GetPushEventByID loads a push event by primary key. -func GetPushEventByID(ctx context.Context, id uint64) (model.PushEvent, error) { - var event model.PushEvent - if err := db.DB(ctx).First(&event, id).Error; err != nil { - return model.PushEvent{}, err - } - return event, nil -} - -// GetPushEventByKey loads a push event by event key. -func GetPushEventByKey(ctx context.Context, key string) (model.PushEvent, error) { - var event model.PushEvent - if err := db.DB(ctx).Where("event_key = ?", key).First(&event).Error; err != nil { - return model.PushEvent{}, err - } - return event, nil -} - -// CountPushEventsByKey returns how many events use the given event key. -func CountPushEventsByKey(ctx context.Context, key string) (int64, error) { - var count int64 - if err := db.DB(ctx).Model(&model.PushEvent{}).Where("event_key = ?", key).Count(&count).Error; err != nil { - return 0, err - } - return count, nil -} - -// CreatePushEvent persists a new push event and invalidates cache. -func CreatePushEvent(ctx context.Context, event *model.PushEvent) error { - if err := db.DB(ctx).Create(event).Error; err != nil { - return err - } - DeleteActivePushEventCache(ctx, event.EventKey) - return nil -} - -// SavePushEvent updates a push event and invalidates cache. -func SavePushEvent(ctx context.Context, event *model.PushEvent) error { - if err := db.DB(ctx).Save(event).Error; err != nil { - return err - } - DeleteActivePushEventCache(ctx, event.EventKey) - return nil -} - -// UpdatePushEventEnabled toggles the enabled flag for a push event. -func UpdatePushEventEnabled(ctx context.Context, event *model.PushEvent, enabled bool) error { - event.Enabled = enabled - if err := db.DB(ctx).Model(event).Update("enabled", enabled).Error; err != nil { - return err - } - DeleteActivePushEventCache(ctx, event.EventKey) - return nil -} - -// DeletePushEvent removes a push event and invalidates cache. -func DeletePushEvent(ctx context.Context, event *model.PushEvent) error { - if err := db.DB(ctx).Delete(event).Error; err != nil { - return err - } - DeleteActivePushEventCache(ctx, event.EventKey) - return nil -} - -// ListActivePushEventsByTaskType returns enabled events bound to a task type. -func ListActivePushEventsByTaskType(ctx context.Context, taskType string) ([]model.PushEvent, error) { - var events []model.PushEvent - if err := db.DB(ctx).Where("task_type = ? AND enabled = ?", taskType, true).Find(&events).Error; err != nil { - return nil, err - } - return events, nil -} - -// GetActivePushEventByKey 获取启用的通知事件 (优先从 Redis 缓存获取)。 -func GetActivePushEventByKey(ctx context.Context, key string) (*model.PushEvent, error) { - cacheKey := "push:event:active:" + key - var event model.PushEvent - if db.Redis != nil { - if err := db.GetJSON(ctx, cacheKey, &event); err == nil { - return &event, nil - } - } - - if err := db.DB(ctx).Where("event_key = ? AND enabled = ?", key, true).First(&event).Error; err != nil { - return nil, err - } - - if db.Redis != nil { - _ = db.SetJSON(ctx, cacheKey, event, activePushEventCacheTTL) - } - - return &event, nil -} - -// DeleteActivePushEventCache 清理启用通知事件的缓存。 -func DeleteActivePushEventCache(ctx context.Context, key string) { - if db.Redis != nil { - _ = db.Redis.Del(ctx, db.PrefixedKey("push:event:active:"+key)).Err() - } -} diff --git a/internal/repository/push_history.go b/internal/repository/push_history.go deleted file mode 100644 index 221eb4ee..00000000 --- a/internal/repository/push_history.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package repository - -import ( - "context" - - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "gorm.io/gorm" -) - -// PushHistoryListFilter filters push history pagination queries. -type PushHistoryListFilter struct { - EventKey string - Status string - Page int - PageSize int -} - -// ListPushHistories returns paginated push history records. -func ListPushHistories(ctx context.Context, filter PushHistoryListFilter) (int64, []model.PushHistory, error) { - query := db.DB(ctx).Model(&model.PushHistory{}).Order("created_at DESC") - if filter.EventKey != "" { - query = query.Where("event_key = ?", filter.EventKey) - } - if filter.Status != "" { - query = query.Where("status = ?", filter.Status) - } - - var total int64 - if err := query.Count(&total).Error; err != nil { - return 0, nil, err - } - - var results []model.PushHistory - offset := (filter.Page - 1) * filter.PageSize - if err := query.Offset(offset).Limit(filter.PageSize).Find(&results).Error; err != nil { - return 0, nil, err - } - - return total, results, nil -} - -// CreatePushHistory persists a push history audit record. -func CreatePushHistory(ctx context.Context, history *model.PushHistory) error { - return db.DB(ctx).Create(history).Error -} - -// PushHistoryQuery returns a scoped query builder for push histories. -func PushHistoryQuery(ctx context.Context) *gorm.DB { - return db.DB(ctx).Model(&model.PushHistory{}) -} diff --git a/internal/repository/schedule.go b/internal/repository/schedule.go deleted file mode 100644 index 7195928b..00000000 --- a/internal/repository/schedule.go +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package repository - -import ( - "context" - - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" -) - -// CreateSchedule 创建定时任务 -func CreateSchedule(ctx context.Context, schedule *model.Schedule) error { - return db.DB(ctx).Create(schedule).Error -} - -// UpdateSchedule 更新定时任务 -func UpdateSchedule(ctx context.Context, schedule *model.Schedule) error { - return db.DB(ctx).Save(schedule).Error -} - -// DeleteSchedule 删除定时任务 -func DeleteSchedule(ctx context.Context, id uint64) error { - return db.DB(ctx).Delete(&model.Schedule{}, id).Error -} - -// GetScheduleByID 根据 ID 获取定时任务 -func GetScheduleByID(ctx context.Context, id uint64) (*model.Schedule, error) { - var schedule model.Schedule - if err := db.DB(ctx).Where("id = ?", id).First(&schedule).Error; err != nil { - return nil, err - } - return &schedule, nil -} - -// ListSchedules 获取所有定时任务 -func ListSchedules(ctx context.Context) ([]model.Schedule, error) { - var schedules []model.Schedule - if err := db.DB(ctx).Order("id DESC").Find(&schedules).Error; err != nil { - return nil, err - } - return schedules, nil -} - -// ListActiveSchedules 获取所有启用的定时任务 -func ListActiveSchedules(ctx context.Context) ([]model.Schedule, error) { - var schedules []model.Schedule - if err := db.DB(ctx).Where("is_active = ?", true).Find(&schedules).Error; err != nil { - return nil, err - } - return schedules, nil -} diff --git a/internal/repository/system_config.go b/internal/repository/system_config.go deleted file mode 100644 index 86919685..00000000 --- a/internal/repository/system_config.go +++ /dev/null @@ -1,256 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package repository provides data access with caching and persistence boundaries. -package repository - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strconv" - - "github.com/shopspring/decimal" - - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/cache/ram" -) - -const configTypeSystem = "system" - -// PreheatSystemConfigs loads all system configs from database. -// This function strictly performs database read and does not perform any cache read or write operations. -func PreheatSystemConfigs(ctx context.Context) ([]model.SystemConfig, error) { - database := db.DB(ctx) - if database == nil { - return nil, errors.New(errDatabaseNotInitialized) - } - - var configs []model.SystemConfig - if err := database.Find(&configs).Error; err != nil { - return nil, err - } - return configs, nil -} - -// PreheatSystemConfigByKey loads a single config key from database. -// This function strictly performs database read and does not perform any cache read or write operations. -func PreheatSystemConfigByKey(ctx context.Context, key string) (model.SystemConfig, error) { - database := db.DB(ctx) - if database == nil { - return model.SystemConfig{}, errors.New(errDatabaseNotInitialized) - } - - var sc model.SystemConfig - if err := database.Where("key = ?", key).First(&sc).Error; err != nil { - return model.SystemConfig{}, err - } - return sc, nil -} - -// GetSystemConfigByGroup queries a configuration by Type and Key. -func GetSystemConfigByGroup(ctx context.Context, configType string, key string) (model.SystemConfig, error) { - ensureSystemConfigCacheListener() - - if item, ok := ram.Get(configType, key); ok { - var sc model.SystemConfig - if err := json.Unmarshal([]byte(item.Value), &sc); err == nil { - return sc, nil - } - } - - database := db.DB(ctx) - if database == nil { - return model.SystemConfig{}, errors.New(errDatabaseNotInitialized) - } - - var sc model.SystemConfig - if err := database.Where("key = ?", key).First(&sc).Error; err != nil { - return model.SystemConfig{}, err - } - - // Populate local cache directly on query miss - valBytes, err := json.Marshal(sc) - if err == nil { - ram.Set(ram.CacheItem{ - Key: sc.Key, - Value: string(valBytes), - Type: configType, - TTL: determineTTL(sc.Key), - }) - } - - return sc, nil -} - -// GetSystemConfigByKey queries config by key (delegates to Type "config"). -func GetSystemConfigByKey(ctx context.Context, key string) (model.SystemConfig, error) { - return GetSystemConfigByGroup(ctx, ConfigCacheType, key) -} - -// ListSystemConfigsByKeys loads multiple config keys. -func ListSystemConfigsByKeys(ctx context.Context, keys []string) (map[string]model.SystemConfig, error) { - if len(keys) == 0 { - return map[string]model.SystemConfig{}, nil - } - - ensureSystemConfigCacheListener() - - result := make(map[string]model.SystemConfig, len(keys)) - missing := make([]string, 0, len(keys)) - - for _, key := range keys { - if item, ok := ram.Get(ConfigCacheType, key); ok { - var sc model.SystemConfig - if err := json.Unmarshal([]byte(item.Value), &sc); err == nil { - result[key] = sc - continue - } - } - missing = append(missing, key) - } - - if len(missing) == 0 { - return result, nil - } - - database := db.DB(ctx) - if database == nil { - return nil, errors.New(errDatabaseNotInitialized) - } - - var configs []model.SystemConfig - if err := database.Where("key IN ?", missing).Find(&configs).Error; err != nil { - return nil, err - } - - for i := range configs { - valBytes, err := json.Marshal(configs[i]) - if err == nil { - ram.Set(ram.CacheItem{ - Key: configs[i].Key, - Value: string(valBytes), - Type: ConfigCacheType, - TTL: determineTTL(configs[i].Key), - }) - } - result[configs[i].Key] = configs[i] - } - - return result, nil -} - -// InvalidateVisibleSystemConfigsCache clears the cached public config list. -func InvalidateVisibleSystemConfigsCache(ctx context.Context) error { - return InvalidateAllSystemConfigCaches(ctx) -} - -// ListVisibleSystemConfigs queries visible configs using local cache store. -func ListVisibleSystemConfigs(ctx context.Context) ([]model.SystemConfig, error) { - ensureSystemConfigCacheListener() - - items := ram.GetTypeItems(ConfigCacheType) - if len(items) > 0 { - var list []model.SystemConfig - for _, item := range items { - var sc model.SystemConfig - if err := json.Unmarshal([]byte(item.Value), &sc); err == nil { - if sc.Visibility == model.ConfigVisibilityVisible { - list = append(list, sc) - } - } - } - return list, nil - } - - database := db.DB(ctx) - if database == nil { - return nil, errors.New(errDatabaseNotInitialized) - } - - var configs []model.SystemConfig - if err := database.Where("visibility = ?", model.ConfigVisibilityVisible).Find(&configs).Error; err != nil { - return nil, err - } - - // Populate visible configs to local cache store - for _, cfg := range configs { - valBytes, err := json.Marshal(cfg) - if err == nil { - ram.Set(ram.CacheItem{ - Key: cfg.Key, - Value: string(valBytes), - Type: ConfigCacheType, - TTL: determineTTL(cfg.Key), - }) - } - } - - return configs, nil -} - -// GetIntByKey queries config and converts to int. -func GetIntByKey(ctx context.Context, key string) (int, error) { - sc, err := GetSystemConfigByKey(ctx, key) - if err != nil { - return 0, err - } - - value, err := strconv.Atoi(sc.Value) - if err != nil { - return 0, fmt.Errorf(errConfigIntParseFailed, key, sc.Value, err) - } - - return value, nil -} - -// GetDecimalByKey queries config and converts to decimal.Decimal. -func GetDecimalByKey(ctx context.Context, key string, precision int32) (decimal.Decimal, error) { - sc, err := GetSystemConfigByKey(ctx, key) - if err != nil { - return decimal.Zero, err - } - - value, err := decimal.NewFromString(sc.Value) - if err != nil { - return decimal.Zero, fmt.Errorf(errConfigDecimalParseFailed, key, sc.Value, err) - } - - return value.Truncate(precision), nil -} - -// GetBoolByKey queries config and converts to bool. -func GetBoolByKey(ctx context.Context, key string) (bool, error) { - sc, err := GetSystemConfigByKey(ctx, key) - if err != nil { - return false, err - } - - value, err := strconv.ParseBool(sc.Value) - if err != nil { - return false, fmt.Errorf(errConfigBoolParseFailed, key, sc.Value, err) - } - - return value, nil -} - -// GetMenuDisplayConfig queries and parses menu config. -func GetMenuDisplayConfig(ctx context.Context) (map[string]bool, error) { - sc, err := GetSystemConfigByKey(ctx, model.ConfigKeyMenuDisplayConfig) - if err != nil { - return nil, err - } - - config := make(map[string]bool) - if sc.Value == "" || sc.Value == "{}" { - return config, nil - } - - if err := json.Unmarshal([]byte(sc.Value), &config); err != nil { - return nil, fmt.Errorf(errParseMenuDisplayConfigFailed, err) - } - - return config, nil -} diff --git a/internal/repository/system_config_admin.go b/internal/repository/system_config_admin.go deleted file mode 100644 index 247efa28..00000000 --- a/internal/repository/system_config_admin.go +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package repository - -import ( - "context" - "errors" - - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "gorm.io/gorm" -) - -// ListAdminSystemConfigs returns all configs, optionally filtered by type. -func ListAdminSystemConfigs(ctx context.Context, configType string) ([]model.SystemConfig, error) { - query := db.DB(ctx).Order("created_at DESC") - if configType != "" { - query = query.Where("type = ?", configType) - } - var configs []model.SystemConfig - if err := query.Find(&configs).Error; err != nil { - return nil, err - } - return configs, nil -} - -// GetAdminSystemConfigByKey loads a config directly from PostgreSQL. -func GetAdminSystemConfigByKey(ctx context.Context, key string) (model.SystemConfig, error) { - var config model.SystemConfig - if err := db.DB(ctx).Where("key = ?", key).First(&config).Error; err != nil { - return model.SystemConfig{}, err - } - return config, nil -} - -// SystemConfigExists reports whether a config key already exists. -func SystemConfigExists(ctx context.Context, key string) (bool, error) { - var existing model.SystemConfig - err := db.DB(ctx).Where("key = ?", key).First(&existing).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - if err != nil { - return false, err - } - return true, nil -} - -// CreateSystemConfig persists a new system config row. -func CreateSystemConfig(ctx context.Context, config *model.SystemConfig) error { - return db.DB(ctx).Create(config).Error -} - -// UpdateSystemConfigFields applies partial updates to a system config row. -func UpdateSystemConfigFields(ctx context.Context, config *model.SystemConfig, updates map[string]any) error { - return db.DB(ctx).Model(config).Updates(updates).Error -} - -// SaveOrUpdateSystemConfig creates or updates a config row and invalidates cache. -func SaveOrUpdateSystemConfig(ctx context.Context, key, value string) error { - var sc model.SystemConfig - err := db.DB(ctx).Where("key = ?", key).First(&sc).Error - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - - if errors.Is(err, gorm.ErrRecordNotFound) { - sc = model.SystemConfig{ - Key: key, - Value: value, - Type: configTypeSystem, - Visibility: model.ConfigVisibilityHidden, - } - if err := db.DB(ctx).Create(&sc).Error; err != nil { - return err - } - } else { - sc.Value = value - if err := db.DB(ctx).Save(&sc).Error; err != nil { - return err - } - } - return InvalidateSystemConfigCache(ctx, key) -} diff --git a/internal/repository/task_execution.go b/internal/repository/task_execution.go deleted file mode 100644 index 920ff13c..00000000 --- a/internal/repository/task_execution.go +++ /dev/null @@ -1,305 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package repository - -import ( - "context" - "errors" - "fmt" - "strings" - "time" - - "github.com/redis/go-redis/v9" - "gorm.io/gorm" - - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/util" -) - -const ( - taskExecutionLogRedisKeyPrefix = "task:execution:log:" - taskExecutionLogExpiration = 24 * time.Hour - taskExecutionLogMaxLines = 1000 -) - -// CreateTaskExecution 创建任务执行记录 -func CreateTaskExecution(ctx context.Context, execution *model.TaskExecution) error { - execution.ID = idgen.NextUint64ID() - return db.DB(ctx).Create(execution).Error -} - -// UpdateTaskExecution 更新任务执行记录,忽略由 Redis 缓冲和归档流程管理的 log 字段。 -func UpdateTaskExecution(ctx context.Context, execution *model.TaskExecution) error { - return db.DB(ctx).Omit("log").Save(execution).Error -} - -// GetTaskExecutionByTaskID 根据 TaskID 获取执行记录 -func GetTaskExecutionByTaskID(ctx context.Context, taskID string) (*model.TaskExecution, error) { - var execution model.TaskExecution - if err := db.DB(ctx).Where("task_id = ?", taskID).First(&execution).Error; err != nil { - return nil, err - } - if err := loadTaskExecutionLog(ctx, &execution); err != nil { - return nil, err - } - return &execution, nil -} - -// GetTaskExecutionByID 根据 ID 获取执行记录 -func GetTaskExecutionByID(ctx context.Context, id uint64) (*model.TaskExecution, error) { - var execution model.TaskExecution - if err := db.DB(ctx).Where("id = ?", id).First(&execution).Error; err != nil { - return nil, err - } - if err := loadTaskExecutionLog(ctx, &execution); err != nil { - return nil, err - } - return &execution, nil -} - -// GetLatestTaskExecutionByTaskType returns the most recent execution for a task type. -// ok is false when no row exists. -func GetLatestTaskExecutionByTaskType(ctx context.Context, taskType string) (*model.TaskExecution, bool, error) { - var execution model.TaskExecution - err := db.DB(ctx). - Where("task_type = ?", taskType). - Order("id DESC"). - First(&execution).Error - if err == nil { - if loadErr := loadTaskExecutionLog(ctx, &execution); loadErr != nil { - return nil, false, loadErr - } - return &execution, true, nil - } - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, false, nil - } - return nil, false, err -} - -// AppendTaskExecutionLog 将日志追加到 Redis 缓冲,任务完成后再持久化到数据库。 -func AppendTaskExecutionLog(ctx context.Context, taskID string, logLine string) error { - if db.Redis == nil { - return errors.New("redis client is not initialized") - } - - now := time.Now().Format("15:04:05") - line := fmt.Sprintf("[%s] %s\n", now, logLine) - key := taskExecutionLogRedisKey(taskID) - - _, err := db.Redis.TxPipelined(ctx, func(pipe redis.Pipeliner) error { - pipe.RPush(ctx, key, line) - pipe.LTrim(ctx, key, -taskExecutionLogMaxLines, -1) - pipe.Expire(ctx, key, taskExecutionLogExpiration) - return nil - }) - if err != nil { - return fmt.Errorf("append task execution log to redis: %w", err) - } - return nil -} - -// FlushTaskExecutionLog 将 Redis 中的完整任务日志写入数据库,并在成功后清理缓存。 -func FlushTaskExecutionLog(ctx context.Context, taskID string) error { - if db.Redis == nil { - return errors.New("redis client is not initialized") - } - - key := taskExecutionLogRedisKey(taskID) - logLines, err := db.Redis.LRange(ctx, key, 0, -1).Result() - if err != nil { - return fmt.Errorf("get task execution log from redis: %w", err) - } - if len(logLines) == 0 { - return nil - } - logText := strings.Join(logLines, "") - - result := db.DB(ctx).Model(&model.TaskExecution{}). - Where("task_id = ?", taskID). - Update("log", logText) - if result.Error != nil { - return fmt.Errorf("persist task execution log: %w", result.Error) - } - if result.RowsAffected == 0 { - return fmt.Errorf("persist task execution log: task %q not found", taskID) - } - - if err := db.Redis.Del(ctx, key).Err(); err != nil { - return fmt.Errorf("delete persisted task execution log from redis: %w", err) - } - return nil -} - -// ListTaskExecutions 分页查询任务执行记录 -func ListTaskExecutions(ctx context.Context, req model.ListTaskExecutionsRequest) ([]model.TaskExecution, int64, error) { - if req.Page <= 0 { - req.Page = 1 - } - if req.PageSize <= 0 { - req.PageSize = 20 - } - - query := db.DB(ctx).Model(&model.TaskExecution{}) - - if req.Status != "" { - query = query.Where("status = ?", req.Status) - } - if req.TaskType != "" { - query = query.Where("task_type = ?", req.TaskType) - } else if types := parseTaskTypesFilter(req.TaskTypes); len(types) > 0 { - query = query.Where("task_type IN ?", types) - } else if req.TaskTypePrefix != "" { - query = query.Where("task_type LIKE ? ESCAPE '\\'", util.EscapeLike(req.TaskTypePrefix)+"%") - } - - var total int64 - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - - var executions []model.TaskExecution - offset := (req.Page - 1) * req.PageSize - if err := query.Order("id DESC").Offset(offset).Limit(req.PageSize).Find(&executions).Error; err != nil { - return nil, 0, err - } - if err := loadTaskExecutionLogs(ctx, executions); err != nil { - return nil, 0, err - } - - return executions, total, nil -} - -func parseTaskTypesFilter(raw string) []string { - if strings.TrimSpace(raw) == "" { - return nil - } - parts := strings.Split(raw, ",") - out := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - out = append(out, part) - } - } - return out -} - -// MarkFailedTaskExecutionsSucceededTx marks failed executions of a task type as succeeded within a transaction. -func MarkFailedTaskExecutionsSucceededTx( - tx *gorm.DB, - taskType string, - result string, - finishedAt time.Time, -) error { - return tx.Model(&model.TaskExecution{}). - Where("task_type = ? AND status = ?", taskType, model.TaskExecutionStatusFailed). - Updates(map[string]any{ - "status": model.TaskExecutionStatusSucceeded, - "result": result, - "finished_at": finishedAt, - }).Error -} - -// CleanupTaskExecutionLogs removes finished task execution logs according to frequency-based retention. -func CleanupTaskExecutionLogs(ctx context.Context, now time.Time) (model.TaskExecutionCleanupStats, error) { - const ( - frequencyWindowDays = 30 - highFrequencyThreshold = frequencyWindowDays - ) - - frequencyWindowStart := now.AddDate(0, 0, -frequencyWindowDays) - highFrequencyCutoff := now.AddDate(0, 0, -3) - lowFrequencyCutoff := now.AddDate(0, 0, -30) - terminalStatuses := []model.TaskExecutionStatus{model.TaskExecutionStatusSucceeded, model.TaskExecutionStatusFailed} - - var highFrequencyTaskTypes []string - if err := db.DB(ctx). - Model(&model.TaskExecution{}). - Select("task_type"). - Where("created_at >= ?", frequencyWindowStart). - Group("task_type"). - Having("COUNT(*) > ?", highFrequencyThreshold). - Pluck("task_type", &highFrequencyTaskTypes).Error; err != nil { - return model.TaskExecutionCleanupStats{}, fmt.Errorf("query high-frequency task types: %w", err) - } - - var highFrequencyDeleted int64 - if len(highFrequencyTaskTypes) > 0 { - highFrequencyResult := db.DB(ctx). - Where("status IN ?", terminalStatuses). - Where("created_at < ?", highFrequencyCutoff). - Where("task_type IN ?", highFrequencyTaskTypes). - Delete(&model.TaskExecution{}) - if highFrequencyResult.Error != nil { - return model.TaskExecutionCleanupStats{}, fmt.Errorf("delete high-frequency task execution logs: %w", highFrequencyResult.Error) - } - highFrequencyDeleted = highFrequencyResult.RowsAffected - } - - lowFrequencyQuery := db.DB(ctx). - Where("status IN ?", terminalStatuses). - Where("created_at < ?", lowFrequencyCutoff) - if len(highFrequencyTaskTypes) > 0 { - lowFrequencyQuery = lowFrequencyQuery.Where("task_type NOT IN ?", highFrequencyTaskTypes) - } - lowFrequencyResult := lowFrequencyQuery.Delete(&model.TaskExecution{}) - if lowFrequencyResult.Error != nil { - return model.TaskExecutionCleanupStats{}, fmt.Errorf("delete low-frequency task execution logs: %w", lowFrequencyResult.Error) - } - - return model.TaskExecutionCleanupStats{ - HighFrequencyDeleted: highFrequencyDeleted, - LowFrequencyDeleted: lowFrequencyResult.RowsAffected, - }, nil -} - -func taskExecutionLogRedisKey(taskID string) string { - return db.PrefixedKey(taskExecutionLogRedisKeyPrefix + taskID) -} - -func loadTaskExecutionLog(ctx context.Context, execution *model.TaskExecution) error { - if db.Redis == nil { - return nil - } - - logLines, err := db.Redis.LRange(ctx, taskExecutionLogRedisKey(execution.TaskID), 0, -1).Result() - if err != nil { - return fmt.Errorf("get task execution log from redis: %w", err) - } - if len(logLines) == 0 { - return nil - } - - execution.Log = strings.Join(logLines, "") - return nil -} - -func loadTaskExecutionLogs(ctx context.Context, executions []model.TaskExecution) error { - if db.Redis == nil || len(executions) == 0 { - return nil - } - - commands := make([]*redis.StringSliceCmd, len(executions)) - _, err := db.Redis.Pipelined(ctx, func(pipe redis.Pipeliner) error { - for i := range executions { - commands[i] = pipe.LRange(ctx, taskExecutionLogRedisKey(executions[i].TaskID), 0, -1) - } - return nil - }) - if err != nil { - return fmt.Errorf("get task execution logs from redis: %w", err) - } - - for i := range executions { - logLines := commands[i].Val() - if len(logLines) > 0 { - executions[i].Log = strings.Join(logLines, "") - } - } - return nil -} diff --git a/internal/repository/task_execution_test.go b/internal/repository/task_execution_test.go deleted file mode 100644 index 9d169be2..00000000 --- a/internal/repository/task_execution_test.go +++ /dev/null @@ -1,514 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package repository - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/Rain-kl/Wavelet/internal/model" - - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/alicebob/miniredis/v2" - "github.com/glebarez/sqlite" - "github.com/redis/go-redis/v9" - "github.com/redis/go-redis/v9/maintnotifications" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/gorm" -) - -func setupTaskExecutionTestEnvironment(t *testing.T) func() { - sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ - DisableForeignKeyConstraintWhenMigrating: true, - }) - require.NoError(t, err) - - err = sqliteDB.AutoMigrate(&model.TaskExecution{}) - require.NoError(t, err) - - miniRedis, err := miniredis.Run() - require.NoError(t, err) - redisClient := redis.NewClient(&redis.Options{ - Addr: miniRedis.Addr(), - MaintNotificationsConfig: &maintnotifications.Config{ - Mode: maintnotifications.ModeDisabled, - }, - }) - - db.SetDB(sqliteDB) - db.Redis = redisClient - - return func() { - require.NoError(t, redisClient.Close()) - miniRedis.Close() - db.SetDB(nil) - db.Redis = nil - } -} - -func TestCreateTaskExecution(t *testing.T) { - cleanup := setupTaskExecutionTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - execution := &model.TaskExecution{ - TaskID: "manual_cleanup_123", - TaskType: "system:cleanup", - TaskName: "清理未使用上传", - Status: model.TaskExecutionStatusPending, - Retryable: true, - MaxRetry: 3, - RetryCount: 0, - Payload: `{"test": true}`, - TriggeredBy: "manual", - } - - err := CreateTaskExecution(ctx, execution) - require.NoError(t, err) - assert.NotZero(t, execution.ID, "ID should be generated") - assert.NotZero(t, execution.CreatedAt, "CreatedAt should be set") - assert.NotZero(t, execution.UpdatedAt, "UpdatedAt should be set") -} - -func TestGetTaskExecutionByTaskID(t *testing.T) { - cleanup := setupTaskExecutionTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - // 创建记录 - execution := &model.TaskExecution{ - TaskID: "test_task_id_001", - TaskType: "system:cleanup", - TaskName: "清理未使用上传", - Status: model.TaskExecutionStatusPending, - Retryable: true, - MaxRetry: 3, - TriggeredBy: "manual", - } - err := CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - // 按 TaskID 查询 - found, err := GetTaskExecutionByTaskID(ctx, "test_task_id_001") - require.NoError(t, err) - assert.Equal(t, execution.ID, found.ID) - assert.Equal(t, "test_task_id_001", found.TaskID) - assert.Equal(t, model.TaskExecutionStatusPending, found.Status) - assert.True(t, found.Retryable) - assert.Equal(t, 3, found.MaxRetry) - - // 查询不存在的 TaskID - _, err = GetTaskExecutionByTaskID(ctx, "nonexistent") - assert.Error(t, err, "should return error for non-existent taskID") -} - -func TestGetTaskExecutionByID(t *testing.T) { - cleanup := setupTaskExecutionTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - execution := &model.TaskExecution{ - TaskID: "test_by_id_001", - TaskType: "system:cleanup", - TaskName: "清理未使用上传", - Status: model.TaskExecutionStatusPending, - TriggeredBy: "system", - } - err := CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - // 按主键查询 - found, err := GetTaskExecutionByID(ctx, execution.ID) - require.NoError(t, err) - assert.Equal(t, execution.TaskID, found.TaskID) -} - -func TestUpdateTaskExecution(t *testing.T) { - cleanup := setupTaskExecutionTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - // 创建记录 - execution := &model.TaskExecution{ - TaskID: "test_update_001", - TaskType: "system:cleanup", - TaskName: "清理未使用上传", - Status: model.TaskExecutionStatusPending, - TriggeredBy: "manual", - } - err := CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - // 更新状态为 running - now := time.Now() - execution.Status = model.TaskExecutionStatusRunning - execution.StartedAt = &now - err = UpdateTaskExecution(ctx, execution) - require.NoError(t, err) - - // 验证更新 - found, err := GetTaskExecutionByTaskID(ctx, "test_update_001") - require.NoError(t, err) - assert.Equal(t, model.TaskExecutionStatusRunning, found.Status) - assert.NotNil(t, found.StartedAt) - - // 更新为 succeeded - finishTime := time.Now() - execution.Status = model.TaskExecutionStatusSucceeded - execution.FinishedAt = &finishTime - execution.Duration = 1500 - execution.Result = "共清理 50 个文件" - err = UpdateTaskExecution(ctx, execution) - require.NoError(t, err) - - found, err = GetTaskExecutionByTaskID(ctx, "test_update_001") - require.NoError(t, err) - assert.Equal(t, model.TaskExecutionStatusSucceeded, found.Status) - assert.Equal(t, int64(1500), found.Duration) - assert.Equal(t, "共清理 50 个文件", found.Result) -} - -func TestUpdateTaskExecutionFailed(t *testing.T) { - cleanup := setupTaskExecutionTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - execution := &model.TaskExecution{ - TaskID: "test_fail_001", - TaskType: "system:cleanup", - TaskName: "清理未使用上传", - Status: model.TaskExecutionStatusPending, - Retryable: true, - MaxRetry: 3, - TriggeredBy: "manual", - } - err := CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - // 标记为失败 - now := time.Now() - execution.Status = model.TaskExecutionStatusFailed - execution.StartedAt = &now - execution.FinishedAt = &now - execution.Duration = 200 - execution.ErrorMessage = "S3 连接超时" - err = UpdateTaskExecution(ctx, execution) - require.NoError(t, err) - - found, err := GetTaskExecutionByTaskID(ctx, "test_fail_001") - require.NoError(t, err) - assert.Equal(t, model.TaskExecutionStatusFailed, found.Status) - assert.Equal(t, "S3 连接超时", found.ErrorMessage) - assert.Equal(t, int64(200), found.Duration) -} - -func TestUpdateTaskExecutionDoesNotPersistBufferedLog(t *testing.T) { - cleanup := setupTaskExecutionTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - execution := &model.TaskExecution{ - TaskID: "test_omit_log_001", - TaskType: "system:cleanup", - TaskName: "清理未使用上传", - Status: model.TaskExecutionStatusPending, - TriggeredBy: "manual", - } - err := CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - // 运行中的日志仅缓存在 Redis。 - err = AppendTaskExecutionLog(ctx, "test_omit_log_001", "第一条执行日志") - require.NoError(t, err) - - assert.Empty(t, execution.Log) - - execution.Status = model.TaskExecutionStatusSucceeded - execution.Duration = 100 - err = UpdateTaskExecution(ctx, execution) - require.NoError(t, err) - - var persisted model.TaskExecution - err = db.DB(ctx).Where("task_id = ?", "test_omit_log_001").First(&persisted).Error - require.NoError(t, err) - assert.Equal(t, model.TaskExecutionStatusSucceeded, persisted.Status) - assert.Empty(t, persisted.Log) - - found, err := GetTaskExecutionByTaskID(ctx, "test_omit_log_001") - require.NoError(t, err) - assert.Contains(t, found.Log, "第一条执行日志") -} - -func TestAppendTaskExecutionLog(t *testing.T) { - cleanup := setupTaskExecutionTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - execution := &model.TaskExecution{ - TaskID: "test_log_001", - TaskType: "system:cleanup", - TaskName: "清理未使用上传", - Status: model.TaskExecutionStatusPending, - TriggeredBy: "manual", - } - err := CreateTaskExecution(ctx, execution) - require.NoError(t, err) - - // 追加多条日志 - err = AppendTaskExecutionLog(ctx, "test_log_001", "开始扫描未使用上传文件") - require.NoError(t, err) - - err = AppendTaskExecutionLog(ctx, "test_log_001", "本批次找到 42 个待清理文件") - require.NoError(t, err) - - err = AppendTaskExecutionLog(ctx, "test_log_001", "清理完成,共删除 42 个文件") - require.NoError(t, err) - - // 读取时优先返回 Redis 中的在途日志。 - found, err := GetTaskExecutionByTaskID(ctx, "test_log_001") - require.NoError(t, err) - assert.Contains(t, found.Log, "开始扫描未使用上传文件") - assert.Contains(t, found.Log, "本批次找到 42 个待清理文件") - assert.Contains(t, found.Log, "清理完成,共删除 42 个文件") - - var persisted model.TaskExecution - err = db.DB(ctx).Where("task_id = ?", "test_log_001").First(&persisted).Error - require.NoError(t, err) - assert.Empty(t, persisted.Log) - - err = FlushTaskExecutionLog(ctx, "test_log_001") - require.NoError(t, err) - - err = db.DB(ctx).Where("task_id = ?", "test_log_001").First(&persisted).Error - require.NoError(t, err) - assert.Contains(t, persisted.Log, "开始扫描未使用上传文件") - - exists, err := db.Redis.Exists(ctx, taskExecutionLogRedisKey("test_log_001")).Result() - require.NoError(t, err) - assert.Zero(t, exists) -} - -func TestAppendTaskExecutionLogLimitsLinesAndRefreshesTTL(t *testing.T) { - cleanup := setupTaskExecutionTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - const taskID = "limited_log_001" - for i := 0; i < taskExecutionLogMaxLines+5; i++ { - err := AppendTaskExecutionLog(ctx, taskID, fmt.Sprintf("日志-%04d", i)) - require.NoError(t, err) - } - - key := taskExecutionLogRedisKey(taskID) - logLines, err := db.Redis.LRange(ctx, key, 0, -1).Result() - require.NoError(t, err) - assert.Len(t, logLines, taskExecutionLogMaxLines) - assert.Contains(t, logLines[0], "日志-0005") - assert.Contains(t, logLines[len(logLines)-1], "日志-1004") - - ttl, err := db.Redis.TTL(ctx, key).Result() - require.NoError(t, err) - assert.Equal(t, taskExecutionLogExpiration, ttl) -} - -func TestAppendTaskExecutionLogNonExistent(t *testing.T) { - cleanup := setupTaskExecutionTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - // Redis 缓冲不依赖数据库记录是否已经创建。 - err := AppendTaskExecutionLog(ctx, "nonexistent_task", "测试日志") - assert.NoError(t, err) - - err = FlushTaskExecutionLog(ctx, "nonexistent_task") - assert.Error(t, err) -} - -func TestGetTaskExecutionLogPrefersRedis(t *testing.T) { - cleanup := setupTaskExecutionTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - execution := &model.TaskExecution{ - TaskID: "redis_priority_001", - TaskType: "system:cleanup", - TaskName: "清理未使用上传", - Status: model.TaskExecutionStatusRunning, - Log: "数据库旧日志", - TriggeredBy: "manual", - } - err := CreateTaskExecution(ctx, execution) - require.NoError(t, err) - err = AppendTaskExecutionLog(ctx, execution.TaskID, "Redis 最新日志") - require.NoError(t, err) - - found, err := GetTaskExecutionByID(ctx, execution.ID) - require.NoError(t, err) - assert.Contains(t, found.Log, "Redis 最新日志") - assert.NotContains(t, found.Log, "数据库旧日志") -} - -func TestListTaskExecutions(t *testing.T) { - cleanup := setupTaskExecutionTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - // 创建多条记录,包含不同状态和类型 - records := []*model.TaskExecution{ - {TaskID: "list_001", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: model.TaskExecutionStatusSucceeded, TriggeredBy: "manual"}, - {TaskID: "list_002", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: model.TaskExecutionStatusFailed, TriggeredBy: "system"}, - {TaskID: "list_003", TaskType: "other:task", TaskName: "其他任务", Status: model.TaskExecutionStatusPending, TriggeredBy: "manual"}, - {TaskID: "list_004", TaskType: "system:cleanup", TaskName: "系统垃圾清理", Status: model.TaskExecutionStatusRunning, TriggeredBy: "manual"}, - {TaskID: "list_005", TaskType: "other:task", TaskName: "其他任务", Status: model.TaskExecutionStatusSucceeded, TriggeredBy: "system"}, - } - for _, r := range records { - err := CreateTaskExecution(ctx, r) - require.NoError(t, err) - } - err := AppendTaskExecutionLog(ctx, "list_004", "运行中的 Redis 日志") - require.NoError(t, err) - - // 查询全部(分页) - items, total, err := ListTaskExecutions(ctx, model.ListTaskExecutionsRequest{Page: 1, PageSize: 10}) - require.NoError(t, err) - assert.Equal(t, int64(5), total) - assert.Len(t, items, 5) - for _, item := range items { - if item.TaskID == "list_004" { - assert.Contains(t, item.Log, "运行中的 Redis 日志") - } - } - - // 按状态筛选:failed - items, total, err = ListTaskExecutions(ctx, model.ListTaskExecutionsRequest{Status: "failed", Page: 1, PageSize: 10}) - require.NoError(t, err) - assert.Equal(t, int64(1), total) - assert.Len(t, items, 1) - assert.Equal(t, "list_002", items[0].TaskID) - - // 按类型筛选 - _, total, err = ListTaskExecutions(ctx, model.ListTaskExecutionsRequest{TaskType: "other:task", Page: 1, PageSize: 10}) - require.NoError(t, err) - assert.Equal(t, int64(2), total) - - // 分页测试 - items, total, err = ListTaskExecutions(ctx, model.ListTaskExecutionsRequest{Page: 1, PageSize: 2}) - require.NoError(t, err) - assert.Equal(t, int64(5), total) - assert.Len(t, items, 2) - - items2, total2, err := ListTaskExecutions(ctx, model.ListTaskExecutionsRequest{Page: 2, PageSize: 2}) - require.NoError(t, err) - assert.Equal(t, int64(5), total2) - assert.Len(t, items2, 2) - - // 确保分页数据不重复 - assert.NotEqual(t, items[0].ID, items2[0].ID) - - // 状态 + 类型组合筛选 - items, total, err = ListTaskExecutions(ctx, model.ListTaskExecutionsRequest{Status: "succeeded", TaskType: "system:cleanup", Page: 1, PageSize: 10}) - require.NoError(t, err) - assert.Equal(t, int64(1), total) - assert.Equal(t, "list_001", items[0].TaskID) - - // 按类型前缀筛选 - items, total, err = ListTaskExecutions(ctx, model.ListTaskExecutionsRequest{TaskTypePrefix: "system:", Page: 1, PageSize: 10}) - require.NoError(t, err) - assert.Equal(t, int64(3), total) - assert.Len(t, items, 3) - - // 按多类型 IN 筛选 - items, total, err = ListTaskExecutions(ctx, model.ListTaskExecutionsRequest{ - TaskTypes: "system:cleanup,other:task", - Page: 1, - PageSize: 10, - }) - require.NoError(t, err) - assert.Equal(t, int64(5), total) - assert.Len(t, items, 5) - - // 精确类型优先于 task_types / 前缀 - items, total, err = ListTaskExecutions(ctx, model.ListTaskExecutionsRequest{ - TaskType: "other:task", - TaskTypes: "system:cleanup", - TaskTypePrefix: "system:", - Page: 1, - PageSize: 10, - }) - require.NoError(t, err) - assert.Equal(t, int64(2), total) - assert.Len(t, items, 2) -} - -func TestListTaskExecutionsDefaultPaging(t *testing.T) { - cleanup := setupTaskExecutionTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - // 不传分页参数,应使用默认值 page=1, pageSize=20 - items, total, err := ListTaskExecutions(ctx, model.ListTaskExecutionsRequest{}) - require.NoError(t, err) - assert.Equal(t, int64(0), total) - assert.Len(t, items, 0) -} - -func TestCleanupTaskExecutionLogs(t *testing.T) { - cleanup := setupTaskExecutionTestEnvironment(t) - defer cleanup() - ctx := context.Background() - - now := time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC) - for i := 0; i < 31; i++ { - createTaskExecutionForCleanup(t, ctx, fmt.Sprintf("high_recent_%02d", i), "high:task", model.TaskExecutionStatusSucceeded, now.Add(-2*time.Hour)) - } - createTaskExecutionForCleanup(t, ctx, "high_old_4d", "high:task", model.TaskExecutionStatusSucceeded, now.AddDate(0, 0, -4)) - createTaskExecutionForCleanup(t, ctx, "high_old_40d", "high:task", model.TaskExecutionStatusFailed, now.AddDate(0, 0, -40)) - createTaskExecutionForCleanup(t, ctx, "high_running_old", "high:task", model.TaskExecutionStatusRunning, now.AddDate(0, 0, -10)) - createTaskExecutionForCleanup(t, ctx, "low_old_31d", "low:task", model.TaskExecutionStatusSucceeded, now.AddDate(0, 0, -31)) - createTaskExecutionForCleanup(t, ctx, "low_recent_29d", "low:task", model.TaskExecutionStatusSucceeded, now.AddDate(0, 0, -29)) - createTaskExecutionForCleanup(t, ctx, "low_pending_old", "low:task", model.TaskExecutionStatusPending, now.AddDate(0, 0, -45)) - - stats, err := CleanupTaskExecutionLogs(ctx, now) - require.NoError(t, err) - assert.Equal(t, int64(2), stats.HighFrequencyDeleted) - assert.Equal(t, int64(1), stats.LowFrequencyDeleted) - - for _, taskID := range []string{"high_old_4d", "high_old_40d", "low_old_31d"} { - var count int64 - err := db.DB(ctx).Model(&model.TaskExecution{}).Where("task_id = ?", taskID).Count(&count).Error - require.NoError(t, err) - assert.Equal(t, int64(0), count, "CleanupTaskExecutionLogs(%s) should delete expired log", taskID) - } - for _, taskID := range []string{"high_recent_00", "high_running_old", "low_recent_29d", "low_pending_old"} { - var count int64 - err := db.DB(ctx).Model(&model.TaskExecution{}).Where("task_id = ?", taskID).Count(&count).Error - require.NoError(t, err) - assert.Equal(t, int64(1), count, "CleanupTaskExecutionLogs(%s) should keep retained log", taskID) - } -} - -func TestTaskExecutionTableName(t *testing.T) { - execution := model.TaskExecution{} - assert.Equal(t, "w_task_executions", execution.TableName()) -} - -func createTaskExecutionForCleanup(t *testing.T, ctx context.Context, taskID string, taskType string, status model.TaskExecutionStatus, createdAt time.Time) { - t.Helper() - - execution := &model.TaskExecution{ - TaskID: taskID, - TaskType: taskType, - TaskName: taskType, - Status: status, - CreatedAt: createdAt, - UpdatedAt: createdAt, - TriggeredBy: "system", - } - err := CreateTaskExecution(ctx, execution) - require.NoError(t, err) -} diff --git a/internal/repository/template.go b/internal/repository/template.go deleted file mode 100644 index 1cba12e5..00000000 --- a/internal/repository/template.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package repository - -import ( - "context" - "errors" - - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "gorm.io/gorm" -) - -// ListTemplates returns all templates ordered by system flag and creation time. -func ListTemplates(ctx context.Context) ([]model.Template, error) { - var templates []model.Template - if err := db.DB(ctx).Order("is_system DESC, created_at DESC").Find(&templates).Error; err != nil { - return nil, err - } - return templates, nil -} - -// GetTemplateByKey loads a template by its key. -func GetTemplateByKey(ctx context.Context, key string) (model.Template, error) { - var tmpl model.Template - if err := db.DB(ctx).Where("key = ?", key).First(&tmpl).Error; err != nil { - return model.Template{}, err - } - return tmpl, nil -} - -// TemplateExistsByKey reports whether a template key is already taken. -func TemplateExistsByKey(ctx context.Context, key string) (bool, error) { - var existing model.Template - err := db.DB(ctx).Where("key = ?", key).First(&existing).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - if err != nil { - return false, err - } - return true, nil -} - -// CreateTemplate persists a new template. -func CreateTemplate(ctx context.Context, tmpl *model.Template) error { - return db.DB(ctx).Create(tmpl).Error -} - -// SaveTemplate updates an existing template. -func SaveTemplate(ctx context.Context, tmpl *model.Template) error { - return db.DB(ctx).Save(tmpl).Error -} - -// DeleteTemplate removes a template record. -func DeleteTemplate(ctx context.Context, tmpl *model.Template) error { - return db.DB(ctx).Delete(tmpl).Error -} diff --git a/internal/repository/upload_stat.go b/internal/repository/upload_stat.go deleted file mode 100644 index cf87c6bc..00000000 --- a/internal/repository/upload_stat.go +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package repository - -import ( - "context" - - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" -) - -// ListUploadStats returns all upload statistics rows. -func ListUploadStats(ctx context.Context) ([]model.UploadStat, error) { - var stats []model.UploadStat - if err := db.DB(ctx).Find(&stats).Error; err != nil { - return nil, err - } - return stats, nil -} diff --git a/internal/repository/user.go b/internal/repository/user.go deleted file mode 100644 index 6569acab..00000000 --- a/internal/repository/user.go +++ /dev/null @@ -1,295 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package repository - -import ( - "context" - "errors" - "time" - - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/util" - "gorm.io/gorm" -) - -// GetUserByID loads an active user by ID. -func GetUserByID(ctx context.Context, id uint64) (model.User, error) { - var user model.User - if err := db.DB(ctx).Where("id = ?", id).First(&user).Error; err != nil { - return model.User{}, err - } - return user, nil -} - -// GetUserByUsername loads a user by username. -func GetUserByUsername(ctx context.Context, username string) (model.User, error) { - var user model.User - if err := db.DB(ctx).Where("username = ?", username).First(&user).Error; err != nil { - return model.User{}, err - } - return user, nil -} - -// GetSystemUser loads the built-in system user, or returns a synthetic fallback. -func GetSystemUser(ctx context.Context) model.User { - var user model.User - if err := db.DB(ctx).Where("username = ?", configTypeSystem).First(&user).Error; err == nil { - return user - } - return model.User{ - ID: 999, - Username: configTypeSystem, - Nickname: "系统", - } -} - -// GetFirstAdminUser loads the earliest admin user. -func GetFirstAdminUser(ctx context.Context) (model.User, error) { - var user model.User - if err := db.DB(ctx).Where("is_admin = ?", true).Order("id asc").First(&user).Error; err != nil { - return model.User{}, err - } - return user, nil -} - -// AdminUserListFilter filters admin user list queries. -type AdminUserListFilter struct { - UserID *uint64 - Username string - Email string - Page int - PageSize int -} - -// ListAdminUsers returns paginated users for the admin console. -func ListAdminUsers(ctx context.Context, filter AdminUserListFilter) (int64, []model.User, error) { - query := db.DB(ctx).Model(&model.User{}) - if filter.UserID != nil { - query = query.Where("id = ?", *filter.UserID) - } - if filter.Username != "" { - query = query.Where("username LIKE ? ESCAPE '\\'", util.EscapeLike(filter.Username)+"%") - } - if filter.Email != "" { - query = query.Where("email LIKE ? ESCAPE '\\'", util.EscapeLike(filter.Email)+"%") - } - - var total int64 - if err := query.Count(&total).Error; err != nil { - return 0, nil, err - } - - var users []model.User - offset := (filter.Page - 1) * filter.PageSize - if err := query. - Select("id, username, nickname, email, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at"). - Order("id ASC"). - Offset(offset). - Limit(filter.PageSize). - Find(&users).Error; err != nil { - return 0, nil, err - } - return total, users, nil -} - -// GetAdminUserDetail loads full user profile fields for admin detail view. -func GetAdminUserDetail(ctx context.Context, id uint64) (model.User, error) { - var user model.User - if err := db.DB(ctx). - Select("id, username, nickname, email, avatar_url, is_active, is_admin, bio, phone, gender, website, location, last_login_at, created_at, updated_at"). - Where("id = ?", id). - First(&user).Error; err != nil { - return model.User{}, err - } - return user, nil -} - -// UserAdminFlags stores minimal user authorization flags. -type UserAdminFlags struct { - ID uint64 - IsAdmin bool -} - -// GetUserAdminFlags loads id and is_admin for authorization checks. -func GetUserAdminFlags(ctx context.Context, id uint64) (UserAdminFlags, error) { - var flags UserAdminFlags - if err := db.DB(ctx). - Model(&model.User{}). - Select("id, is_admin"). - Where("id = ?", id). - First(&flags).Error; err != nil { - return UserAdminFlags{}, err - } - return flags, nil -} - -// UpdateUserActive updates the is_active flag for a user. -func UpdateUserActive(ctx context.Context, id uint64, active bool) error { - return db.DB(ctx).Model(&model.User{}).Where("id = ?", id).Update("is_active", active).Error -} - -// DeleteUserWithRelations removes a user and related access tokens / external accounts. -func DeleteUserWithRelations(ctx context.Context, id uint64) error { - return db.DB(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("user_id = ?", id).Delete(&model.AccessToken{}).Error; err != nil { - return err - } - if err := tx.Where("user_id = ?", id).Delete(&model.ExternalAccount{}).Error; err != nil { - return err - } - return tx.Where("id = ?", id).Delete(&model.User{}).Error - }) -} - -// CountUsersByUsername returns how many users share the username. -func CountUsersByUsername(ctx context.Context, username string) (int64, error) { - var count int64 - if err := db.DB(ctx).Model(&model.User{}).Where("username = ?", username).Count(&count).Error; err != nil { - return 0, err - } - return count, nil -} - -// CountUsersByEmail returns how many users share the email. -func CountUsersByEmail(ctx context.Context, email string) (int64, error) { - var count int64 - if err := db.DB(ctx).Model(&model.User{}).Where("email = ?", email).Count(&count).Error; err != nil { - return 0, err - } - return count, nil -} - -// CreateUser persists a new user record. -func CreateUser(ctx context.Context, user *model.User) error { - return db.DB(ctx).Create(user).Error -} - -// ListUsersByIDs loads users matching the given IDs. -func ListUsersByIDs(ctx context.Context, ids []uint64) ([]model.User, error) { - if len(ids) == 0 { - return []model.User{}, nil - } - var users []model.User - if err := db.DB(ctx).Where("id IN ?", ids).Find(&users).Error; err != nil { - return nil, err - } - return users, nil -} - -// ListUserIDsByUsernameContains returns user IDs whose username contains the given fragment. -func ListUserIDsByUsernameContains(ctx context.Context, username string) ([]uint64, error) { - if username == "" { - return []uint64{}, nil - } - var userIDs []uint64 - if err := db.DB(ctx).Model(&model.User{}). - Where("username LIKE ? ESCAPE '\\'", "%"+util.EscapeLike(username)+"%"). - Pluck("id", &userIDs).Error; err != nil { - return nil, err - } - return userIDs, nil -} - -// UpdateUser updates all fields of an existing user. -func UpdateUser(ctx context.Context, user *model.User) error { - return db.DB(ctx).Save(user).Error -} - -// CreateUserFromOAuth creates a user from OAuth profile data and fills userOut. -func CreateUserFromOAuth(ctx context.Context, userOut *model.User, oauthInfo *model.OAuthUserInfo) error { - now := time.Now() - userID := oauthInfo.GetID() - newUser := model.User{ - ID: userID, - Username: oauthInfo.Username, - Nickname: oauthInfo.Name, - Email: oauthInfo.Email, - AvatarURL: oauthInfo.AvatarURL, - IsActive: oauthInfo.Active, - LastLoginAt: now, - IsAdmin: false, - } - if newUser.ID == 0 { - newUser.ID = idgen.NextUint64ID() - } - if err := db.DB(ctx).Create(&newUser).Error; err != nil { - return err - } - *userOut = newUser - return nil -} - -// ListUsernamesMatchingBase returns usernames equal to base or prefixed with base+"-". -func ListUsernamesMatchingBase(ctx context.Context, base string) ([]string, error) { - var names []string - if err := db.DB(ctx).Model(&model.User{}). - Where("username = ? OR username LIKE ? ESCAPE '\\'", base, util.EscapeLike(base)+"-%"). - Pluck("username", &names).Error; err != nil { - return nil, err - } - return names, nil -} - -// GetActiveUserByID loads a user by ID who is active. -func GetActiveUserByID(ctx context.Context, id uint64) (model.User, error) { - var user model.User - if err := db.DB(ctx).Where("id = ? AND is_active = ?", id, true).First(&user).Error; err != nil { - return model.User{}, err - } - return user, nil -} - -// GetUserByUsernameOrEmail loads a user by username or email. -func GetUserByUsernameOrEmail(ctx context.Context, input string) (model.User, error) { - var user model.User - if err := db.DB(ctx).Where("username = ? OR email = ?", input, input).First(&user).Error; err != nil { - return model.User{}, err - } - return user, nil -} - -// CountUsersByEmailExceptID counts users with the email excluding a given user id. -func CountUsersByEmailExceptID(ctx context.Context, email string, exceptID uint64) (int64, error) { - var count int64 - if err := db.DB(ctx).Model(&model.User{}).Where("email = ? AND id != ?", email, exceptID).Count(&count).Error; err != nil { - return 0, err - } - return count, nil -} - -// UpdateUserLastLoginAt updates only last_login_at for a user. -func UpdateUserLastLoginAt(ctx context.Context, userID uint64, at time.Time) error { - return db.DB(ctx).Model(&model.User{}).Where("id = ?", userID).Update("last_login_at", at).Error -} - -// UpdateUserPassword updates only the password hash for a user. -func UpdateUserPassword(ctx context.Context, userID uint64, passwordHash string) error { - return db.DB(ctx).Model(&model.User{}).Where("id = ?", userID).Update("password", passwordHash).Error -} - -// RegisterUserWithChecks validates username/email uniqueness then creates the user. -func RegisterUserWithChecks(ctx context.Context, user *model.User) error { - var count int64 - if err := db.DB(ctx).Model(&model.User{}).Where("username = ?", user.Username).Count(&count).Error; err != nil { - return err - } - if count > 0 { - return errors.New("用户名已存在") - } - if user.Email != "" { - var emailCount int64 - if err := db.DB(ctx).Model(&model.User{}).Where("email = ?", user.Email).Count(&emailCount).Error; err != nil { - return err - } - if emailCount > 0 { - return errors.New("该邮箱已被其他账号绑定") - } - } - if user.ID == 0 { - user.ID = idgen.NextUint64ID() - } - return db.DB(ctx).Create(user).Error -} diff --git a/internal/router/middlewares_test.go b/internal/router/middlewares_test.go deleted file mode 100644 index c801fd5e..00000000 --- a/internal/router/middlewares_test.go +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -package router - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" - "github.com/gin-gonic/gin" -) - -func TestCORSMiddleware(t *testing.T) { - dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) - defer cleanup() - - gin.SetMode(gin.TestMode) - - clearConfigCache := func() { - if err := repository.InvalidateAllSystemConfigCaches(context.Background()); err != nil { - t.Fatalf("InvalidateAllSystemConfigCaches() error = %v", err) - } - } - - t.Run("missing server_address configuration returns no CORS headers", func(t *testing.T) { - clearConfigCache() - // Ensure it's empty in DB - if err := dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyServerAddress).Update("value", "").Error; err != nil { - t.Fatalf("failed to update config: %v", err) - } - clearConfigCache() - - r := gin.New() - r.Use(corsMiddleware()) - r.GET("/test", func(c *gin.Context) { - c.String(http.StatusOK, "ok") - }) - - req, _ := http.NewRequest(http.MethodGet, "/test", nil) - req.Header.Set("Origin", "http://attacker.com") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d", w.Code) - } - if val := w.Header().Get("Access-Control-Allow-Origin"); val != "" { - t.Errorf("expected empty Access-Control-Allow-Origin header, got %q", val) - } - if val := w.Header().Get("Access-Control-Allow-Credentials"); val != "" { - t.Errorf("expected empty Access-Control-Allow-Credentials header, got %q", val) - } - }) - - t.Run("matching server_address allows origin and sets credential headers", func(t *testing.T) { - clearConfigCache() - if err := dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyServerAddress).Update("value", "https://trusted.com, http://localhost:3000/").Error; err != nil { - t.Fatalf("failed to update config: %v", err) - } - clearConfigCache() - - r := gin.New() - r.Use(corsMiddleware()) - r.GET("/test", func(c *gin.Context) { - c.String(http.StatusOK, "ok") - }) - - // Test trusted origin 1 - req1, _ := http.NewRequest(http.MethodGet, "/test", nil) - req1.Header.Set("Origin", "https://trusted.com") - w1 := httptest.NewRecorder() - r.ServeHTTP(w1, req1) - - if w1.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d", w1.Code) - } - if val := w1.Header().Get("Access-Control-Allow-Origin"); val != "https://trusted.com" { - t.Errorf("expected Access-Control-Allow-Origin 'https://trusted.com', got %q", val) - } - if val := w1.Header().Get("Access-Control-Allow-Credentials"); val != "true" { - t.Errorf("expected Access-Control-Allow-Credentials 'true', got %q", val) - } - - // Test trusted origin 2 (trimmed trailing slash) - req2, _ := http.NewRequest(http.MethodGet, "/test", nil) - req2.Header.Set("Origin", "http://localhost:3000") - w2 := httptest.NewRecorder() - r.ServeHTTP(w2, req2) - - if w2.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d", w2.Code) - } - if val := w2.Header().Get("Access-Control-Allow-Origin"); val != "http://localhost:3000" { - t.Errorf("expected Access-Control-Allow-Origin 'http://localhost:3000', got %q", val) - } - }) - - t.Run("non-matching origin is denied CORS headers", func(t *testing.T) { - clearConfigCache() - if err := dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyServerAddress).Update("value", "https://trusted.com").Error; err != nil { - t.Fatalf("failed to update config: %v", err) - } - clearConfigCache() - - r := gin.New() - r.Use(corsMiddleware()) - r.GET("/test", func(c *gin.Context) { - c.String(http.StatusOK, "ok") - }) - - req, _ := http.NewRequest(http.MethodGet, "/test", nil) - req.Header.Set("Origin", "https://attacker.com") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 OK, got %d", w.Code) - } - if val := w.Header().Get("Access-Control-Allow-Origin"); val != "" { - t.Errorf("expected empty Access-Control-Allow-Origin header, got %q", val) - } - }) -} diff --git a/internal/testhelper/test_helper.go b/internal/testhelper/test_helper.go deleted file mode 100644 index 315936f3..00000000 --- a/internal/testhelper/test_helper.go +++ /dev/null @@ -1,363 +0,0 @@ -// Copyright 2025 linux.do -// Copyright 2026 Arctel.net -// SPDX-License-Identifier: Apache-2.0 - -// Package testhelper 提供测试辅助工具 -package testhelper - -import ( - "context" - "testing" - - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/alicebob/miniredis/v2" - "github.com/glebarez/sqlite" - "github.com/redis/go-redis/v9" - "github.com/redis/go-redis/v9/maintnotifications" - "gorm.io/gorm" -) - -const ( - configTypeSystem = "system" - configTypeBusiness = "business" - configValueTrue = "true" - configValueFalse = "false" -) - -// SetupTestEnvironment initializes an in-memory SQLite DB, seeds default configurations, -// starts miniredis, and overrides the global db/Redis clients. It returns a cleanup function. -func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func()) { - // Initialize GORM in-memory SQLite - sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ - DisableForeignKeyConstraintWhenMigrating: true, - }) - if err != nil { - t.Fatalf("failed to open in-memory SQLite db: %v", err) - } - - // Limit to 1 open connection for SQLite :memory: to keep the database in one shared connection - if sqlDB, err := sqliteDB.DB(); err == nil { - sqlDB.SetMaxOpenConns(1) - } - - // AutoMigrate all tables - err = sqliteDB.AutoMigrate( - &model.User{}, - &model.AuthSource{}, - &model.ExternalAccount{}, - &model.SystemConfig{}, - &model.Upload{}, - &model.UploadStat{}, - &model.TaskExecution{}, - &model.Template{}, - &model.AccessToken{}, - &model.Schedule{}, - &model.MessageChannel{}, - &model.MessageBinding{}, - &model.MessagePairingCode{}, - ) - if err != nil { - t.Fatalf("failed to auto migrate tables: %v", err) - } - - // Set global db - db.SetDB(sqliteDB) - - // Start miniredis - mr, err := miniredis.Run() - if err != nil { - t.Fatalf("failed to start miniredis: %v", err) - } - - // Hook up Redis Client to miniredis - redisClient := redis.NewClient(&redis.Options{ - Addr: mr.Addr(), - MaintNotificationsConfig: &maintnotifications.Config{ - Mode: maintnotifications.ModeDisabled, - }, - }) - db.Redis = redisClient - - // Seed default configurations - seedDefaultConfigs(t, sqliteDB) - - // Cleanup function - cleanup := func() { - runExtraCleanups() - repository.StopSystemConfigCacheListener() - repository.StopAuthSourceCacheListener() - repository.ResetSystemConfigRAMCacheForTest() - _ = redisClient.Close() - mr.Close() - // Reset database and Redis references - db.SetDB(nil) - db.Redis = nil - } - - return sqliteDB, mr, cleanup -} - -func getSeedConfigsPart1() []model.SystemConfig { - return []model.SystemConfig{ - { - Key: model.ConfigKeyUploadAllowedExtensions, - Value: "jpg,png,webp", - Type: configTypeSystem, - Description: "允许上传的图片扩展名(逗号分隔)", - }, - { - Key: model.ConfigKeySiteName, - Value: "Wavelet", - Type: configTypeSystem, - Description: "系统平台的展示名称", - }, - { - Key: model.ConfigKeyPasswordLoginEnabled, - Value: configValueTrue, - Type: configTypeSystem, - Description: "是否允许使用账号密码登录", - }, - { - Key: model.ConfigKeyRegistrationEnabled, - Value: configValueTrue, - Type: configTypeSystem, - Description: "控制普通用户是否可以自主注册(true/false)", - }, - { - Key: model.ConfigKeyPasswordRegisterEnabled, - Value: configValueTrue, - Type: configTypeSystem, - Description: "是否允许通过密码创建本地账号", - }, - { - Key: model.ConfigKeyOIDCLoginEnabled, - Value: configValueTrue, - Type: configTypeSystem, - Description: "是否允许使用第三方 OIDC 认证源登录", - }, - { - Key: model.ConfigKeyMaxAPIKeysPerUser, - Value: "5", - Type: configTypeBusiness, - Description: "限制每个普通用户可以创建的 API Key 最大数量", - }, - { - Key: model.ConfigKeyCapLoginEnabled, - Value: configValueFalse, - Type: configTypeSystem, - Description: "是否启用登录人机验证(true/false)", - }, - { - Key: model.ConfigKeyCapAutoSolve, - Value: configValueTrue, - Type: configTypeSystem, - Description: "打开页面后是否自动开始计算,关闭则需用户手动点击触发", - }, - { - Key: model.ConfigKeyCapChallengeCount, - Value: "1", - Type: configTypeSystem, - Description: "客户端需求解的 PoW 难题总数,默认 1,推荐 1~5", - }, - { - Key: model.ConfigKeyCapChallengeSize, - Value: "32", - Type: configTypeSystem, - Description: "人机验证盐值长度", - }, - { - Key: model.ConfigKeyCapChallengeDifficulty, - Value: "4", - Type: configTypeSystem, - Description: "人机验证 PoW 难度(目标前缀长度)", - }, - { - Key: model.ConfigKeyCapChallengeTTL, - Value: "600", - Type: configTypeSystem, - Description: "人机验证难题有效时间(秒)", - }, - { - Key: model.ConfigKeyCapTokenTTL, - Value: "1200", - Type: configTypeSystem, - Description: "人机验证兑换凭证有效时间(秒)", - }, - } -} - -func getSeedConfigsPart2() []model.SystemConfig { - return []model.SystemConfig{ - { - Key: model.ConfigKeyServerAddress, - Value: "", - Type: configTypeSystem, - Description: "服务器地址(用于跨域源控制,不设定则允许任意源)", - }, - { - Key: model.ConfigKeySMTPHost, - Value: "", - Type: configTypeSystem, - Description: "SMTP 服务器地址(例如 smtp.example.com)", - }, - { - Key: model.ConfigKeySMTPPort, - Value: "587", - Type: configTypeSystem, - Description: "SMTP 端口(例如 587 或 465)", - }, - { - Key: model.ConfigKeySMTPUsername, - Value: "", - Type: configTypeSystem, - Description: "SMTP 账户(如 sender@example.com)", - }, - { - Key: model.ConfigKeySMTPPassword, - Value: "", - Type: configTypeSystem, - Description: "SMTP 访问凭证(授权码/密码)", - }, - { - Key: model.ConfigKeyEmailLoginVerificationEnabled, - Value: configValueFalse, - Type: configTypeSystem, - Description: "是否开启邮箱登录验证(true/false)", - }, - { - Key: model.ConfigKeyEmailRegisterVerificationEnabled, - Value: configValueFalse, - Type: configTypeSystem, - Description: "是否开启邮箱注册验证(true/false)", - }, - { - Key: model.ConfigKeyMenuDisplayConfig, - Value: "{}", - Type: configTypeSystem, - Description: "目录显示配置(JSON 字符串,格式为 {url: enabled})", - }, - { - Key: model.ConfigKeySearchEngineIndexingEnabled, - Value: configValueFalse, - Type: configTypeSystem, - Description: "是否允许搜索引擎检索", - }, - { - Key: model.ConfigKeyFileAccessWhitelist, - Value: `["avatar"]`, - Type: configTypeSystem, - Description: "免登录访问的文件业务类型白名单", - }, - { - Key: model.ConfigKeyDiskCacheMaxSizeMB, - Value: "100", - Type: configTypeSystem, - Description: "磁盘缓存最大空间大小 (MB)", - }, - { - Key: model.ConfigKeyDiskCacheTTLMinutes, - Value: "60", - Type: configTypeSystem, - Description: "磁盘缓存默认有效期 (分钟)", - }, - { - Key: model.ConfigKeyDiskCacheLRUEnabled, - Value: configValueTrue, - Type: configTypeSystem, - Description: "是否启用 LRU 淘汰机制", - }, - { - Key: model.ConfigKeyLoginSessionTTLHours, - Value: "0", - Type: configTypeSystem, - Description: "登录会话过期时间 (小时,0表示浏览器关闭后自动退出,-1表示永不过期)", - }, - { - Key: model.ConfigKeyUpdateUpstreamRepository, - Value: "Rain-kl/Wavelet", - Type: configTypeSystem, - Description: "GitHub Actions Release 上游仓库(owner/repo 或 GitHub 仓库地址)", - }, - { - Key: model.ConfigKeyStorageConfig, - Value: `{"driver":"local","local":{"root":"."},"s3":{"region":"us-east-1"},"r2":{"region":"auto"},"minio":{"region":"us-east-1","path_style":true},"oss":{},"webdav":{}}`, - Type: configTypeSystem, - Description: "文件存储驱动及连接配置(JSON)", - }, - { - Key: model.ConfigKeyLogDatabase, - Value: "sqlite", - Type: configTypeSystem, - Description: "当前日志主库", - }, - { - Key: model.ConfigKeyLogDBMigration, - Value: "", - Type: configTypeSystem, - Description: "日志库迁移冻结标记", - }, - { - Key: model.ConfigKeyLogRetentionDaysPostgres, - Value: "30", - Type: configTypeBusiness, - Description: "PostgreSQL 用户访问日志保留天数", - }, - { - Key: model.ConfigKeyLogRetentionDaysSQLite, - Value: "30", - Type: configTypeBusiness, - Description: "SQLite 用户访问日志保留天数", - }, - { - Key: model.ConfigKeyLogRetentionDaysClickHouse, - Value: "30", - Type: configTypeBusiness, - Description: "ClickHouse 用户访问日志保留天数", - }, - } -} - -func seedDefaultConfigs(t *testing.T, tx *gorm.DB) { - defaultConfigs := append(getSeedConfigsPart1(), getSeedConfigsPart2()...) - - if err := tx.Create(&defaultConfigs).Error; err != nil { - t.Fatalf("failed to seed default system configs: %v", err) - } - - publicKeys := map[string]struct{}{ - model.ConfigKeyUploadAllowedExtensions: {}, - model.ConfigKeySiteName: {}, - model.ConfigKeyPasswordLoginEnabled: {}, - model.ConfigKeyRegistrationEnabled: {}, - model.ConfigKeyPasswordRegisterEnabled: {}, - model.ConfigKeyOIDCLoginEnabled: {}, - model.ConfigKeyMaxAPIKeysPerUser: {}, - model.ConfigKeyCapLoginEnabled: {}, - model.ConfigKeyCapAutoSolve: {}, - model.ConfigKeyEmailLoginVerificationEnabled: {}, - model.ConfigKeyEmailRegisterVerificationEnabled: {}, - model.ConfigKeyMenuDisplayConfig: {}, - model.ConfigKeySearchEngineIndexingEnabled: {}, - model.ConfigKeyFileAccessWhitelist: {}, - } - keys := make([]string, 0, len(publicKeys)) - for key := range publicKeys { - keys = append(keys, key) - } - if err := tx.Model(&model.SystemConfig{}). - Where("key IN ?", keys). - Update("visibility", model.ConfigVisibilityVisible).Error; err != nil { - t.Fatalf("failed to seed public system config visibility: %v", err) - } - - // Also seed these in miniredis context if required, but they are stored in postgres first. - // We'll write configs to miniredis in actual handlers. - for _, config := range defaultConfigs { - if _, ok := publicKeys[config.Key]; ok { - config.Visibility = model.ConfigVisibilityVisible - } - _ = db.HSetJSON(context.Background(), repository.SystemConfigRedisHashKey, config.Key, &config) - } -} diff --git a/main.go b/main.go index 47fc90c6..d7114f30 100644 --- a/main.go +++ b/main.go @@ -4,7 +4,7 @@ // Package main 是 Wavelet 平台的程序入口 package main -import "github.com/Rain-kl/Wavelet/internal/cmd" +import "github.com/Rain-kl/Wavelet/cmd" // @title Wavelet API // @version 1.0.0 diff --git a/internal/buildinfo/buildinfo.go b/pkg/buildinfo/buildinfo.go similarity index 100% rename from internal/buildinfo/buildinfo.go rename to pkg/buildinfo/buildinfo.go diff --git a/internal/infra/config/config.go b/pkg/config/config.go similarity index 100% rename from internal/infra/config/config.go rename to pkg/config/config.go diff --git a/internal/infra/config/config_test.go b/pkg/config/config_test.go similarity index 81% rename from internal/infra/config/config_test.go rename to pkg/config/config_test.go index 8620f9af..b79e48ee 100644 --- a/internal/infra/config/config_test.go +++ b/pkg/config/config_test.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package config import "testing" diff --git a/internal/infra/config/model.go b/pkg/config/model.go similarity index 100% rename from internal/infra/config/model.go rename to pkg/config/model.go diff --git a/internal/repository/analytics/access_log.go b/pkg/persistence/analytics/access_log.go similarity index 77% rename from internal/repository/analytics/access_log.go rename to pkg/persistence/analytics/access_log.go index 48c5e849..66c2b85a 100644 --- a/internal/repository/analytics/access_log.go +++ b/pkg/persistence/analytics/access_log.go @@ -10,8 +10,7 @@ import ( "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + "github.com/Rain-kl/Wavelet/pkg/persistence" "github.com/Rain-kl/Wavelet/pkg/util" "gorm.io/gorm" ) @@ -24,7 +23,7 @@ func CountAccessLogs(ctx context.Context, filter AccessLogFilter) (uint64, error } var count int64 - query := applyFilter(ch.Model(&analyticsmodel.UserAccessLog{}), filter) + query := applyFilter(ch.Model(&UserAccessLog{}), filter) if err := query.Count(&count).Error; err != nil { return 0, fmt.Errorf("count access logs: %w", err) } @@ -32,23 +31,23 @@ func CountAccessLogs(ctx context.Context, filter AccessLogFilter) (uint64, error } // ListAccessLogs returns paginated access logs and the total match count. -func ListAccessLogs(ctx context.Context, filter AccessLogFilter, page, pageSize int) ([]analyticsmodel.UserAccessLog, uint64, error) { +func ListAccessLogs(ctx context.Context, filter AccessLogFilter, page, pageSize int) ([]UserAccessLog, uint64, error) { ch := db.ChDB(ctx) if ch == nil { return nil, 0, fmt.Errorf("clickhouse gorm connection is not initialized") } if filter.UserIDs != nil && len(filter.UserIDs) == 0 { - return []analyticsmodel.UserAccessLog{}, 0, nil + return []UserAccessLog{}, 0, nil } var total int64 - baseQuery := applyFilter(ch.Model(&analyticsmodel.UserAccessLog{}), filter) + baseQuery := applyFilter(ch.Model(&UserAccessLog{}), filter) if err := baseQuery.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("count access logs: %w", err) } if total == 0 { - return []analyticsmodel.UserAccessLog{}, 0, nil + return []UserAccessLog{}, 0, nil } if page < 1 { @@ -59,8 +58,8 @@ func ListAccessLogs(ctx context.Context, filter AccessLogFilter, page, pageSize } offset := (page - 1) * pageSize - var logs []analyticsmodel.UserAccessLog - err := applyFilter(ch.Model(&analyticsmodel.UserAccessLog{}), filter). + var logs []UserAccessLog + err := applyFilter(ch.Model(&UserAccessLog{}), filter). Order("created_at DESC, id DESC"). Limit(pageSize). Offset(offset). @@ -77,7 +76,7 @@ func DeleteAllUserAccessLogs(ctx context.Context) (int64, error) { if db.ChConn == nil { return 0, fmt.Errorf("clickhouse connection is not initialized") } - if err := db.ChConn.Exec(ctx, "TRUNCATE TABLE "+analyticsmodel.UserAccessLog{}.TableName()); err != nil { + if err := db.ChConn.Exec(ctx, "TRUNCATE TABLE "+UserAccessLog{}.TableName()); err != nil { return 0, fmt.Errorf("truncate user access logs: %w", err) } return 0, nil @@ -88,7 +87,7 @@ func DeleteUserAccessLogsBefore(ctx context.Context, cutoff time.Time) (int64, e if db.ChConn == nil { return 0, fmt.Errorf("clickhouse connection is not initialized") } - if err := db.ChConn.Exec(ctx, "ALTER TABLE "+analyticsmodel.UserAccessLog{}.TableName()+" DELETE WHERE created_at < ?", cutoff); err != nil { + if err := db.ChConn.Exec(ctx, "ALTER TABLE "+UserAccessLog{}.TableName()+" DELETE WHERE created_at < ?", cutoff); err != nil { return 0, fmt.Errorf("delete expired user access logs: %w", err) } return 0, nil diff --git a/internal/repository/analytics/access_log_filter.go b/pkg/persistence/analytics/access_log_filter.go similarity index 100% rename from internal/repository/analytics/access_log_filter.go rename to pkg/persistence/analytics/access_log_filter.go diff --git a/internal/repository/analytics/access_log_stats.go b/pkg/persistence/analytics/access_log_stats.go similarity index 92% rename from internal/repository/analytics/access_log_stats.go rename to pkg/persistence/analytics/access_log_stats.go index 6f9321ac..efa0516d 100644 --- a/internal/repository/analytics/access_log_stats.go +++ b/pkg/persistence/analytics/access_log_stats.go @@ -9,8 +9,7 @@ import ( "sort" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + "github.com/Rain-kl/Wavelet/pkg/persistence" ) const hoursInDay = 24 @@ -45,7 +44,7 @@ func GetDailyTrend(ctx context.Context, days int) ([]DailyTrend, error) { } startTime := time.Now().AddDate(0, 0, -(days - 1)).Truncate(hoursInDay * time.Hour) - tableName := analyticsmodel.UserAccessLog{}.TableName() + tableName := UserAccessLog{}.TableName() query := fmt.Sprintf(` SELECT toDate(created_at) AS date, count() AS count @@ -93,7 +92,7 @@ func GetBrowserDistribution(ctx context.Context, startTime time.Time) ([]Browser return nil, fmt.Errorf("clickhouse gorm connection is not initialized") } - tableName := analyticsmodel.UserAccessLog{}.TableName() + tableName := UserAccessLog{}.TableName() query := fmt.Sprintf(` SELECT user_agent, count() AS count FROM %s @@ -141,7 +140,7 @@ func GetTopActiveUsers(ctx context.Context, startTime time.Time, limit int) ([]T return nil, fmt.Errorf("clickhouse gorm connection is not initialized") } - tableName := analyticsmodel.UserAccessLog{}.TableName() + tableName := UserAccessLog{}.TableName() query := fmt.Sprintf(` SELECT user_id, count() AS count FROM %s diff --git a/internal/repository/analytics/access_log_test.go b/pkg/persistence/analytics/access_log_test.go similarity index 92% rename from internal/repository/analytics/access_log_test.go rename to pkg/persistence/analytics/access_log_test.go index 7a5cc881..7e2763ba 100644 --- a/internal/repository/analytics/access_log_test.go +++ b/pkg/persistence/analytics/access_log_test.go @@ -11,8 +11,8 @@ import ( "github.com/ClickHouse/clickhouse-go/v2/lib/column" "github.com/ClickHouse/clickhouse-go/v2/lib/driver" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + + "github.com/Rain-kl/Wavelet/pkg/persistence" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -26,7 +26,7 @@ func setupChGormDB(t *testing.T) *gorm.DB { DisableForeignKeyConstraintWhenMigrating: true, }) require.NoError(t, err) - require.NoError(t, gormDB.AutoMigrate(&analyticsmodel.UserAccessLog{})) + require.NoError(t, gormDB.AutoMigrate(&UserAccessLog{})) db.SetChDBForTest(gormDB) return gormDB } @@ -77,7 +77,7 @@ func TestListAccessLogs_WithFilters(t *testing.T) { t.Cleanup(func() { db.SetChDBForTest(nil) }) now := time.Now().UTC().Truncate(time.Second) - logs := []analyticsmodel.UserAccessLog{ + logs := []UserAccessLog{ {ID: 1, UserID: 10, Path: "/api/v1/users", Method: "GET", Status: 200, CreatedAt: now}, {ID: 2, UserID: 20, Path: "/api/v1/admin/logs", Method: "GET", Status: 200, CreatedAt: now}, {ID: 3, UserID: 10, Path: "/api/v1/other", Method: "POST", Status: 201, CreatedAt: now}, @@ -113,13 +113,13 @@ func TestBatchInsert_UsesModelBatchSQL(t *testing.T) { mockBatch := &mockBatch{} mockConn := &mockConn{ batch: mockBatch, - batchQuery: analyticsmodel.UserAccessLog{}.BatchInsertSQL(), + batchQuery: UserAccessLog{}.BatchInsertSQL(), } db.SetChConnForTest(mockConn) t.Cleanup(func() { db.SetChConnForTest(nil) }) createdAt := time.Now().UTC() - err := BatchInsert(ctx, []analyticsmodel.UserAccessLog{ + err := BatchInsert(ctx, []UserAccessLog{ { ID: 1, UserID: 42, @@ -135,7 +135,7 @@ func TestBatchInsert_UsesModelBatchSQL(t *testing.T) { }) require.NoError(t, err) assert.True(t, mockConn.prepareCalled) - assert.Equal(t, analyticsmodel.UserAccessLog{}.BatchInsertSQL(), mockConn.preparedQuery) + assert.Equal(t, UserAccessLog{}.BatchInsertSQL(), mockConn.preparedQuery) assert.True(t, mockBatch.sendCalled) require.Len(t, mockBatch.rows, 1) assert.Equal(t, uint64(42), mockBatch.rows[0][1]) diff --git a/internal/repository/analytics/access_log_writer.go b/pkg/persistence/analytics/access_log_writer.go similarity index 73% rename from internal/repository/analytics/access_log_writer.go rename to pkg/persistence/analytics/access_log_writer.go index 67bdd293..8ff937f2 100644 --- a/internal/repository/analytics/access_log_writer.go +++ b/pkg/persistence/analytics/access_log_writer.go @@ -7,12 +7,11 @@ import ( "context" "fmt" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + "github.com/Rain-kl/Wavelet/pkg/persistence" ) // BatchInsert writes access logs to ClickHouse using the native batch API. -func BatchInsert(ctx context.Context, logs []analyticsmodel.UserAccessLog) error { +func BatchInsert(ctx context.Context, logs []UserAccessLog) error { if len(logs) == 0 { return nil } @@ -20,7 +19,7 @@ func BatchInsert(ctx context.Context, logs []analyticsmodel.UserAccessLog) error return fmt.Errorf("clickhouse connection is not initialized") } - batch, err := db.ChConn.PrepareBatch(ctx, analyticsmodel.UserAccessLog{}.BatchInsertSQL()) + batch, err := db.ChConn.PrepareBatch(ctx, UserAccessLog{}.BatchInsertSQL()) if err != nil { return fmt.Errorf("prepare clickhouse batch: %w", err) } diff --git a/internal/repository/analytics/browser.go b/pkg/persistence/analytics/browser.go similarity index 100% rename from internal/repository/analytics/browser.go rename to pkg/persistence/analytics/browser.go diff --git a/internal/model/analytics/user_access_log.go b/pkg/persistence/analytics/models.go similarity index 100% rename from internal/model/analytics/user_access_log.go rename to pkg/persistence/analytics/models.go diff --git a/internal/infra/persistence/batchwriter/config.go b/pkg/persistence/batchwriter/config.go similarity index 100% rename from internal/infra/persistence/batchwriter/config.go rename to pkg/persistence/batchwriter/config.go diff --git a/internal/infra/persistence/batchwriter/errs.go b/pkg/persistence/batchwriter/errs.go similarity index 100% rename from internal/infra/persistence/batchwriter/errs.go rename to pkg/persistence/batchwriter/errs.go diff --git a/internal/infra/persistence/batchwriter/writer.go b/pkg/persistence/batchwriter/writer.go similarity index 100% rename from internal/infra/persistence/batchwriter/writer.go rename to pkg/persistence/batchwriter/writer.go diff --git a/internal/infra/persistence/batchwriter/writer_test.go b/pkg/persistence/batchwriter/writer_test.go similarity index 100% rename from internal/infra/persistence/batchwriter/writer_test.go rename to pkg/persistence/batchwriter/writer_test.go diff --git a/internal/infra/persistence/clickhouse.go b/pkg/persistence/clickhouse.go similarity index 98% rename from internal/infra/persistence/clickhouse.go rename to pkg/persistence/clickhouse.go index 63fa3ecb..c59b1c85 100644 --- a/internal/infra/persistence/clickhouse.go +++ b/pkg/persistence/clickhouse.go @@ -16,7 +16,7 @@ import ( "github.com/ClickHouse/clickhouse-go/v2" "github.com/ClickHouse/clickhouse-go/v2/lib/driver" - "github.com/Rain-kl/Wavelet/internal/infra/config" + "github.com/Rain-kl/Wavelet/pkg/config" "go.opentelemetry.io/otel/attribute" clickhouseDriver "gorm.io/driver/clickhouse" "gorm.io/gorm" diff --git a/internal/infra/persistence/errs.go b/pkg/persistence/errs.go similarity index 100% rename from internal/infra/persistence/errs.go rename to pkg/persistence/errs.go diff --git a/internal/infra/persistence/idgen/snowflake.go b/pkg/persistence/idgen/snowflake.go similarity index 95% rename from internal/infra/persistence/idgen/snowflake.go rename to pkg/persistence/idgen/snowflake.go index e828a578..aec36f8c 100644 --- a/internal/infra/persistence/idgen/snowflake.go +++ b/pkg/persistence/idgen/snowflake.go @@ -9,7 +9,7 @@ import ( "fmt" "log" - "github.com/Rain-kl/Wavelet/internal/infra/config" + "github.com/Rain-kl/Wavelet/pkg/config" "github.com/bwmarrin/snowflake" ) diff --git a/internal/infra/persistence/idgen/snowflake_test.go b/pkg/persistence/idgen/snowflake_test.go similarity index 100% rename from internal/infra/persistence/idgen/snowflake_test.go rename to pkg/persistence/idgen/snowflake_test.go diff --git a/internal/repository/logstore/cleanup.go b/pkg/persistence/logstore/cleanup.go similarity index 94% rename from internal/repository/logstore/cleanup.go rename to pkg/persistence/logstore/cleanup.go index 8f347dec..3eeb6df8 100644 --- a/internal/repository/logstore/cleanup.go +++ b/pkg/persistence/logstore/cleanup.go @@ -10,7 +10,6 @@ import ( "strconv" "time" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/pkg/logger" ) @@ -61,12 +60,12 @@ func CleanupExpired(ctx context.Context) (CleanupSummary, error) { } func retentionDaysForDatabase(ctx context.Context, dbName string) int { - key := model.ConfigKeyLogRetentionDaysPostgres + key := "log_retention_days_postgres" switch dbName { case dbNameSQLite: - key = model.ConfigKeyLogRetentionDaysSQLite + key = "log_retention_days_sqlite" case dbNameClickHouse: - key = model.ConfigKeyLogRetentionDaysClickHouse + key = "log_retention_days_clickhouse" } v, err := getConfig(ctx, key) if err != nil { diff --git a/internal/repository/logstore/clickhouse.go b/pkg/persistence/logstore/clickhouse.go similarity index 69% rename from internal/repository/logstore/clickhouse.go rename to pkg/persistence/logstore/clickhouse.go index 8d0e0020..3a198e63 100644 --- a/internal/repository/logstore/clickhouse.go +++ b/pkg/persistence/logstore/clickhouse.go @@ -9,9 +9,8 @@ import ( "time" "github.com/ClickHouse/clickhouse-go/v2/lib/driver" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - analyticsrepo "github.com/Rain-kl/Wavelet/internal/repository/analytics" + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/persistence/analytics" ) type clickhouseUserAccessLogStore struct { @@ -38,48 +37,48 @@ func (s *clickhouseUserAccessLogStore) ensureWritable(ctx context.Context) error return nil } -func (s *clickhouseUserAccessLogStore) BatchInsert(ctx context.Context, logs []analyticsmodel.UserAccessLog) error { +func (s *clickhouseUserAccessLogStore) BatchInsert(ctx context.Context, logs []analytics.UserAccessLog) error { if len(logs) == 0 { return nil } if err := s.ensureWritable(ctx); err != nil { return err } - return analyticsrepo.BatchInsert(ctx, logs) + return analytics.BatchInsert(ctx, logs) } func (s *clickhouseUserAccessLogStore) DeleteAll(ctx context.Context) (int64, error) { if err := s.ensureWritable(ctx); err != nil { return 0, err } - return analyticsrepo.DeleteAllUserAccessLogs(ctx) + return analytics.DeleteAllUserAccessLogs(ctx) } func (s *clickhouseUserAccessLogStore) DeleteBefore(ctx context.Context, cutoff time.Time) (int64, error) { if err := s.ensureWritable(ctx); err != nil { return 0, err } - return analyticsrepo.DeleteUserAccessLogsBefore(ctx, cutoff) + return analytics.DeleteUserAccessLogsBefore(ctx, cutoff) } -func (s *clickhouseUserAccessLogStore) Count(ctx context.Context, filter analyticsrepo.AccessLogFilter) (uint64, error) { - return analyticsrepo.CountAccessLogs(ctx, filter) +func (s *clickhouseUserAccessLogStore) Count(ctx context.Context, filter analytics.AccessLogFilter) (uint64, error) { + return analytics.CountAccessLogs(ctx, filter) } -func (s *clickhouseUserAccessLogStore) List(ctx context.Context, filter analyticsrepo.AccessLogFilter, page, pageSize int) ([]analyticsmodel.UserAccessLog, uint64, error) { - return analyticsrepo.ListAccessLogs(ctx, filter, page, pageSize) +func (s *clickhouseUserAccessLogStore) List(ctx context.Context, filter analytics.AccessLogFilter, page, pageSize int) ([]analytics.UserAccessLog, uint64, error) { + return analytics.ListAccessLogs(ctx, filter, page, pageSize) } -func (s *clickhouseUserAccessLogStore) GetDailyTrend(ctx context.Context, days int) ([]analyticsrepo.DailyTrend, error) { - return analyticsrepo.GetDailyTrend(ctx, days) +func (s *clickhouseUserAccessLogStore) GetDailyTrend(ctx context.Context, days int) ([]analytics.DailyTrend, error) { + return analytics.GetDailyTrend(ctx, days) } -func (s *clickhouseUserAccessLogStore) GetBrowserDistribution(ctx context.Context, startTime time.Time) ([]analyticsrepo.BrowserShare, error) { - return analyticsrepo.GetBrowserDistribution(ctx, startTime) +func (s *clickhouseUserAccessLogStore) GetBrowserDistribution(ctx context.Context, startTime time.Time) ([]analytics.BrowserShare, error) { + return analytics.GetBrowserDistribution(ctx, startTime) } -func (s *clickhouseUserAccessLogStore) GetTopActiveUsers(ctx context.Context, startTime time.Time, limit int) ([]analyticsrepo.TopUser, error) { - return analyticsrepo.GetTopActiveUsers(ctx, startTime, limit) +func (s *clickhouseUserAccessLogStore) GetTopActiveUsers(ctx context.Context, startTime time.Time, limit int) ([]analytics.TopUser, error) { + return analytics.GetTopActiveUsers(ctx, startTime, limit) } func (s *clickhouseUserAccessLogStore) EnsurePartitions(_ context.Context, _, _ time.Time) error { @@ -98,7 +97,7 @@ func (s *clickhouseUserAccessLogStore) MigrationRange(ctx context.Context) (time if db.ChConn == nil { return time.Time{}, time.Time{}, fmt.Errorf("clickhouse connection is not initialized") } - table := analyticsmodel.UserAccessLog{}.TableName() + table := analytics.UserAccessLog{}.TableName() var minTime, maxTime *time.Time if err := db.ChConn.QueryRow(ctx, "SELECT min(created_at), max(created_at) FROM "+table).Scan(&minTime, &maxTime); err != nil { return time.Time{}, time.Time{}, fmt.Errorf("query migration range %s: %w", table, err) @@ -109,15 +108,15 @@ func (s *clickhouseUserAccessLogStore) MigrationRange(ctx context.Context) (time return minTime.UTC(), maxTime.UTC(), nil } -func (s *clickhouseUserAccessLogStore) ListForMigration(ctx context.Context, afterID uint64, limit int) ([]analyticsmodel.UserAccessLog, error) { +func (s *clickhouseUserAccessLogStore) ListForMigration(ctx context.Context, afterID uint64, limit int) ([]analytics.UserAccessLog, error) { if db.ChConn == nil { return nil, fmt.Errorf("clickhouse connection is not initialized") } if limit <= 0 { limit = migrationPageSize } - table := analyticsmodel.UserAccessLog{}.TableName() - columns := analyticsmodel.UserAccessLog{}.InsertColumns() + table := analytics.UserAccessLog{}.TableName() + columns := analytics.UserAccessLog{}.InsertColumns() rows, err := db.ChConn.Query(ctx, fmt.Sprintf( "SELECT %s FROM %s WHERE id > ? ORDER BY id ASC LIMIT ?", columns, table, @@ -129,10 +128,10 @@ func (s *clickhouseUserAccessLogStore) ListForMigration(ctx context.Context, aft return scanUserAccessLogs(rows) } -func scanUserAccessLogs(rows driver.Rows) ([]analyticsmodel.UserAccessLog, error) { - var result []analyticsmodel.UserAccessLog +func scanUserAccessLogs(rows driver.Rows) ([]analytics.UserAccessLog, error) { + var result []analytics.UserAccessLog for rows.Next() { - var item analyticsmodel.UserAccessLog + var item analytics.UserAccessLog if err := rows.Scan( &item.ID, &item.UserID, diff --git a/internal/repository/logstore/gorm.go b/pkg/persistence/logstore/gorm.go similarity index 76% rename from internal/repository/logstore/gorm.go rename to pkg/persistence/logstore/gorm.go index e00dbe18..0cea2dcd 100644 --- a/internal/repository/logstore/gorm.go +++ b/pkg/persistence/logstore/gorm.go @@ -11,9 +11,9 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - analyticsrepo "github.com/Rain-kl/Wavelet/internal/repository/analytics" + "github.com/Rain-kl/Wavelet/pkg/persistence/analytics" + "github.com/Rain-kl/Wavelet/pkg/persistence/idgen" + "gorm.io/gorm" ) @@ -60,7 +60,7 @@ func (s *gormLogStore) ensureWritable(ctx context.Context) error { return nil } -func (s *userAccessLogGormStore) BatchInsert(ctx context.Context, logs []analyticsmodel.UserAccessLog) error { +func (s *userAccessLogGormStore) BatchInsert(ctx context.Context, logs []analytics.UserAccessLog) error { if len(logs) == 0 { return nil } @@ -79,7 +79,7 @@ func (s *userAccessLogGormStore) DeleteAll(ctx context.Context) (int64, error) { if err := s.ensureWritable(ctx); err != nil { return 0, err } - res := s.db.WithContext(ctx).Where("1 = 1").Delete(&analyticsmodel.UserAccessLog{}) + res := s.db.WithContext(ctx).Where("1 = 1").Delete(&analytics.UserAccessLog{}) return res.RowsAffected, res.Error } @@ -87,16 +87,16 @@ func (s *userAccessLogGormStore) DeleteBefore(ctx context.Context, cutoff time.T if err := s.ensureWritable(ctx); err != nil { return 0, err } - res := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(&analyticsmodel.UserAccessLog{}) + res := s.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(&analytics.UserAccessLog{}) if res.Error != nil && isMissingRelation(res.Error) { return 0, nil } return res.RowsAffected, res.Error } -func (s *userAccessLogGormStore) ListForMigration(ctx context.Context, afterID uint64, limit int) ([]analyticsmodel.UserAccessLog, error) { - var rows []analyticsmodel.UserAccessLog - q := s.db.WithContext(ctx).Model(&analyticsmodel.UserAccessLog{}). +func (s *userAccessLogGormStore) ListForMigration(ctx context.Context, afterID uint64, limit int) ([]analytics.UserAccessLog, error) { + var rows []analytics.UserAccessLog + q := s.db.WithContext(ctx).Model(&analytics.UserAccessLog{}). Where("id > ?", afterID). Order("id ASC"). Limit(limitOr(limit, migrationPageSize)) @@ -107,36 +107,36 @@ func (s *userAccessLogGormStore) ListForMigration(ctx context.Context, afterID u } func (s *userAccessLogGormStore) MigrationRange(ctx context.Context) (time.Time, time.Time, error) { - return gormMigrationRange(ctx, s.db, "created_at", analyticsmodel.UserAccessLog{}, func(v *analyticsmodel.UserAccessLog) time.Time { + return gormMigrationRange(ctx, s.db, "created_at", analytics.UserAccessLog{}, func(v *analytics.UserAccessLog) time.Time { return v.CreatedAt }) } -func (s *userAccessLogGormStore) Count(ctx context.Context, filter analyticsrepo.AccessLogFilter) (uint64, error) { +func (s *userAccessLogGormStore) Count(ctx context.Context, filter analytics.AccessLogFilter) (uint64, error) { where, args, ok := buildUserAccessLogWhere(filter) if !ok { return 0, nil } var total int64 - if err := s.db.WithContext(ctx).Model(&analyticsmodel.UserAccessLog{}).Where(where, args...).Count(&total).Error; err != nil { + if err := s.db.WithContext(ctx).Model(&analytics.UserAccessLog{}).Where(where, args...).Count(&total).Error; err != nil { return 0, err } return countToUint64(total), nil } -func (s *userAccessLogGormStore) List(ctx context.Context, filter analyticsrepo.AccessLogFilter, page, pageSize int) ([]analyticsmodel.UserAccessLog, uint64, error) { +func (s *userAccessLogGormStore) List(ctx context.Context, filter analytics.AccessLogFilter, page, pageSize int) ([]analytics.UserAccessLog, uint64, error) { where, args, ok := buildUserAccessLogWhere(filter) if !ok { - return []analyticsmodel.UserAccessLog{}, 0, nil + return []analytics.UserAccessLog{}, 0, nil } var total int64 - if err := s.db.WithContext(ctx).Model(&analyticsmodel.UserAccessLog{}).Where(where, args...).Count(&total).Error; err != nil { + if err := s.db.WithContext(ctx).Model(&analytics.UserAccessLog{}).Where(where, args...).Count(&total).Error; err != nil { return nil, 0, err } if total == 0 { - return []analyticsmodel.UserAccessLog{}, 0, nil + return []analytics.UserAccessLog{}, 0, nil } - var rows []analyticsmodel.UserAccessLog + var rows []analytics.UserAccessLog q := s.db.WithContext(ctx).Where(where, args...).Order("created_at DESC, id DESC") if err := q.Limit(limitOr(pageSize, defaultPageSize)).Offset(offsetOf(page, pageSize)).Find(&rows).Error; err != nil { return nil, 0, err @@ -144,7 +144,7 @@ func (s *userAccessLogGormStore) List(ctx context.Context, filter analyticsrepo. return rows, countToUint64(total), nil } -func buildUserAccessLogWhere(filter analyticsrepo.AccessLogFilter) (string, []any, bool) { +func buildUserAccessLogWhere(filter analytics.AccessLogFilter) (string, []any, bool) { if filter.UserIDs != nil && len(filter.UserIDs) == 0 { return "", nil, false } @@ -172,7 +172,7 @@ func buildUserAccessLogWhere(filter analyticsrepo.AccessLogFilter) (string, []an return strings.Join(parts, " AND "), args, true } -func (s *userAccessLogGormStore) GetDailyTrend(ctx context.Context, days int) ([]analyticsrepo.DailyTrend, error) { +func (s *userAccessLogGormStore) GetDailyTrend(ctx context.Context, days int) ([]analytics.DailyTrend, error) { if days <= 0 { days = 7 } @@ -182,7 +182,7 @@ func (s *userAccessLogGormStore) GetDailyTrend(ctx context.Context, days int) ([ Cnt uint64 } var rows []row - err := s.db.WithContext(ctx).Model(&analyticsmodel.UserAccessLog{}). + err := s.db.WithContext(ctx).Model(&analytics.UserAccessLog{}). Select(dailyTrendDateSQL(s.db)+" AS date, COUNT(*) AS cnt"). Where("created_at >= ?", start). Group("date").Order("date ASC").Scan(&rows).Error @@ -193,21 +193,21 @@ func (s *userAccessLogGormStore) GetDailyTrend(ctx context.Context, days int) ([ for _, r := range rows { counts[r.Date] = r.Cnt } - out := make([]analyticsrepo.DailyTrend, 0, days) + out := make([]analytics.DailyTrend, 0, days) for i := 0; i < days; i++ { d := start.AddDate(0, 0, i).Format("2006-01-02") - out = append(out, analyticsrepo.DailyTrend{Date: d, Count: counts[d]}) + out = append(out, analytics.DailyTrend{Date: d, Count: counts[d]}) } return out, nil } -func (s *userAccessLogGormStore) GetBrowserDistribution(ctx context.Context, startTime time.Time) ([]analyticsrepo.BrowserShare, error) { +func (s *userAccessLogGormStore) GetBrowserDistribution(ctx context.Context, startTime time.Time) ([]analytics.BrowserShare, error) { type row struct { UserAgent string Cnt uint64 } var rows []row - err := s.db.WithContext(ctx).Model(&analyticsmodel.UserAccessLog{}). + err := s.db.WithContext(ctx).Model(&analytics.UserAccessLog{}). Select("user_agent, COUNT(*) AS cnt"). Where("created_at >= ?", startTime). Group("user_agent").Order("cnt DESC").Limit(topUserAgents).Scan(&rows).Error @@ -216,32 +216,32 @@ func (s *userAccessLogGormStore) GetBrowserDistribution(ctx context.Context, sta } counts := make(map[string]uint64) for _, r := range rows { - counts[analyticsrepo.ParseBrowserName(r.UserAgent)] += r.Cnt + counts[analytics.ParseBrowserName(r.UserAgent)] += r.Cnt } - out := make([]analyticsrepo.BrowserShare, 0, len(counts)) + out := make([]analytics.BrowserShare, 0, len(counts)) for label, count := range counts { - out = append(out, analyticsrepo.BrowserShare{Browser: label, Count: count}) + out = append(out, analytics.BrowserShare{Browser: label, Count: count}) } sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count }) return out, nil } -func (s *userAccessLogGormStore) GetTopActiveUsers(ctx context.Context, startTime time.Time, limit int) ([]analyticsrepo.TopUser, error) { +func (s *userAccessLogGormStore) GetTopActiveUsers(ctx context.Context, startTime time.Time, limit int) ([]analytics.TopUser, error) { type row struct { UserID uint64 Cnt uint64 } var rows []row - err := s.db.WithContext(ctx).Model(&analyticsmodel.UserAccessLog{}). + err := s.db.WithContext(ctx).Model(&analytics.UserAccessLog{}). Select("user_id, COUNT(*) AS cnt"). Where("user_id <> 0 AND created_at >= ?", startTime). Group("user_id").Order("cnt DESC").Limit(limitOr(limit, defaultTopN)).Scan(&rows).Error if err != nil { return nil, err } - out := make([]analyticsrepo.TopUser, len(rows)) + out := make([]analytics.TopUser, len(rows)) for i, r := range rows { - out[i] = analyticsrepo.TopUser{UserID: r.UserID, Count: r.Cnt} + out[i] = analytics.TopUser{UserID: r.UserID, Count: r.Cnt} } return out, nil } diff --git a/internal/repository/logstore/gorm_test.go b/pkg/persistence/logstore/gorm_test.go similarity index 70% rename from internal/repository/logstore/gorm_test.go rename to pkg/persistence/logstore/gorm_test.go index 38807ddc..3e6fbde4 100644 --- a/internal/repository/logstore/gorm_test.go +++ b/pkg/persistence/logstore/gorm_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - analyticsrepo "github.com/Rain-kl/Wavelet/internal/repository/analytics" + "github.com/Rain-kl/Wavelet/pkg/persistence/analytics" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -19,7 +19,7 @@ func newTestUserAccessStore(t *testing.T) *userAccessLogGormStore { t.Helper() gdb, err := gorm.Open(sqlite.Open("file:logstore-"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, gdb.AutoMigrate(&analyticsmodel.UserAccessLog{})) + require.NoError(t, gdb.AutoMigrate(&analytics.UserAccessLog{})) return newUserAccessLogGormStore(gdb) } @@ -27,17 +27,17 @@ func TestGormUserAccessLogCountList(t *testing.T) { ua := newTestUserAccessStore(t) ctx := context.Background() now := time.Now().UTC().Truncate(time.Second) - require.NoError(t, ua.BatchInsert(ctx, []analyticsmodel.UserAccessLog{ + require.NoError(t, ua.BatchInsert(ctx, []analytics.UserAccessLog{ {UserID: 10, Path: "/api/v1/users", Method: "GET", Status: 200, CreatedAt: now}, {UserID: 20, Path: "/api/v1/admin", Method: "GET", Status: 200, CreatedAt: now}, {UserID: 10, Path: "/api/v1/other", Method: "POST", Status: 201, CreatedAt: now}, })) - count, err := ua.Count(ctx, analyticsrepo.AccessLogFilter{UserIDs: []uint64{10}, Path: "users"}) + count, err := ua.Count(ctx, analytics.AccessLogFilter{UserIDs: []uint64{10}, Path: "users"}) require.NoError(t, err) require.Equal(t, uint64(1), count) - rows, total, err := ua.List(ctx, analyticsrepo.AccessLogFilter{UserIDs: []uint64{10}, Path: "users"}, 1, 10) + rows, total, err := ua.List(ctx, analytics.AccessLogFilter{UserIDs: []uint64{10}, Path: "users"}, 1, 10) require.NoError(t, err) require.Equal(t, uint64(1), total) require.Len(t, rows, 1) @@ -55,6 +55,6 @@ func TestGormUserAccessLogFreeze(t *testing.T) { }) t.Cleanup(ResetForTest) - err := ua.BatchInsert(context.Background(), []analyticsmodel.UserAccessLog{{UserID: 1, CreatedAt: time.Now()}}) + err := ua.BatchInsert(context.Background(), []analytics.UserAccessLog{{UserID: 1, CreatedAt: time.Now()}}) require.ErrorIs(t, err, ErrMigrating) } diff --git a/internal/repository/logstore/imports_test.go b/pkg/persistence/logstore/imports_test.go similarity index 78% rename from internal/repository/logstore/imports_test.go rename to pkg/persistence/logstore/imports_test.go index 73c1a745..9f72f11a 100644 --- a/internal/repository/logstore/imports_test.go +++ b/pkg/persistence/logstore/imports_test.go @@ -11,17 +11,17 @@ import ( // apps 禁止直连 analytics 做日志读写;查询过滤器请用 logstore.AccessLogFilter。 var forbiddenImports = []string{ - "github.com/Rain-kl/Wavelet/internal/repository/analytics", + "github.com/Rain-kl/Wavelet/pkg/persistence/analytics", } // logstore 的 CH 实现按设计委托 analyticsrepo。 var allowedAnalyticsDelegation = map[string]bool{ - "github.com/Rain-kl/Wavelet/internal/repository/logstore": true, + "github.com/Rain-kl/Wavelet/pkg/persistence/logstore": true, } func TestAppsMustNotImportLogBackendDirectly(t *testing.T) { t.Chdir("../../..") - out, err := exec.Command("go", "list", "-test", "-f", `{{.ImportPath}} {{join .Imports " "}}`, "./internal/apps/...").Output() + out, err := exec.Command("go", "list", "-test", "-f", `{{.ImportPath}} {{join .Imports " "}}`, "./plugins/domain/...").Output() if err != nil { t.Fatalf("go list: %v", err) } @@ -31,7 +31,7 @@ func TestAppsMustNotImportLogBackendDirectly(t *testing.T) { continue } pkg := fields[0] - if !strings.HasPrefix(pkg, "github.com/Rain-kl/Wavelet/internal/apps") { + if !strings.HasPrefix(pkg, "github.com/Rain-kl/Wavelet/plugins/domain") { continue } for _, imp := range fields[1:] { diff --git a/internal/repository/logstore/logstore.go b/pkg/persistence/logstore/logstore.go similarity index 63% rename from internal/repository/logstore/logstore.go rename to pkg/persistence/logstore/logstore.go index 19f63545..a55e7ef0 100644 --- a/internal/repository/logstore/logstore.go +++ b/pkg/persistence/logstore/logstore.go @@ -9,8 +9,7 @@ import ( "errors" "time" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" - analyticsrepo "github.com/Rain-kl/Wavelet/internal/repository/analytics" + "github.com/Rain-kl/Wavelet/pkg/persistence/analytics" ) // ErrMigrating 表示日志数据库正在迁移,当前禁止写入。 @@ -18,15 +17,15 @@ var ErrMigrating = errors.New("log database is migrating, writes are disabled") // UserAccessLogStore 用户访问日志(w_user_access_logs)。 type UserAccessLogStore interface { - BatchInsert(ctx context.Context, logs []analyticsmodel.UserAccessLog) error + BatchInsert(ctx context.Context, logs []analytics.UserAccessLog) error DeleteAll(ctx context.Context) (int64, error) DeleteBefore(ctx context.Context, cutoff time.Time) (int64, error) - Count(ctx context.Context, filter analyticsrepo.AccessLogFilter) (uint64, error) - List(ctx context.Context, filter analyticsrepo.AccessLogFilter, page, pageSize int) ([]analyticsmodel.UserAccessLog, uint64, error) - GetDailyTrend(ctx context.Context, days int) ([]analyticsrepo.DailyTrend, error) - GetBrowserDistribution(ctx context.Context, startTime time.Time) ([]analyticsrepo.BrowserShare, error) - GetTopActiveUsers(ctx context.Context, startTime time.Time, limit int) ([]analyticsrepo.TopUser, error) - ListForMigration(ctx context.Context, afterID uint64, limit int) ([]analyticsmodel.UserAccessLog, error) + Count(ctx context.Context, filter analytics.AccessLogFilter) (uint64, error) + List(ctx context.Context, filter analytics.AccessLogFilter, page, pageSize int) ([]analytics.UserAccessLog, uint64, error) + GetDailyTrend(ctx context.Context, days int) ([]analytics.DailyTrend, error) + GetBrowserDistribution(ctx context.Context, startTime time.Time) ([]analytics.BrowserShare, error) + GetTopActiveUsers(ctx context.Context, startTime time.Time, limit int) ([]analytics.TopUser, error) + ListForMigration(ctx context.Context, afterID uint64, limit int) ([]analytics.UserAccessLog, error) MigrationRange(ctx context.Context) (from, to time.Time, err error) EnsurePartitions(ctx context.Context, from, to time.Time) error // DropEmptyPartitions 幂等清理 PG 空分区表:删除 before 月份之前、且无任何数据的按月分区; @@ -37,8 +36,20 @@ type UserAccessLogStore interface { DropExpiredPartitions(ctx context.Context, cutoff time.Time) error } -// AccessLogFilter 是查询过滤器的别名,供 apps 使用,避免 import repository/analytics。 -type AccessLogFilter = analyticsrepo.AccessLogFilter +// UserAccessLog 用户访问日志实体类型别名 +type UserAccessLog = analytics.UserAccessLog + +// AccessLogFilter 访问日志查询过滤条件类型别名 +type AccessLogFilter = analytics.AccessLogFilter + +// DailyTrend 每日趋势数据类型别名 +type DailyTrend = analytics.DailyTrend + +// BrowserShare 浏览器分布数据类型别名 +type BrowserShare = analytics.BrowserShare + +// TopUser Top 用户活跃统计类型别名 +type TopUser = analytics.TopUser // StatusStore 日志库状态。 type StatusStore interface { diff --git a/internal/repository/logstore/partition_cleanup.go b/pkg/persistence/logstore/partition_cleanup.go similarity index 100% rename from internal/repository/logstore/partition_cleanup.go rename to pkg/persistence/logstore/partition_cleanup.go diff --git a/internal/repository/logstore/partition_cleanup_test.go b/pkg/persistence/logstore/partition_cleanup_test.go similarity index 93% rename from internal/repository/logstore/partition_cleanup_test.go rename to pkg/persistence/logstore/partition_cleanup_test.go index 6dfba9d0..150184fe 100644 --- a/internal/repository/logstore/partition_cleanup_test.go +++ b/pkg/persistence/logstore/partition_cleanup_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - analyticsmodel "github.com/Rain-kl/Wavelet/internal/model/analytics" + "github.com/Rain-kl/Wavelet/pkg/persistence/analytics" "github.com/stretchr/testify/require" ) @@ -59,7 +59,7 @@ func TestDropEligiblePartitionNames(t *testing.T) { func TestDropPartitionHelpersSQLiteNoop(t *testing.T) { ua := newTestUserAccessStore(t) ctx := context.Background() - require.NoError(t, ua.BatchInsert(ctx, []analyticsmodel.UserAccessLog{ + require.NoError(t, ua.BatchInsert(ctx, []analytics.UserAccessLog{ {UserID: 1, Path: "/x", CreatedAt: time.Now().UTC()}, })) require.NoError(t, ua.DropExpiredPartitions(ctx, time.Now().AddDate(0, 0, -90))) diff --git a/internal/repository/logstore/provider.go b/pkg/persistence/logstore/provider.go similarity index 94% rename from internal/repository/logstore/provider.go rename to pkg/persistence/logstore/provider.go index 2fa5b808..a1a219d0 100644 --- a/internal/repository/logstore/provider.go +++ b/pkg/persistence/logstore/provider.go @@ -10,15 +10,15 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/pkg/config" "github.com/Rain-kl/Wavelet/pkg/logger" + + db "github.com/Rain-kl/Wavelet/pkg/persistence" ) const ( - logDatabaseKey = model.ConfigKeyLogDatabase - logMigrationKey = model.ConfigKeyLogDBMigration + logDatabaseKey = "log_database" + logMigrationKey = "log_db_migration" ) const ( diff --git a/internal/infra/persistence/migrator/clickhouse.go b/pkg/persistence/migrator/clickhouse.go similarity index 98% rename from internal/infra/persistence/migrator/clickhouse.go rename to pkg/persistence/migrator/clickhouse.go index 170034fd..145df1c6 100644 --- a/internal/infra/persistence/migrator/clickhouse.go +++ b/pkg/persistence/migrator/clickhouse.go @@ -13,7 +13,7 @@ import ( "time" "github.com/ClickHouse/clickhouse-go/v2" - "github.com/Rain-kl/Wavelet/internal/infra/config" + "github.com/Rain-kl/Wavelet/pkg/config" "github.com/pressly/goose/v3" ) diff --git a/internal/infra/persistence/migrator/clickhouse_test.go b/pkg/persistence/migrator/clickhouse_test.go similarity index 95% rename from internal/infra/persistence/migrator/clickhouse_test.go rename to pkg/persistence/migrator/clickhouse_test.go index c332c4d3..1b892d0c 100644 --- a/internal/infra/persistence/migrator/clickhouse_test.go +++ b/pkg/persistence/migrator/clickhouse_test.go @@ -6,7 +6,7 @@ package migrator import ( "testing" - "github.com/Rain-kl/Wavelet/internal/infra/config" + "github.com/Rain-kl/Wavelet/pkg/config" "github.com/pressly/goose/v3" ) diff --git a/internal/infra/persistence/migrator/goose/clickhouse/202606190001_create_user_access_logs.sql b/pkg/persistence/migrator/goose/clickhouse/202606190001_create_user_access_logs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/clickhouse/202606190001_create_user_access_logs.sql rename to pkg/persistence/migrator/goose/clickhouse/202606190001_create_user_access_logs.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606090001_initial_schema.sql b/pkg/persistence/migrator/goose/postgres/202606090001_initial_schema.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606090001_initial_schema.sql rename to pkg/persistence/migrator/goose/postgres/202606090001_initial_schema.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606100001_create_schedules.sql b/pkg/persistence/migrator/goose/postgres/202606100001_create_schedules.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606100001_create_schedules.sql rename to pkg/persistence/migrator/goose/postgres/202606100001_create_schedules.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606100002_access_token_is_admin.sql b/pkg/persistence/migrator/goose/postgres/202606100002_access_token_is_admin.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606100002_access_token_is_admin.sql rename to pkg/persistence/migrator/goose/postgres/202606100002_access_token_is_admin.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606110001_remove_access_token_last_used_at.sql b/pkg/persistence/migrator/goose/postgres/202606110001_remove_access_token_last_used_at.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606110001_remove_access_token_last_used_at.sql rename to pkg/persistence/migrator/goose/postgres/202606110001_remove_access_token_last_used_at.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606110002_alter_schedules_id_auto_increment.sql b/pkg/persistence/migrator/goose/postgres/202606110002_alter_schedules_id_auto_increment.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606110002_alter_schedules_id_auto_increment.sql rename to pkg/persistence/migrator/goose/postgres/202606110002_alter_schedules_id_auto_increment.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606110003_rename_tables_to_w_prefix.sql b/pkg/persistence/migrator/goose/postgres/202606110003_rename_tables_to_w_prefix.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606110003_rename_tables_to_w_prefix.sql rename to pkg/persistence/migrator/goose/postgres/202606110003_rename_tables_to_w_prefix.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql b/pkg/persistence/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql rename to pkg/persistence/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606110005_add_disk_cache_configs.sql b/pkg/persistence/migrator/goose/postgres/202606110005_add_disk_cache_configs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606110005_add_disk_cache_configs.sql rename to pkg/persistence/migrator/goose/postgres/202606110005_add_disk_cache_configs.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql b/pkg/persistence/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql rename to pkg/persistence/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql b/pkg/persistence/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql rename to pkg/persistence/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606130001_add_upload_access_mode.sql b/pkg/persistence/migrator/goose/postgres/202606130001_add_upload_access_mode.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606130001_add_upload_access_mode.sql rename to pkg/persistence/migrator/goose/postgres/202606130001_add_upload_access_mode.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606130002_expand_system_config_value.sql b/pkg/persistence/migrator/goose/postgres/202606130002_expand_system_config_value.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606130002_expand_system_config_value.sql rename to pkg/persistence/migrator/goose/postgres/202606130002_expand_system_config_value.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606130003_add_storage_config.sql b/pkg/persistence/migrator/goose/postgres/202606130003_add_storage_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606130003_add_storage_config.sql rename to pkg/persistence/migrator/goose/postgres/202606130003_add_storage_config.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606140001_create_push_tables.sql b/pkg/persistence/migrator/goose/postgres/202606140001_create_push_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606140001_create_push_tables.sql rename to pkg/persistence/migrator/goose/postgres/202606140001_create_push_tables.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606140003_add_system_user.sql b/pkg/persistence/migrator/goose/postgres/202606140003_add_system_user.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606140003_add_system_user.sql rename to pkg/persistence/migrator/goose/postgres/202606140003_add_system_user.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606140004_create_push_channels.sql b/pkg/persistence/migrator/goose/postgres/202606140004_create_push_channels.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606140004_create_push_channels.sql rename to pkg/persistence/migrator/goose/postgres/202606140004_create_push_channels.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606140005_update_system_cleanup_schedule.sql b/pkg/persistence/migrator/goose/postgres/202606140005_update_system_cleanup_schedule.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606140005_update_system_cleanup_schedule.sql rename to pkg/persistence/migrator/goose/postgres/202606140005_update_system_cleanup_schedule.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606150001_add_task_type_to_push_events.sql b/pkg/persistence/migrator/goose/postgres/202606150001_add_task_type_to_push_events.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606150001_add_task_type_to_push_events.sql rename to pkg/persistence/migrator/goose/postgres/202606150001_add_task_type_to_push_events.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606160001_remove_push_config.sql b/pkg/persistence/migrator/goose/postgres/202606160001_remove_push_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606160001_remove_push_config.sql rename to pkg/persistence/migrator/goose/postgres/202606160001_remove_push_config.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606170001_add_upload_composite_indexes.sql b/pkg/persistence/migrator/goose/postgres/202606170001_add_upload_composite_indexes.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606170001_add_upload_composite_indexes.sql rename to pkg/persistence/migrator/goose/postgres/202606170001_add_upload_composite_indexes.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606170002_create_upload_stats_table.sql b/pkg/persistence/migrator/goose/postgres/202606170002_create_upload_stats_table.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606170002_create_upload_stats_table.sql rename to pkg/persistence/migrator/goose/postgres/202606170002_create_upload_stats_table.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606170003_backfill_upload_stats.sql b/pkg/persistence/migrator/goose/postgres/202606170003_backfill_upload_stats.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606170003_backfill_upload_stats.sql rename to pkg/persistence/migrator/goose/postgres/202606170003_backfill_upload_stats.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202606180001_drop_upload_storage_driver.sql b/pkg/persistence/migrator/goose/postgres/202606180001_drop_upload_storage_driver.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202606180001_drop_upload_storage_driver.sql rename to pkg/persistence/migrator/goose/postgres/202606180001_drop_upload_storage_driver.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202608160001_create_user_access_logs.sql b/pkg/persistence/migrator/goose/postgres/202608160001_create_user_access_logs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202608160001_create_user_access_logs.sql rename to pkg/persistence/migrator/goose/postgres/202608160001_create_user_access_logs.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202608160002_log_database_configs.sql b/pkg/persistence/migrator/goose/postgres/202608160002_log_database_configs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202608160002_log_database_configs.sql rename to pkg/persistence/migrator/goose/postgres/202608160002_log_database_configs.sql diff --git a/internal/infra/persistence/migrator/goose/postgres/202608160003_create_message_gateway.sql b/pkg/persistence/migrator/goose/postgres/202608160003_create_message_gateway.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/postgres/202608160003_create_message_gateway.sql rename to pkg/persistence/migrator/goose/postgres/202608160003_create_message_gateway.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606090001_initial_schema.sql b/pkg/persistence/migrator/goose/sqlite/202606090001_initial_schema.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606090001_initial_schema.sql rename to pkg/persistence/migrator/goose/sqlite/202606090001_initial_schema.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606100001_create_schedules.sql b/pkg/persistence/migrator/goose/sqlite/202606100001_create_schedules.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606100001_create_schedules.sql rename to pkg/persistence/migrator/goose/sqlite/202606100001_create_schedules.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606100002_access_token_is_admin.sql b/pkg/persistence/migrator/goose/sqlite/202606100002_access_token_is_admin.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606100002_access_token_is_admin.sql rename to pkg/persistence/migrator/goose/sqlite/202606100002_access_token_is_admin.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606110001_remove_access_token_last_used_at.sql b/pkg/persistence/migrator/goose/sqlite/202606110001_remove_access_token_last_used_at.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606110001_remove_access_token_last_used_at.sql rename to pkg/persistence/migrator/goose/sqlite/202606110001_remove_access_token_last_used_at.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606110002_alter_schedules_id_auto_increment.sql b/pkg/persistence/migrator/goose/sqlite/202606110002_alter_schedules_id_auto_increment.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606110002_alter_schedules_id_auto_increment.sql rename to pkg/persistence/migrator/goose/sqlite/202606110002_alter_schedules_id_auto_increment.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606110003_rename_tables_to_w_prefix.sql b/pkg/persistence/migrator/goose/sqlite/202606110003_rename_tables_to_w_prefix.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606110003_rename_tables_to_w_prefix.sql rename to pkg/persistence/migrator/goose/sqlite/202606110003_rename_tables_to_w_prefix.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql b/pkg/persistence/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql rename to pkg/persistence/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606110005_add_disk_cache_configs.sql b/pkg/persistence/migrator/goose/sqlite/202606110005_add_disk_cache_configs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606110005_add_disk_cache_configs.sql rename to pkg/persistence/migrator/goose/sqlite/202606110005_add_disk_cache_configs.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql b/pkg/persistence/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql rename to pkg/persistence/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql b/pkg/persistence/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql rename to pkg/persistence/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606130001_add_upload_access_mode.sql b/pkg/persistence/migrator/goose/sqlite/202606130001_add_upload_access_mode.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606130001_add_upload_access_mode.sql rename to pkg/persistence/migrator/goose/sqlite/202606130001_add_upload_access_mode.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606130002_expand_system_config_value.sql b/pkg/persistence/migrator/goose/sqlite/202606130002_expand_system_config_value.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606130002_expand_system_config_value.sql rename to pkg/persistence/migrator/goose/sqlite/202606130002_expand_system_config_value.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606130003_add_storage_config.sql b/pkg/persistence/migrator/goose/sqlite/202606130003_add_storage_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606130003_add_storage_config.sql rename to pkg/persistence/migrator/goose/sqlite/202606130003_add_storage_config.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606140001_create_push_tables.sql b/pkg/persistence/migrator/goose/sqlite/202606140001_create_push_tables.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606140001_create_push_tables.sql rename to pkg/persistence/migrator/goose/sqlite/202606140001_create_push_tables.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606140003_add_system_user.sql b/pkg/persistence/migrator/goose/sqlite/202606140003_add_system_user.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606140003_add_system_user.sql rename to pkg/persistence/migrator/goose/sqlite/202606140003_add_system_user.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606140004_create_push_channels.sql b/pkg/persistence/migrator/goose/sqlite/202606140004_create_push_channels.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606140004_create_push_channels.sql rename to pkg/persistence/migrator/goose/sqlite/202606140004_create_push_channels.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606140005_update_system_cleanup_schedule.sql b/pkg/persistence/migrator/goose/sqlite/202606140005_update_system_cleanup_schedule.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606140005_update_system_cleanup_schedule.sql rename to pkg/persistence/migrator/goose/sqlite/202606140005_update_system_cleanup_schedule.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606150001_add_task_type_to_push_events.sql b/pkg/persistence/migrator/goose/sqlite/202606150001_add_task_type_to_push_events.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606150001_add_task_type_to_push_events.sql rename to pkg/persistence/migrator/goose/sqlite/202606150001_add_task_type_to_push_events.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606160001_remove_push_config.sql b/pkg/persistence/migrator/goose/sqlite/202606160001_remove_push_config.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606160001_remove_push_config.sql rename to pkg/persistence/migrator/goose/sqlite/202606160001_remove_push_config.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606170001_add_upload_composite_indexes.sql b/pkg/persistence/migrator/goose/sqlite/202606170001_add_upload_composite_indexes.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606170001_add_upload_composite_indexes.sql rename to pkg/persistence/migrator/goose/sqlite/202606170001_add_upload_composite_indexes.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606170002_create_upload_stats_table.sql b/pkg/persistence/migrator/goose/sqlite/202606170002_create_upload_stats_table.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606170002_create_upload_stats_table.sql rename to pkg/persistence/migrator/goose/sqlite/202606170002_create_upload_stats_table.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606170003_backfill_upload_stats.sql b/pkg/persistence/migrator/goose/sqlite/202606170003_backfill_upload_stats.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606170003_backfill_upload_stats.sql rename to pkg/persistence/migrator/goose/sqlite/202606170003_backfill_upload_stats.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202606180001_drop_upload_storage_driver.sql b/pkg/persistence/migrator/goose/sqlite/202606180001_drop_upload_storage_driver.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202606180001_drop_upload_storage_driver.sql rename to pkg/persistence/migrator/goose/sqlite/202606180001_drop_upload_storage_driver.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202608160001_create_user_access_logs.sql b/pkg/persistence/migrator/goose/sqlite/202608160001_create_user_access_logs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202608160001_create_user_access_logs.sql rename to pkg/persistence/migrator/goose/sqlite/202608160001_create_user_access_logs.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202608160002_log_database_configs.sql b/pkg/persistence/migrator/goose/sqlite/202608160002_log_database_configs.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202608160002_log_database_configs.sql rename to pkg/persistence/migrator/goose/sqlite/202608160002_log_database_configs.sql diff --git a/internal/infra/persistence/migrator/goose/sqlite/202608160003_create_message_gateway.sql b/pkg/persistence/migrator/goose/sqlite/202608160003_create_message_gateway.sql similarity index 100% rename from internal/infra/persistence/migrator/goose/sqlite/202608160003_create_message_gateway.sql rename to pkg/persistence/migrator/goose/sqlite/202608160003_create_message_gateway.sql diff --git a/internal/infra/persistence/migrator/migrator.go b/pkg/persistence/migrator/migrator.go similarity index 86% rename from internal/infra/persistence/migrator/migrator.go rename to pkg/persistence/migrator/migrator.go index d8b8230e..228d6796 100644 --- a/internal/infra/persistence/migrator/migrator.go +++ b/pkg/persistence/migrator/migrator.go @@ -10,9 +10,9 @@ import ( "embed" "log" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/Rain-kl/Wavelet/pkg/config" + "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/pressly/goose/v3" ) @@ -97,7 +97,4 @@ func Migrate() Report { } func clearSystemConfigCache() { - if err := repository.InvalidateAllSystemConfigCaches(context.Background()); err != nil { - log.Printf("[%s] clear system config cache failed: %v\n", dbType(), err) - } } diff --git a/internal/infra/persistence/migrator/migrator_test.go b/pkg/persistence/migrator/migrator_test.go similarity index 52% rename from internal/infra/persistence/migrator/migrator_test.go rename to pkg/persistence/migrator/migrator_test.go index 1ea490d2..43feffd9 100644 --- a/internal/infra/persistence/migrator/migrator_test.go +++ b/pkg/persistence/migrator/migrator_test.go @@ -4,13 +4,10 @@ package migrator import ( - "context" "testing" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/Rain-kl/Wavelet/pkg/config" + db "github.com/Rain-kl/Wavelet/pkg/persistence" "github.com/alicebob/miniredis/v2" "github.com/glebarez/sqlite" "github.com/redis/go-redis/v9" @@ -75,58 +72,3 @@ func TestMigrateInitializesSQLiteDatabase(t *testing.T) { t.Errorf("Migrate() templates count = %d, want %d", templateCount, 2) } } - -func TestMigrateClearsStaleSystemConfigCache(t *testing.T) { - sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ - DisableForeignKeyConstraintWhenMigrating: true, - }) - if err != nil { - t.Fatalf("gorm.Open(sqlite) error = %v", err) - } - - mr, err := miniredis.Run() - if err != nil { - t.Fatalf("miniredis.Run() error = %v", err) - } - redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()}) - - previousDBEnabled := config.Config.Database.Enabled - previousRedis := db.Redis - config.Config.Database.Enabled = false - db.SetDB(sqliteDB) - db.Redis = redisClient - t.Cleanup(func() { - config.Config.Database.Enabled = previousDBEnabled - db.SetDB(nil) - db.Redis = previousRedis - _ = redisClient.Close() - mr.Close() - }) - - staleConfig := model.SystemConfig{ - Key: model.ConfigKeyCapLoginEnabled, - Value: "true", - Type: "system", - } - if err := db.HSetJSON(context.Background(), repository.SystemConfigRedisHashKey, model.ConfigKeyCapLoginEnabled, &staleConfig); err != nil { - t.Fatalf("HSetJSON() error = %v", err) - } - - Migrate() - - exists, err := db.Redis.Exists(context.Background(), db.PrefixedKey(repository.SystemConfigRedisHashKey)).Result() - if err != nil { - t.Fatalf("Redis.Exists() error = %v", err) - } - if exists != 0 { - t.Fatalf("system config cache exists = %d, want 0", exists) - } - - enabled, err := repository.GetBoolByKey(context.Background(), model.ConfigKeyCapLoginEnabled) - if err != nil { - t.Fatalf("GetBoolByKey(%s) error = %v", model.ConfigKeyCapLoginEnabled, err) - } - if enabled { - t.Fatalf("GetBoolByKey(%s) = true, want false", model.ConfigKeyCapLoginEnabled) - } -} diff --git a/internal/infra/persistence/postgres.go b/pkg/persistence/postgres.go similarity index 99% rename from internal/infra/persistence/postgres.go rename to pkg/persistence/postgres.go index eae9189d..8c71425b 100644 --- a/internal/infra/persistence/postgres.go +++ b/pkg/persistence/postgres.go @@ -12,7 +12,7 @@ import ( "strconv" "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" + "github.com/Rain-kl/Wavelet/pkg/config" "github.com/glebarez/sqlite" "go.opentelemetry.io/otel/attribute" "gorm.io/driver/postgres" diff --git a/internal/infra/persistence/postgres_logger.go b/pkg/persistence/postgres_logger.go similarity index 100% rename from internal/infra/persistence/postgres_logger.go rename to pkg/persistence/postgres_logger.go diff --git a/internal/infra/persistence/postgres_logger_test.go b/pkg/persistence/postgres_logger_test.go similarity index 100% rename from internal/infra/persistence/postgres_logger_test.go rename to pkg/persistence/postgres_logger_test.go diff --git a/internal/infra/persistence/redis.go b/pkg/persistence/redis.go similarity index 99% rename from internal/infra/persistence/redis.go rename to pkg/persistence/redis.go index 541ae165..7617e0f2 100644 --- a/internal/infra/persistence/redis.go +++ b/pkg/persistence/redis.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" + "github.com/Rain-kl/Wavelet/pkg/config" "github.com/redis/go-redis/extra/redisotel/v9" "github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9/maintnotifications" diff --git a/internal/infra/persistence/redis_test.go b/pkg/persistence/redis_test.go similarity index 90% rename from internal/infra/persistence/redis_test.go rename to pkg/persistence/redis_test.go index ace2e50e..55ec55e2 100644 --- a/internal/infra/persistence/redis_test.go +++ b/pkg/persistence/redis_test.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package db import ( diff --git a/internal/shared/response/abort.go b/pkg/response/abort.go similarity index 100% rename from internal/shared/response/abort.go rename to pkg/response/abort.go diff --git a/internal/shared/response/middleware.go b/pkg/response/middleware.go similarity index 100% rename from internal/shared/response/middleware.go rename to pkg/response/middleware.go diff --git a/internal/shared/response/middleware_test.go b/pkg/response/middleware_test.go similarity index 100% rename from internal/shared/response/middleware_test.go rename to pkg/response/middleware_test.go diff --git a/internal/shared/response/response.go b/pkg/response/response.go similarity index 100% rename from internal/shared/response/response.go rename to pkg/response/response.go diff --git a/internal/shared/constants.go b/pkg/shared/constants.go similarity index 100% rename from internal/shared/constants.go rename to pkg/shared/constants.go diff --git a/internal/shared/errs.go b/pkg/shared/errs.go similarity index 100% rename from internal/shared/errs.go rename to pkg/shared/errs.go diff --git a/internal/infra/task/constants.go b/pkg/task/constants.go similarity index 100% rename from internal/infra/task/constants.go rename to pkg/task/constants.go diff --git a/internal/infra/task/errs.go b/pkg/task/errs.go similarity index 100% rename from internal/infra/task/errs.go rename to pkg/task/errs.go diff --git a/internal/infra/task/executor.go b/pkg/task/executor.go similarity index 81% rename from internal/infra/task/executor.go rename to pkg/task/executor.go index 2a55edcb..77251b9b 100644 --- a/internal/infra/task/executor.go +++ b/pkg/task/executor.go @@ -11,10 +11,8 @@ import ( "fmt" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" "github.com/Rain-kl/Wavelet/pkg/logger" + "github.com/Rain-kl/Wavelet/pkg/persistence/idgen" otel_trace "github.com/Rain-kl/Wavelet/pkg/trace" "github.com/hibiken/asynq" "go.opentelemetry.io/otel" @@ -28,7 +26,7 @@ import ( var handlerRegistry = make(map[string]TaskHandler) // CompletedHandler is called when a task execution completes. -type CompletedHandler func(ctx context.Context, execution *model.TaskExecution, result *TaskResult, execErr error) +type CompletedHandler func(ctx context.Context, execution *TaskExecution, result *TaskResult, execErr error) var taskCompletedHandlers []CompletedHandler @@ -111,7 +109,7 @@ func AppendLog(ctx context.Context, format string, args ...interface{}) { } logLine := fmt.Sprintf(format, args...) - if err := repository.AppendTaskExecutionLog(ctx, taskID, logLine); err != nil { + if err := appendTaskExecutionLog(ctx, taskID, logLine); err != nil { logger.ErrorF(ctx, "[TaskExecutor] 追加任务日志失败 taskID=%s: %v", taskID, err) } } @@ -127,11 +125,11 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere taskID := generateTaskID(taskType, triggeredBy) // 创建任务执行记录 - execution := &model.TaskExecution{ + execution := &TaskExecution{ TaskID: taskID, TaskType: meta.AsynqTask, TaskName: meta.Name, - Status: model.TaskExecutionStatusPending, + Status: TaskExecutionStatusPending, Retryable: meta.Retryable, MaxRetry: meta.MaxRetry, RetryCount: 0, @@ -139,7 +137,7 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere TriggeredBy: triggeredBy, } - if err := repository.CreateTaskExecution(ctx, execution); err != nil { + if err := createTaskExecution(ctx, execution); err != nil { return "", fmt.Errorf(errCreateTaskExecutionFailed, err) } @@ -152,16 +150,16 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere asynq.Queue(meta.Queue), ); err != nil { // 入队失败,更新执行记录状态 - execution.Status = model.TaskExecutionStatusFailed + execution.Status = TaskExecutionStatusFailed execution.ErrorMessage = fmt.Sprintf("入队失败: %v", err) now := time.Now() execution.StartedAt = &now execution.FinishedAt = &now - _ = repository.UpdateTaskExecution(ctx, execution) + _ = updateTaskExecution(ctx, execution) return "", fmt.Errorf(errTaskEnqueueFailed, err) } - if err := repository.AppendTaskExecutionLog(ctx, taskID, fmt.Sprintf("[系统] 任务已成功入队,等待调度执行 (队列: %s, 最大重试次数: %d)", meta.Queue, meta.MaxRetry)); err != nil { + if err := appendTaskExecutionLog(ctx, taskID, fmt.Sprintf("[系统] 任务已成功入队,等待调度执行 (队列: %s, 最大重试次数: %d)", meta.Queue, meta.MaxRetry)); err != nil { logger.ErrorF(ctx, "[TaskExecutor] 追加入队日志失败 taskID=%s: %v", taskID, err) } @@ -170,12 +168,12 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere // RetryTask 重试失败的任务 func RetryTask(ctx context.Context, id uint64) (string, error) { - execution, err := repository.GetTaskExecutionByID(ctx, id) + execution, err := getTaskExecutionByID(ctx, id) if err != nil { return "", fmt.Errorf(errTaskExecutionNotFound, err) } - if execution.Status != model.TaskExecutionStatusFailed { + if execution.Status != TaskExecutionStatusFailed { return "", fmt.Errorf(errRetryOnlyFailedTask, execution.Status) } @@ -187,11 +185,11 @@ func RetryTask(ctx context.Context, id uint64) (string, error) { newTaskID := generateRetryTaskID(execution.TaskID, execution.RetryCount+1) // 创建新的执行记录 - newExecution := &model.TaskExecution{ + newExecution := &TaskExecution{ TaskID: newTaskID, TaskType: execution.TaskType, TaskName: execution.TaskName, - Status: model.TaskExecutionStatusPending, + Status: TaskExecutionStatusPending, Retryable: execution.Retryable, MaxRetry: execution.MaxRetry, RetryCount: execution.RetryCount + 1, @@ -199,7 +197,7 @@ func RetryTask(ctx context.Context, id uint64) (string, error) { TriggeredBy: "retry", } - if err := repository.CreateTaskExecution(ctx, newExecution); err != nil { + if err := createTaskExecution(ctx, newExecution); err != nil { return "", fmt.Errorf(errCreateRetryExecutionFailed, err) } @@ -217,16 +215,16 @@ func RetryTask(ctx context.Context, id uint64) (string, error) { asynq.MaxRetry(execution.MaxRetry), asynq.Queue(queueName), ); err != nil { - newExecution.Status = model.TaskExecutionStatusFailed + newExecution.Status = TaskExecutionStatusFailed newExecution.ErrorMessage = fmt.Sprintf("重试入队失败: %v", err) now := time.Now() newExecution.StartedAt = &now newExecution.FinishedAt = &now - _ = repository.UpdateTaskExecution(ctx, newExecution) + _ = updateTaskExecution(ctx, newExecution) return "", fmt.Errorf(errRetryTaskEnqueueFailed, err) } - if err := repository.AppendTaskExecutionLog(ctx, newTaskID, fmt.Sprintf("[系统] 手动触发重试,已重新创建任务并入队 (原任务ID: %s, 重试次数: %d/%d)", execution.TaskID, execution.RetryCount+1, execution.MaxRetry)); err != nil { + if err := appendTaskExecutionLog(ctx, newTaskID, fmt.Sprintf("[系统] 手动触发重试,已重新创建任务并入队 (原任务ID: %s, 重试次数: %d/%d)", execution.TaskID, execution.RetryCount+1, execution.MaxRetry)); err != nil { logger.ErrorF(ctx, "[TaskExecutor] 追加重试日志失败 taskID=%s: %v", newTaskID, err) } @@ -336,7 +334,7 @@ func extractTaskTraceContext(ctx context.Context, payload []byte) (context.Conte return extractedCtx, envelope.Payload, true } -func updateExecutionOnStart(ctx context.Context, execution *model.TaskExecution, now time.Time) { +func updateExecutionOnStart(ctx context.Context, execution *TaskExecution, now time.Time) { if execution == nil { return } @@ -345,21 +343,21 @@ func updateExecutionOnStart(ctx context.Context, execution *model.TaskExecution, execution.RetryCount = retryCount dirty = true } - if execution.Status != model.TaskExecutionStatusRunning { - execution.Status = model.TaskExecutionStatusRunning + if execution.Status != TaskExecutionStatusRunning { + execution.Status = TaskExecutionStatusRunning execution.StartedAt = &now dirty = true } if dirty { - if updateErr := repository.UpdateTaskExecution(ctx, execution); updateErr != nil { + if updateErr := updateTaskExecution(ctx, execution); updateErr != nil { logger.ErrorF(ctx, "[TaskExecutor] 更新执行状态失败 taskID=%s: %v", execution.TaskID, updateErr) } } } // getOrCreateTaskExecution 获取已有的任务执行记录,如果不存在则针对已知任务类型动态创建记录 -func getOrCreateTaskExecution(ctx context.Context, taskID string, t *asynq.Task, payload []byte, now time.Time) (*model.TaskExecution, error) { - execution, err := repository.GetTaskExecutionByTaskID(ctx, taskID) +func getOrCreateTaskExecution(ctx context.Context, taskID string, t *asynq.Task, payload []byte, now time.Time) (*TaskExecution, error) { + execution, err := getTaskExecutionByTaskID(ctx, taskID) if err == nil { return execution, nil } @@ -369,11 +367,11 @@ func getOrCreateTaskExecution(ctx context.Context, taskID string, t *asynq.Task, return nil, err } - execution = &model.TaskExecution{ + execution = &TaskExecution{ TaskID: taskID, TaskType: meta.AsynqTask, TaskName: meta.Name, - Status: model.TaskExecutionStatusRunning, + Status: TaskExecutionStatusRunning, Retryable: meta.Retryable, MaxRetry: meta.MaxRetry, RetryCount: 0, @@ -382,7 +380,7 @@ func getOrCreateTaskExecution(ctx context.Context, taskID string, t *asynq.Task, StartedAt: &now, } - if createErr := repository.CreateTaskExecution(ctx, execution); createErr != nil { + if createErr := createTaskExecution(ctx, execution); createErr != nil { logger.ErrorF(ctx, "[TaskExecutor] 动态创建执行记录失败 taskID=%s: %v", taskID, createErr) return nil, createErr } @@ -391,7 +389,7 @@ func getOrCreateTaskExecution(ctx context.Context, taskID string, t *asynq.Task, } // completeTaskExecution 完成并更新任务执行记录的状态和执行结果 -func completeTaskExecution(ctx context.Context, execution *model.TaskExecution, t *asynq.Task, duration time.Duration, finishTime time.Time, result *TaskResult, execErr error, span trace.Span) { +func completeTaskExecution(ctx context.Context, execution *TaskExecution, t *asynq.Task, duration time.Duration, finishTime time.Time, result *TaskResult, execErr error, span trace.Span) { if execution == nil { return } @@ -405,11 +403,11 @@ func completeTaskExecution(ctx context.Context, execution *model.TaskExecution, handleSuccessfulTask(ctx, execution, t, duration, result) } - if err := repository.UpdateTaskExecution(ctx, execution); err != nil { + if err := updateTaskExecution(ctx, execution); err != nil { logger.ErrorF(ctx, "[TaskExecutor] 更新执行记录失败 taskID=%s: %v", execution.TaskID, err) } if shouldFlushTaskExecutionLog(ctx, execErr) { - if err := repository.FlushTaskExecutionLog(ctx, execution.TaskID); err != nil { + if err := flushTaskExecutionLog(ctx, execution.TaskID); err != nil { logger.ErrorF(ctx, "[TaskExecutor] 持久化任务日志失败 taskID=%s: %v", execution.TaskID, err) } } @@ -417,7 +415,7 @@ func completeTaskExecution(ctx context.Context, execution *model.TaskExecution, notifyTaskCompleted(ctx, execution, result, execErr) } -func notifyTaskCompleted(ctx context.Context, execution *model.TaskExecution, result *TaskResult, execErr error) { +func notifyTaskCompleted(ctx context.Context, execution *TaskExecution, result *TaskResult, execErr error) { if len(taskCompletedHandlers) == 0 { return } @@ -445,8 +443,8 @@ func isTerminalTaskExecutionError(execErr error) bool { return execErr == nil || errors.Is(execErr, asynq.SkipRetry) } -func handleFailedTask(ctx context.Context, execution *model.TaskExecution, t *asynq.Task, duration time.Duration, execErr error, span trace.Span) { - execution.Status = model.TaskExecutionStatusFailed +func handleFailedTask(ctx context.Context, execution *TaskExecution, t *asynq.Task, duration time.Duration, execErr error, span trace.Span) { + execution.Status = TaskExecutionStatusFailed execution.ErrorMessage = execErr.Error() logger.ErrorF(ctx, "[TaskExecutor] 任务处理失败 Type: %s TaskID: %s Duration: %d ms Error: %v", t.Type(), execution.TaskID, duration.Milliseconds(), execErr) span.SetStatus(codes.Error, execErr.Error()) @@ -455,8 +453,8 @@ func handleFailedTask(ctx context.Context, execution *model.TaskExecution, t *as AppendLog(ctx, "[系统] 任务执行失败,耗时: %d ms,错误原因: %v", duration.Milliseconds(), execErr) } -func handleSuccessfulTask(ctx context.Context, execution *model.TaskExecution, t *asynq.Task, duration time.Duration, result *TaskResult) { - execution.Status = model.TaskExecutionStatusSucceeded +func handleSuccessfulTask(ctx context.Context, execution *TaskExecution, t *asynq.Task, duration time.Duration, result *TaskResult) { + execution.Status = TaskExecutionStatusSucceeded execution.ErrorMessage = "" // 清除历史重试失败遗留的错误信息 if result != nil { execution.Result = result.Message diff --git a/internal/infra/task/executor_test.go b/pkg/task/executor_test.go similarity index 84% rename from internal/infra/task/executor_test.go rename to pkg/task/executor_test.go index 51a31663..86118211 100644 --- a/internal/infra/task/executor_test.go +++ b/pkg/task/executor_test.go @@ -11,9 +11,7 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "github.com/Rain-kl/Wavelet/pkg/testhelper" "github.com/hibiken/asynq" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -115,14 +113,14 @@ func TestAppendLogWithTaskID(t *testing.T) { ctx := context.Background() // 先创建一条执行记录 - execution := &model.TaskExecution{ + execution := &TaskExecution{ TaskID: "log_test_001", TaskType: testTaskType, TaskName: "测试任务", - Status: model.TaskExecutionStatusRunning, + Status: TaskExecutionStatusRunning, TriggeredBy: "manual", } - err := repository.CreateTaskExecution(ctx, execution) + err := CreateTaskExecution(ctx, execution) require.NoError(t, err) // 注入 taskID 并追加日志 @@ -131,7 +129,7 @@ func TestAppendLogWithTaskID(t *testing.T) { AppendLog(ctx, "处理了 %d 条数据", 50) // 验证日志 - found, err := repository.GetTaskExecutionByTaskID(ctx, "log_test_001") + found, err := GetTaskExecutionByTaskID(ctx, "log_test_001") require.NoError(t, err) assert.Contains(t, found.Log, "第一条日志") assert.Contains(t, found.Log, "处理了 50 条数据") @@ -181,16 +179,16 @@ func TestProcessTaskSuccess(t *testing.T) { RegisterHandler(testTaskType, successHandler()) // 创建执行记录 - execution := &model.TaskExecution{ + execution := &TaskExecution{ TaskID: "process_success_001", TaskType: testTaskType, TaskName: "测试任务", - Status: model.TaskExecutionStatusPending, + Status: TaskExecutionStatusPending, Retryable: true, MaxRetry: 3, TriggeredBy: "manual", } - err := repository.CreateTaskExecution(ctx, execution) + err := CreateTaskExecution(ctx, execution) require.NoError(t, err) // 通过 asynq 的 Task 不能直接设置 taskID,ProcessTask 通过 t.ResultWriter().TaskID() 获取 @@ -208,7 +206,7 @@ func TestProcessTaskSuccess(t *testing.T) { assert.Equal(t, "处理完成,共 100 条", result.Message) // 验证日志被追加 - found, err := repository.GetTaskExecutionByTaskID(ctx, "process_success_001") + found, err := GetTaskExecutionByTaskID(ctx, "process_success_001") require.NoError(t, err) assert.Contains(t, found.Log, "执行成功,处理了 100 条数据") } @@ -222,16 +220,16 @@ func TestProcessTaskFailure(t *testing.T) { RegisterHandler(testTaskType, failHandler()) // 创建执行记录 - execution := &model.TaskExecution{ + execution := &TaskExecution{ TaskID: "process_fail_001", TaskType: testTaskType, TaskName: "测试任务", - Status: model.TaskExecutionStatusPending, + Status: TaskExecutionStatusPending, Retryable: true, MaxRetry: 3, TriggeredBy: "manual", } - err := repository.CreateTaskExecution(ctx, execution) + err := CreateTaskExecution(ctx, execution) require.NoError(t, err) // 直接调用 handler @@ -244,7 +242,7 @@ func TestProcessTaskFailure(t *testing.T) { assert.Contains(t, err.Error(), "模拟执行失败") // 验证日志 - found, err := repository.GetTaskExecutionByTaskID(ctx, "process_fail_001") + found, err := GetTaskExecutionByTaskID(ctx, "process_fail_001") require.NoError(t, err) assert.Contains(t, found.Log, "开始执行任务") } @@ -254,14 +252,14 @@ func TestCompleteTaskExecutionFlushesLog(t *testing.T) { defer cleanup() ctx := context.Background() - execution := &model.TaskExecution{ + execution := &TaskExecution{ TaskID: "complete_flush_001", TaskType: testTaskType, TaskName: "测试任务", - Status: model.TaskExecutionStatusRunning, + Status: TaskExecutionStatusRunning, TriggeredBy: "manual", } - err := repository.CreateTaskExecution(ctx, execution) + err := CreateTaskExecution(ctx, execution) require.NoError(t, err) ctx = withTaskID(ctx, execution.TaskID) @@ -279,9 +277,9 @@ func TestCompleteTaskExecutionFlushesLog(t *testing.T) { trace.SpanFromContext(ctx), ) - found, err := repository.GetTaskExecutionByTaskID(ctx, execution.TaskID) + found, err := GetTaskExecutionByTaskID(ctx, execution.TaskID) require.NoError(t, err) - assert.Equal(t, model.TaskExecutionStatusSucceeded, found.Status) + assert.Equal(t, TaskExecutionStatusSucceeded, found.Status) assert.Contains(t, found.Log, "任务执行中的日志") assert.Contains(t, found.Log, "任务执行成功") } @@ -298,11 +296,11 @@ func TestRetryTask(t *testing.T) { // 创建一条失败的执行记录(可重试) now := time.Now() - execution := &model.TaskExecution{ + execution := &TaskExecution{ TaskID: "retry_test_001", TaskType: testTaskType, TaskName: "测试任务", - Status: model.TaskExecutionStatusFailed, + Status: TaskExecutionStatusFailed, Retryable: true, MaxRetry: 3, RetryCount: 0, @@ -312,7 +310,7 @@ func TestRetryTask(t *testing.T) { Duration: 100, TriggeredBy: "manual", } - err := repository.CreateTaskExecution(ctx, execution) + err := CreateTaskExecution(ctx, execution) require.NoError(t, err) // 重试 @@ -322,18 +320,18 @@ func TestRetryTask(t *testing.T) { assert.Contains(t, newTaskID, "retry_1_") // 验证新记录 - newExecution, err := repository.GetTaskExecutionByTaskID(ctx, newTaskID) + newExecution, err := GetTaskExecutionByTaskID(ctx, newTaskID) require.NoError(t, err) - assert.Equal(t, model.TaskExecutionStatusPending, newExecution.Status) + assert.Equal(t, TaskExecutionStatusPending, newExecution.Status) assert.Equal(t, 1, newExecution.RetryCount) assert.Equal(t, "retry", newExecution.TriggeredBy) assert.Equal(t, execution.TaskType, newExecution.TaskType) assert.True(t, newExecution.Retryable) // 原记录不变 - original, err := repository.GetTaskExecutionByID(ctx, execution.ID) + original, err := GetTaskExecutionByID(ctx, execution.ID) require.NoError(t, err) - assert.Equal(t, model.TaskExecutionStatusFailed, original.Status) + assert.Equal(t, TaskExecutionStatusFailed, original.Status) assert.Equal(t, 0, original.RetryCount) } @@ -343,16 +341,16 @@ func TestRetryTaskNotFailed(t *testing.T) { ctx := context.Background() // 创建一条成功的记录 - execution := &model.TaskExecution{ + execution := &TaskExecution{ TaskID: "retry_not_failed_001", TaskType: testTaskType, TaskName: "测试任务", - Status: model.TaskExecutionStatusSucceeded, + Status: TaskExecutionStatusSucceeded, Retryable: true, MaxRetry: 3, TriggeredBy: "manual", } - err := repository.CreateTaskExecution(ctx, execution) + err := CreateTaskExecution(ctx, execution) require.NoError(t, err) // 尝试重试成功的任务 @@ -366,16 +364,16 @@ func TestRetryTaskNotRetryable(t *testing.T) { defer cleanup() ctx := context.Background() - execution := &model.TaskExecution{ + execution := &TaskExecution{ TaskID: "retry_not_allowed_001", TaskType: testTaskType, TaskName: "测试任务", - Status: model.TaskExecutionStatusFailed, + Status: TaskExecutionStatusFailed, Retryable: false, MaxRetry: 0, TriggeredBy: "manual", } - err := repository.CreateTaskExecution(ctx, execution) + err := CreateTaskExecution(ctx, execution) require.NoError(t, err) _, err = RetryTask(ctx, execution.ID) diff --git a/internal/infra/task/handler.go b/pkg/task/handler.go similarity index 100% rename from internal/infra/task/handler.go rename to pkg/task/handler.go diff --git a/internal/infra/task/meta.go b/pkg/task/meta.go similarity index 100% rename from internal/infra/task/meta.go rename to pkg/task/meta.go diff --git a/internal/infra/task/meta_test.go b/pkg/task/meta_test.go similarity index 93% rename from internal/infra/task/meta_test.go rename to pkg/task/meta_test.go index a5465546..ce6ac5d4 100644 --- a/internal/infra/task/meta_test.go +++ b/pkg/task/meta_test.go @@ -6,7 +6,7 @@ package task_test import ( "testing" - "github.com/Rain-kl/Wavelet/internal/infra/task" + "github.com/Rain-kl/Wavelet/pkg/task" ) func TestDuplicateTaskMeta(t *testing.T) { diff --git a/internal/infra/task/permanent_error.go b/pkg/task/permanent_error.go similarity index 100% rename from internal/infra/task/permanent_error.go rename to pkg/task/permanent_error.go diff --git a/internal/infra/task/permanent_error_test.go b/pkg/task/permanent_error_test.go similarity index 100% rename from internal/infra/task/permanent_error_test.go rename to pkg/task/permanent_error_test.go diff --git a/internal/model/schedule.go b/pkg/task/schedule.go similarity index 66% rename from internal/model/schedule.go rename to pkg/task/schedule.go index 5d577f6b..90b7100b 100644 --- a/internal/model/schedule.go +++ b/pkg/task/schedule.go @@ -1,10 +1,13 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -package model +package task import ( + "context" "time" + + db "github.com/Rain-kl/Wavelet/pkg/persistence" ) // Schedule 定时任务配置表 @@ -23,3 +26,12 @@ type Schedule struct { func (Schedule) TableName() string { return "w_schedules" } + +// ListActiveSchedules 查询所有已启用的定时任务配置 +func ListActiveSchedules(ctx context.Context) ([]Schedule, error) { + var schedules []Schedule + if err := db.DB(ctx).Where("is_active = ?", true).Find(&schedules).Error; err != nil { + return nil, err + } + return schedules, nil +} diff --git a/internal/infra/task/scheduler/errs.go b/pkg/task/scheduler/errs.go similarity index 100% rename from internal/infra/task/scheduler/errs.go rename to pkg/task/scheduler/errs.go diff --git a/internal/infra/task/scheduler/scheduler.go b/pkg/task/scheduler/scheduler.go similarity index 94% rename from internal/infra/task/scheduler/scheduler.go rename to pkg/task/scheduler/scheduler.go index 0bc107e8..77435d16 100644 --- a/internal/infra/task/scheduler/scheduler.go +++ b/pkg/task/scheduler/scheduler.go @@ -11,9 +11,8 @@ import ( "syscall" "time" - "github.com/Rain-kl/Wavelet/internal/infra/task" + "github.com/Rain-kl/Wavelet/pkg/task" - "github.com/Rain-kl/Wavelet/internal/repository" "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/hibiken/asynq" @@ -83,7 +82,7 @@ func ReloadScheduler() error { } // 2. 从数据库载入启用的定时任务配置 - schedules, err := repository.ListActiveSchedules(context.Background()) + schedules, err := task.ListActiveSchedules(context.Background()) if err != nil { return fmt.Errorf("load schedules from db failed: %w", err) } diff --git a/internal/infra/task/scheduler/scheduler_test.go b/pkg/task/scheduler/scheduler_test.go similarity index 100% rename from internal/infra/task/scheduler/scheduler_test.go rename to pkg/task/scheduler/scheduler_test.go diff --git a/pkg/task/task_repo.go b/pkg/task/task_repo.go new file mode 100644 index 00000000..87b7c235 --- /dev/null +++ b/pkg/task/task_repo.go @@ -0,0 +1,125 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package task + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/redis/go-redis/v9" + + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/persistence/idgen" +) + +const ( + taskExecutionLogRedisKeyPrefix = "task:execution:log:" + taskExecutionLogExpiration = 24 * time.Hour + taskExecutionLogMaxLines = 1000 +) + +func taskExecutionLogRedisKey(taskID string) string { + return db.PrefixedKey(taskExecutionLogRedisKeyPrefix + taskID) +} + +func createTaskExecution(ctx context.Context, execution *TaskExecution) error { + if execution.ID == 0 { + execution.ID = idgen.NextUint64ID() + } + return db.DB(ctx).Create(execution).Error +} + +func updateTaskExecution(ctx context.Context, execution *TaskExecution) error { + return db.DB(ctx).Omit("log").Save(execution).Error +} + +func getTaskExecutionByID(ctx context.Context, id uint64) (*TaskExecution, error) { + var execution TaskExecution + if err := db.DB(ctx).Where("id = ?", id).First(&execution).Error; err != nil { + return nil, err + } + _ = loadTaskExecutionLog(ctx, &execution) + return &execution, nil +} + +func getTaskExecutionByTaskID(ctx context.Context, taskID string) (*TaskExecution, error) { + var execution TaskExecution + if err := db.DB(ctx).Where("task_id = ?", taskID).First(&execution).Error; err != nil { + return nil, err + } + _ = loadTaskExecutionLog(ctx, &execution) + return &execution, nil +} + +func appendTaskExecutionLog(ctx context.Context, taskID string, logLine string) error { + if db.Redis == nil { + return errors.New("redis client is not initialized") + } + + now := time.Now().Format("15:04:05") + line := fmt.Sprintf("[%s] %s\n", now, logLine) + key := taskExecutionLogRedisKey(taskID) + + _, err := db.Redis.TxPipelined(ctx, func(pipe redis.Pipeliner) error { + pipe.RPush(ctx, key, line) + pipe.LTrim(ctx, key, -taskExecutionLogMaxLines, -1) + pipe.Expire(ctx, key, taskExecutionLogExpiration) + return nil + }) + if err != nil { + return fmt.Errorf("append task execution log to redis: %w", err) + } + return nil +} + +func flushTaskExecutionLog(ctx context.Context, taskID string) error { + if db.Redis == nil { + return errors.New("redis client is not initialized") + } + + key := taskExecutionLogRedisKey(taskID) + logLines, err := db.Redis.LRange(ctx, key, 0, -1).Result() + if err != nil { + return fmt.Errorf("get task execution log from redis: %w", err) + } + if len(logLines) == 0 { + return nil + } + logText := strings.Join(logLines, "") + + result := db.DB(ctx).Model(&TaskExecution{}). + Where("task_id = ?", taskID). + Update("log", logText) + if result.Error != nil { + return fmt.Errorf("persist task execution log: %w", result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("persist task execution log: task %q not found", taskID) + } + + if err := db.Redis.Del(ctx, key).Err(); err != nil { + return fmt.Errorf("delete persisted task execution log from redis: %w", err) + } + return nil +} + +func loadTaskExecutionLog(ctx context.Context, execution *TaskExecution) error { + if db.Redis == nil { + return nil + } + + logLines, err := db.Redis.LRange(ctx, taskExecutionLogRedisKey(execution.TaskID), 0, -1).Result() + if err != nil { + return fmt.Errorf("get task execution log from redis: %w", err) + } + if len(logLines) == 0 { + return nil + } + + execution.Log = strings.Join(logLines, "") + return nil +} diff --git a/pkg/task/types.go b/pkg/task/types.go new file mode 100644 index 00000000..703b9149 --- /dev/null +++ b/pkg/task/types.go @@ -0,0 +1,154 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package task + +import ( + "context" + "errors" + "time" + + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "gorm.io/gorm" +) + +// TaskExecutionStatus 任务执行状态 +// +//nolint:revive +type TaskExecutionStatus string + +// Task execution status constants. +const ( + TaskExecutionStatusPending TaskExecutionStatus = "pending" + TaskExecutionStatusRunning TaskExecutionStatus = "running" + TaskExecutionStatusSucceeded TaskExecutionStatus = "succeeded" + TaskExecutionStatusFailed TaskExecutionStatus = "failed" +) + +// TaskExecution 任务执行记录 +// +//nolint:revive +type TaskExecution struct { + ID uint64 `json:"id,string" gorm:"primaryKey"` + TaskID string `json:"task_id" gorm:"size:128;uniqueIndex;not null"` + TaskType string `json:"task_type" gorm:"size:64;index;not null"` + TaskName string `json:"task_name" gorm:"size:128"` + Status TaskExecutionStatus `json:"status" gorm:"size:32;index;not null"` + Retryable bool `json:"retryable" gorm:"not null;default:false"` + MaxRetry int `json:"max_retry" gorm:"not null;default:0"` + RetryCount int `json:"retry_count" gorm:"not null;default:0"` + Log string `json:"log" gorm:"type:text"` + ErrorMessage string `json:"error_message" gorm:"type:text"` + Result string `json:"result" gorm:"type:text"` + StartedAt *time.Time `json:"started_at" gorm:"index"` + FinishedAt *time.Time `json:"finished_at"` + Duration int64 `json:"duration" gorm:"comment:耗时毫秒"` + Payload string `json:"payload" gorm:"type:text"` + TriggeredBy string `json:"triggered_by" gorm:"size:32;not null;default:system"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// TableName 表名 +func (TaskExecution) TableName() string { + return "w_task_executions" +} + +// CreateTaskExecution 创建任务执行记录 +func CreateTaskExecution(ctx context.Context, exec *TaskExecution) error { + return db.DB(ctx).Create(exec).Error +} + +// GetTaskExecutionByTaskID 根据 TaskID 查询执行记录 +func GetTaskExecutionByTaskID(ctx context.Context, taskID string) (*TaskExecution, error) { + var exec TaskExecution + if err := db.DB(ctx).Where("task_id = ?", taskID).First(&exec).Error; err != nil { + return nil, err + } + _ = loadTaskExecutionLog(ctx, &exec) + return &exec, nil +} + +// GetTaskExecutionByID 根据主键 ID 查询执行记录 +func GetTaskExecutionByID(ctx context.Context, id uint64) (*TaskExecution, error) { + var exec TaskExecution + if err := db.DB(ctx).Where("id = ?", id).First(&exec).Error; err != nil { + return nil, err + } + _ = loadTaskExecutionLog(ctx, &exec) + return &exec, nil +} + +// GetLatestTaskExecutionByTaskType 获取指定任务类型的最新执行记录 +func GetLatestTaskExecutionByTaskType(ctx context.Context, taskType string) (*TaskExecution, bool, error) { + var exec TaskExecution + err := db.DB(ctx).Where("task_type = ?", taskType).Order("id DESC").First(&exec).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, nil + } + return nil, false, err + } + return &exec, true, nil +} + +// TaskExecutionCleanupStats describes task execution log cleanup results. +// +//nolint:revive +type TaskExecutionCleanupStats struct { + HighFrequencyDeleted int64 + LowFrequencyDeleted int64 +} + +// CleanupTaskExecutionLogs removes finished task execution logs according to frequency-based retention. +func CleanupTaskExecutionLogs(ctx context.Context, now time.Time) (TaskExecutionCleanupStats, error) { + const ( + frequencyWindowDays = 30 + highFrequencyThreshold = frequencyWindowDays + ) + + frequencyWindowStart := now.AddDate(0, 0, -frequencyWindowDays) + highFrequencyCutoff := now.AddDate(0, 0, -3) + lowFrequencyCutoff := now.AddDate(0, 0, -30) + terminalStatuses := []TaskExecutionStatus{TaskExecutionStatusSucceeded, TaskExecutionStatusFailed} + + var highFrequencyTaskTypes []string + if err := db.DB(ctx). + Model(&TaskExecution{}). + Select("task_type"). + Where("created_at >= ?", frequencyWindowStart). + Group("task_type"). + Having("COUNT(*) > ?", highFrequencyThreshold). + Pluck("task_type", &highFrequencyTaskTypes).Error; err != nil { + return TaskExecutionCleanupStats{}, err + } + + var highFrequencyDeleted int64 + if len(highFrequencyTaskTypes) > 0 { + highFrequencyResult := db.DB(ctx). + Where("status IN ?", terminalStatuses). + Where("created_at < ?", highFrequencyCutoff). + Where("task_type IN ?", highFrequencyTaskTypes). + Delete(&TaskExecution{}) + if highFrequencyResult.Error != nil { + return TaskExecutionCleanupStats{}, highFrequencyResult.Error + } + highFrequencyDeleted = highFrequencyResult.RowsAffected + } + + lowFrequencyQuery := db.DB(ctx). + Where("status IN ?", terminalStatuses). + Where("created_at < ?", lowFrequencyCutoff) + if len(highFrequencyTaskTypes) > 0 { + lowFrequencyQuery = lowFrequencyQuery.Where("task_type NOT IN ?", highFrequencyTaskTypes) + } + lowFrequencyResult := lowFrequencyQuery.Delete(&TaskExecution{}) + if lowFrequencyResult.Error != nil { + return TaskExecutionCleanupStats{}, lowFrequencyResult.Error + } + + return TaskExecutionCleanupStats{ + HighFrequencyDeleted: highFrequencyDeleted, + LowFrequencyDeleted: lowFrequencyResult.RowsAffected, + }, nil +} diff --git a/internal/infra/task/utils.go b/pkg/task/utils.go similarity index 98% rename from internal/infra/task/utils.go rename to pkg/task/utils.go index d5afa098..3c075735 100644 --- a/internal/infra/task/utils.go +++ b/pkg/task/utils.go @@ -5,7 +5,7 @@ package task import ( - "github.com/Rain-kl/Wavelet/internal/infra/config" + "github.com/Rain-kl/Wavelet/pkg/config" "github.com/hibiken/asynq" "github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9/maintnotifications" diff --git a/internal/infra/task/utils_test.go b/pkg/task/utils_test.go similarity index 95% rename from internal/infra/task/utils_test.go rename to pkg/task/utils_test.go index aae01e32..28a091b3 100644 --- a/internal/infra/task/utils_test.go +++ b/pkg/task/utils_test.go @@ -1,9 +1,12 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package task import ( "testing" - "github.com/Rain-kl/Wavelet/internal/infra/config" + "github.com/Rain-kl/Wavelet/pkg/config" "github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9/maintnotifications" ) diff --git a/internal/infra/task/worker/middlewares.go b/pkg/task/worker/middlewares.go similarity index 100% rename from internal/infra/task/worker/middlewares.go rename to pkg/task/worker/middlewares.go diff --git a/internal/infra/task/worker/worker.go b/pkg/task/worker/worker.go similarity index 95% rename from internal/infra/task/worker/worker.go rename to pkg/task/worker/worker.go index 3244e786..920c7f17 100644 --- a/internal/infra/task/worker/worker.go +++ b/pkg/task/worker/worker.go @@ -7,8 +7,8 @@ package worker import ( "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/task" + "github.com/Rain-kl/Wavelet/pkg/config" + "github.com/Rain-kl/Wavelet/pkg/task" "github.com/hibiken/asynq" ) diff --git a/internal/testhelper/cleanup.go b/pkg/testhelper/cleanup.go similarity index 100% rename from internal/testhelper/cleanup.go rename to pkg/testhelper/cleanup.go diff --git a/internal/testhelper/gin.go b/pkg/testhelper/gin.go similarity index 89% rename from internal/testhelper/gin.go rename to pkg/testhelper/gin.go index edb9c9f9..b743fcc5 100644 --- a/internal/testhelper/gin.go +++ b/pkg/testhelper/gin.go @@ -4,7 +4,7 @@ package testhelper import ( - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/pkg/response" "github.com/gin-gonic/gin" ) diff --git a/pkg/testhelper/test_helper.go b/pkg/testhelper/test_helper.go new file mode 100644 index 00000000..b51008d7 --- /dev/null +++ b/pkg/testhelper/test_helper.go @@ -0,0 +1,332 @@ +// Copyright 2025 linux.do +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package testhelper 提供测试辅助工具 +package testhelper + +import ( + "context" + "testing" + "time" + + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/alicebob/miniredis/v2" + "github.com/glebarez/sqlite" + "github.com/redis/go-redis/v9" + "github.com/redis/go-redis/v9/maintnotifications" + "gorm.io/gorm" +) + +// SystemConfig 测试用系统配置表 +type SystemConfig struct { + Key string `gorm:"primaryKey;size:64;not null"` + Value string `gorm:"type:text;not null"` + Type string `gorm:"size:32;not null"` + Visibility string `gorm:"size:32;not null;default:'hidden'"` + Description string `gorm:"size:255"` +} + +// TableName 返回测试配置表表名 +func (SystemConfig) TableName() string { + return "w_system_configs" +} + +type userHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Username string `gorm:"size:64;uniqueIndex;not null"` + Nickname string `gorm:"size:64;not null;default:''"` + Password string `gorm:"size:255;not null;default:''"` + Email string `gorm:"size:128;index;default:''"` + AvatarURL string `gorm:"size:255;default:''"` + IsAdmin bool `gorm:"default:false;not null"` + IsActive bool `gorm:"default:true;not null"` + NeedChangePassword bool `gorm:"default:false;not null"` + Bio string `gorm:"size:500;default:''"` + Phone string `gorm:"size:32;default:''"` + Gender string `gorm:"size:16;default:''"` + Website string `gorm:"size:255;default:''"` + Location string `gorm:"size:255;default:''"` + LastLoginAt time.Time + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (userHelper) TableName() string { return "w_users" } + +type accessTokenHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + UserID uint64 `gorm:"not null;index"` + Name string `gorm:"size:64;not null"` + TokenHash string `gorm:"size:64;uniqueIndex;not null"` + MaskedToken string `gorm:"size:32;not null"` + IsAdmin bool `gorm:"default:false;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (accessTokenHelper) TableName() string { return "w_access_tokens" } + +type authSourceHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Type string `gorm:"size:32;not null"` + Name string `gorm:"size:64;not null"` + Enabled bool `gorm:"default:false;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (authSourceHelper) TableName() string { return "w_auth_sources" } + +type externalAccountHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + UserID uint64 `gorm:"not null;index"` + AuthSourceType string `gorm:"size:32;not null;index"` + ExternalID string `gorm:"size:128;not null;index"` + Username string `gorm:"size:128;default:''"` + Email string `gorm:"size:128;default:''"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (externalAccountHelper) TableName() string { return "w_external_accounts" } + +type uploadHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + UserID uint64 `gorm:"not null;index"` + FileName string `gorm:"size:255;not null"` + FilePath string `gorm:"size:500;not null"` + FileSize int64 `gorm:"not null"` + MimeType string `gorm:"size:128;not null"` + Extension string `gorm:"size:32;not null"` + Hash string `gorm:"size:64;index;not null;default:''"` + Type string `gorm:"size:50;not null;index"` + Status string `gorm:"size:20;not null;default:'pending'"` + AccessMode int `gorm:"not null;default:0"` + Metadata string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime;index"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (uploadHelper) TableName() string { return "w_uploads" } + +type uploadStatHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Dimension string `gorm:"size:32;not null;uniqueIndex:idx_stat_dimension_key"` + StatKey string `gorm:"size:100;not null;uniqueIndex:idx_stat_dimension_key"` + FileCount int64 `gorm:"not null;default:0"` + FileSize int64 `gorm:"not null;default:0"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (uploadStatHelper) TableName() string { return "w_upload_stats" } + +type messageChannelHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Type string `gorm:"size:32;not null"` + Name string `gorm:"size:64;not null"` + OwnerScope string `gorm:"size:32;not null;default:'system'"` + OwnerID *uint64 + Credentials string `gorm:"type:text;not null"` + Extra string `gorm:"type:text"` + Enabled bool `gorm:"default:false;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (messageChannelHelper) TableName() string { return "w_message_channels" } + +type messageBindingHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + ChannelID uint64 `gorm:"not null;index"` + PlatformUserID string `gorm:"size:128;not null;index"` + UserID uint64 `gorm:"not null;index"` + CreatedAt time.Time `gorm:"autoCreateTime"` +} + +func (messageBindingHelper) TableName() string { return "w_message_bindings" } + +type messagePairingHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + Code string `gorm:"size:32;uniqueIndex;not null"` + ChannelID uint64 `gorm:"not null;index"` + PlatformUserID string `gorm:"size:128;not null;index"` + UserID uint64 `gorm:"not null;index"` + ExpiresAt time.Time `gorm:"not null;index"` + CreatedAt time.Time `gorm:"autoCreateTime"` +} + +func (messagePairingHelper) TableName() string { return "w_message_pairing_codes" } + +type taskExecutionHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + TaskID string `gorm:"size:128;uniqueIndex;not null"` + TaskType string `gorm:"size:64;index;not null"` + TaskName string `gorm:"size:128"` + Status string `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 + Payload string `gorm:"type:text"` + TriggeredBy string `gorm:"size:32;not null;default:system"` + CreatedAt time.Time `gorm:"autoCreateTime;index"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (taskExecutionHelper) TableName() string { + return "w_task_executions" +} + +type scheduleHelper struct { + ID uint64 `gorm:"primaryKey;autoIncrement"` + TaskType string `gorm:"size:64;uniqueIndex;not null"` + TaskName string `gorm:"size:128;not null"` + CronExpr string `gorm:"size:64;not null"` + Payload string `gorm:"type:text"` + Enabled bool `gorm:"default:true;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (scheduleHelper) TableName() string { return "w_schedules" } + +const ( + configTypeSystem = "system" + configTypeBusiness = "business" + configValueTrue = "true" + configValueFalse = "false" +) + +// SetupTestEnvironment initializes an in-memory SQLite DB, seeds default configurations, +// starts miniredis, and overrides the global db/Redis clients. It returns a cleanup function. +func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func()) { + sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + DisableForeignKeyConstraintWhenMigrating: true, + }) + if err != nil { + t.Fatalf("failed to open in-memory SQLite db: %v", err) + } + + if sqlDB, err := sqliteDB.DB(); err == nil { + sqlDB.SetMaxOpenConns(1) + } + + // AutoMigrate all tables via internal test helpers to completely decouple testhelper from domain plugins + err = sqliteDB.AutoMigrate( + &userHelper{}, + &accessTokenHelper{}, + &authSourceHelper{}, + &externalAccountHelper{}, + &SystemConfig{}, + &uploadHelper{}, + &uploadStatHelper{}, + &taskExecutionHelper{}, + &scheduleHelper{}, + &messageChannelHelper{}, + &messageBindingHelper{}, + &messagePairingHelper{}, + ) + if err != nil { + t.Fatalf("failed to auto migrate tables: %v", err) + } + + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("failed to start miniredis: %v", err) + } + + redisClient := redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + MaintNotificationsConfig: &maintnotifications.Config{ + Mode: maintnotifications.ModeDisabled, + }, + }) + + db.SetDB(sqliteDB) + db.Redis = redisClient + + seedDefaultConfigs(t, sqliteDB) + + cleanup := func() { + runExtraCleanups() + _ = redisClient.Close() + mr.Close() + db.SetDB(nil) + db.Redis = nil + } + + return sqliteDB, mr, cleanup +} + +func getSeedConfigsPart1() []SystemConfig { + return []SystemConfig{ + {Key: "upload_allowed_extensions", Value: `["jpg", "jpeg", "png", "gif", "webp", "txt", "pdf", "zip"]`, Type: configTypeSystem, Description: "允许上传的文件扩展名列表(JSON 字符串数组)"}, + {Key: "site_name", Value: "Wavelet", Type: configTypeSystem, Description: "站点名称"}, + {Key: "site_description", Value: "Lightweight and Modular Web Application Platform", Type: configTypeSystem, Description: "站点描述"}, + {Key: "password_login_enabled", Value: configValueTrue, Type: configTypeSystem, Description: "是否开启账号密码登录(true/false)"}, + {Key: "registration_enabled", Value: configValueTrue, Type: configTypeSystem, Description: "是否允许新用户注册(全局总开关,true/false)"}, + {Key: "password_register_enabled", Value: configValueTrue, Type: configTypeSystem, Description: "是否允许账号密码注册(true/false)"}, + {Key: "oidc_login_enabled", Value: configValueFalse, Type: configTypeSystem, Description: "是否开启 OIDC 登录(true/false)"}, + {Key: "server_address", Value: "http://localhost:8000", Type: configTypeSystem, Description: "服务端访问地址(用于生成绝对路径链接,多个地址用英文逗号分隔)"}, + {Key: "smtp_host", Value: "", Type: configTypeSystem, Description: "SMTP 服务器主机名或 IP"}, + {Key: "smtp_port", Value: "587", Type: configTypeSystem, Description: "SMTP 服务器端口(标准 STARTTLS 为 587,SMTPS 为 465)"}, + {Key: "smtp_username", Value: "", Type: configTypeSystem, Description: "SMTP 账户(如 sender@example.com)"}, + {Key: "smtp_password", Value: "", Type: configTypeSystem, Description: "SMTP 访问凭证(授权码/密码)"}, + {Key: "email_login_verification_enabled", Value: configValueFalse, Type: configTypeSystem, Description: "是否开启邮箱登录验证(true/false)"}, + {Key: "email_register_verification_enabled", Value: configValueFalse, Type: configTypeSystem, Description: "是否开启邮箱注册验证(true/false)"}, + {Key: "menu_display_config", Value: "{}", Type: configTypeSystem, Description: "目录显示配置(JSON 字符串,格式为 {url: enabled})"}, + {Key: "search_engine_indexing_enabled", Value: configValueFalse, Type: configTypeSystem, Description: "是否允许搜索引擎检索"}, + {Key: "file_access_whitelist", Value: `["avatar"]`, Type: configTypeSystem, Description: "免登录访问的文件业务类型白名单"}, + {Key: "disk_cache_max_size_mb", Value: "100", Type: configTypeSystem, Description: "磁盘缓存最大空间大小 (MB)"}, + {Key: "disk_cache_ttl_minutes", Value: "60", Type: configTypeSystem, Description: "磁盘缓存默认有效期 (分钟)"}, + {Key: "disk_cache_lru_enabled", Value: configValueTrue, Type: configTypeSystem, Description: "是否启用 LRU 淘汰机制"}, + {Key: "login_session_ttl_hours", Value: "0", Type: configTypeSystem, Description: "登录会话过期时间 (小时,0表示浏览器关闭后自动退出,-1表示永不过期)"}, + {Key: "update_upstream_repository", Value: "Rain-kl/Wavelet", Type: configTypeSystem, Description: "GitHub Actions Release 上游仓库"}, + {Key: "storage_config", Value: `{"driver":"local","local":{"root":"."},"s3":{"region":"us-east-1"},"r2":{"region":"auto"},"minio":{"region":"us-east-1","path_style":true},"oss":{},"webdav":{}}`, Type: configTypeSystem, Description: "文件存储驱动及连接配置(JSON)"}, + {Key: "log_database", Value: "sqlite", Type: configTypeSystem, Description: "当前日志主库"}, + {Key: "log_db_migration", Value: "", Type: configTypeSystem, Description: "日志库迁移冻结标记"}, + {Key: "log_retention_days_postgres", Value: "30", Type: configTypeBusiness, Description: "PostgreSQL 用户访问日志保留天数"}, + {Key: "log_retention_days_sqlite", Value: "30", Type: configTypeBusiness, Description: "SQLite 用户访问日志保留天数"}, + {Key: "log_retention_days_clickhouse", Value: "30", Type: configTypeBusiness, Description: "ClickHouse 用户访问日志保留天数"}, + } +} + +func seedDefaultConfigs(t *testing.T, tx *gorm.DB) { + defaultConfigs := getSeedConfigsPart1() + + if err := tx.Create(&defaultConfigs).Error; err != nil { + t.Fatalf("failed to seed default system configs: %v", err) + } + + publicKeys := map[string]struct{}{ + "upload_allowed_extensions": {}, + "site_name": {}, + "password_login_enabled": {}, + "registration_enabled": {}, + "password_register_enabled": {}, + "oidc_login_enabled": {}, + } + + keys := make([]string, 0, len(publicKeys)) + for key := range publicKeys { + keys = append(keys, key) + } + if err := tx.Model(&SystemConfig{}). + Where("key IN ?", keys). + Update("visibility", "visible").Error; err != nil { + t.Fatalf("failed to seed public system config visibility: %v", err) + } + + for _, config := range defaultConfigs { + if _, ok := publicKeys[config.Key]; ok { + config.Visibility = "visible" + } + _ = db.HSetJSON(context.Background(), "system_configs", config.Key, &config) + } +} diff --git a/internal/util/errs.go b/pkg/util/client_errs.go similarity index 100% rename from internal/util/errs.go rename to pkg/util/client_errs.go diff --git a/internal/util/custom_types.go b/pkg/util/custom_types.go similarity index 100% rename from internal/util/custom_types.go rename to pkg/util/custom_types.go diff --git a/internal/util/http_clients.go b/pkg/util/http_clients.go similarity index 100% rename from internal/util/http_clients.go rename to pkg/util/http_clients.go diff --git a/plugins/domain/admin/handlers_auth_source.go b/plugins/domain/admin/handlers_auth_source.go index 723c38cd..e94d37d8 100644 --- a/plugins/domain/admin/handlers_auth_source.go +++ b/plugins/domain/admin/handlers_auth_source.go @@ -7,8 +7,8 @@ import ( "net/http" "strconv" - persistence "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/shared/response" + persistence "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/response" "github.com/Rain-kl/Wavelet/plugins/domain/auth" "github.com/gin-gonic/gin" ) diff --git a/plugins/domain/admin/handlers_cache.go b/plugins/domain/admin/handlers_cache.go index dbb49d7d..5a1ef936 100644 --- a/plugins/domain/admin/handlers_cache.go +++ b/plugins/domain/admin/handlers_cache.go @@ -10,10 +10,8 @@ import ( "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/infra/diskcache" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/pkg/response" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/diskcache" ) type updateCacheConfigRequest struct { @@ -61,17 +59,17 @@ func UpdateCacheConfig(c *gin.Context) { ctx := c.Request.Context() - if err := saveOrUpdateCacheConfig(ctx, model.ConfigKeyDiskCacheMaxSizeMB, strconv.FormatInt(req.MaxSizeMB, 10)); err != nil { + if err := saveOrUpdateCacheConfig(ctx, ConfigKeyDiskCacheMaxSizeMB, strconv.FormatInt(req.MaxSizeMB, 10)); err != nil { response.AbortInternal(c, err.Error()) return } - if err := saveOrUpdateCacheConfig(ctx, model.ConfigKeyDiskCacheTTLMinutes, strconv.FormatInt(req.TTLMinutes, 10)); err != nil { + if err := saveOrUpdateCacheConfig(ctx, ConfigKeyDiskCacheTTLMinutes, strconv.FormatInt(req.TTLMinutes, 10)); err != nil { response.AbortInternal(c, err.Error()) return } - if err := saveOrUpdateCacheConfig(ctx, model.ConfigKeyDiskCacheLRUEnabled, strconv.FormatBool(req.LRUEnabled)); err != nil { + if err := saveOrUpdateCacheConfig(ctx, ConfigKeyDiskCacheLRUEnabled, strconv.FormatBool(req.LRUEnabled)); err != nil { response.AbortInternal(c, err.Error()) return } @@ -101,5 +99,5 @@ func ClearCache(c *gin.Context) { } func saveOrUpdateCacheConfig(ctx context.Context, key, value string) error { - return repository.SaveOrUpdateSystemConfig(ctx, key, value) + return SaveOrUpdateSystemConfig(ctx, key, value) } diff --git a/plugins/domain/admin/handlers_config.go b/plugins/domain/admin/handlers_config.go index 4803273f..13fedc6f 100644 --- a/plugins/domain/admin/handlers_config.go +++ b/plugins/domain/admin/handlers_config.go @@ -13,15 +13,12 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" "github.com/Rain-kl/Wavelet/pkg/logger" mail "github.com/Rain-kl/Wavelet/pkg/mail" + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/response" "github.com/Rain-kl/Wavelet/plugins/domain/cap" - "github.com/Rain-kl/Wavelet/plugins/domain/upload" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore" "github.com/gin-gonic/gin" "gorm.io/gorm" ) @@ -54,7 +51,7 @@ type UpdateSystemConfigRequest struct { // @Router /api/v1/config/public [get] func GetPublicConfig(c *gin.Context) { ctx := c.Request.Context() - configs, err := repository.ListVisibleSystemConfigs(ctx) + configs, err := ListVisibleSystemConfigs(ctx) if err != nil { response.AbortInternal(c, err.Error()) return @@ -77,7 +74,7 @@ func GetPublicConfig(c *gin.Context) { // @Router /robots.txt [get] func GetRobotsTXT(c *gin.Context) { ctx := c.Request.Context() - enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeySearchEngineIndexingEnabled) + enabled, err := GetBoolByKey(ctx, ConfigKeySearchEngineIndexingEnabled) content := "User-Agent: *\nDisallow: /\n" if err == nil && enabled { content = "User-Agent: *\nAllow: /\n" @@ -129,7 +126,7 @@ func CreateSystemConfig(c *gin.Context) { // @Produce json // @Security SessionCookie // @Param type query string false "配置类型(system/business)" -// @Success 200 {object} response.Any{data=[]model.SystemConfig} "系统配置列表" +// @Success 200 {object} response.Any{data=[]SystemConfig} "系统配置列表" // @Failure 401 {object} response.Any "未登录" // @Failure 403 {object} response.Any "无管理员权限" // @Failure 500 {object} response.Any "内部错误" @@ -155,7 +152,7 @@ func ListSystemConfigs(c *gin.Context) { // @Produce json // @Security SessionCookie // @Param key path string true "配置键" -// @Success 200 {object} response.Any{data=model.SystemConfig} "系统配置详情" +// @Success 200 {object} response.Any{data=SystemConfig} "系统配置详情" // @Failure 401 {object} response.Any "未登录" // @Failure 403 {object} response.Any "无管理员权限" // @Failure 404 {object} response.Any "配置不存在" @@ -222,14 +219,14 @@ func UpdateSystemConfig(c *gin.Context) { } func isProtectedConfigKey(key string) bool { - return key == model.ConfigKeyLogDatabase || key == model.ConfigKeyLogDBMigration + return key == ConfigKeyLogDatabase || key == ConfigKeyLogDBMigration } func createSystemConfig(ctx context.Context, req CreateSystemConfigRequest) error { if isProtectedConfigKey(req.Key) { return errors.New(protectedConfigKeyMessage) } - exists, err := repository.SystemConfigExists(ctx, req.Key) + exists, err := SystemConfigExists(ctx, req.Key) if err != nil { return err } @@ -237,43 +234,43 @@ func createSystemConfig(ctx context.Context, req CreateSystemConfigRequest) erro return errors.New(ConfigKeyExists) } - config := model.SystemConfig{ + config := SystemConfig{ Key: req.Key, Value: req.Value, Type: req.Type, Visibility: req.Visibility, Description: req.Description, } - if err := repository.CreateSystemConfig(ctx, &config); err != nil { + if err := CreateSystemConfigRecord(ctx, &config); err != nil { return err } invalidateSystemConfigCaches(ctx, req.Key) - if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil { + if err := InvalidateVisibleSystemConfigsCache(ctx); err != nil { logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err) } return nil } -func listSystemConfigs(ctx context.Context, configType string) ([]model.SystemConfig, error) { - return repository.ListAdminSystemConfigs(ctx, configType) +func listSystemConfigs(ctx context.Context, configType string) ([]SystemConfig, error) { + return ListAdminSystemConfigs(ctx, configType) } -func getSystemConfig(ctx context.Context, key string) (model.SystemConfig, error) { - return repository.GetAdminSystemConfigByKey(ctx, key) +func getSystemConfig(ctx context.Context, key string) (SystemConfig, error) { + return GetAdminSystemConfigByKey(ctx, key) } func updateSystemConfig(ctx context.Context, key string, req UpdateSystemConfigRequest) error { if isProtectedConfigKey(key) { return errors.New(protectedConfigKeyMessage) } - config, err := repository.GetAdminSystemConfigByKey(ctx, key) + config, err := GetAdminSystemConfigByKey(ctx, key) if err != nil { return err } var originalDriver objectstore.Driver - if key == model.ConfigKeyStorageConfig { + if key == ConfigKeyStorageConfig { var currentCfg objectstore.Config if err := json.Unmarshal([]byte(config.Value), ¤tCfg); err == nil { originalDriver = currentCfg.Driver @@ -294,7 +291,7 @@ func updateSystemConfig(ctx context.Context, key string, req UpdateSystemConfigR updates["visibility"] = *req.Visibility config.Visibility = *req.Visibility } - if key != model.ConfigKeySMTPPassword || req.Value != maskedConfigValue { + if key != ConfigKeySMTPPassword || req.Value != maskedConfigValue { updates["value"] = req.Value config.Value = req.Value } @@ -318,7 +315,7 @@ func resolveStorageMigrationTasksOnDirectDriverUpdate( originalDriver objectstore.Driver, newValue string, ) { - if key != model.ConfigKeyStorageConfig || originalDriver == "" { + if key != ConfigKeyStorageConfig || originalDriver == "" { return } @@ -330,7 +327,7 @@ func resolveStorageMigrationTasksOnDirectDriverUpdate( return } - if err := repository.MarkFailedTaskExecutionsSucceededTx( + if err := MarkFailedTaskExecutionsSucceededTx( tx, "storage:migrate", "存储配置直接更新,故障迁移任务自动标记为已解决", @@ -341,7 +338,7 @@ func resolveStorageMigrationTasksOnDirectDriverUpdate( } func invalidateSystemConfigCaches(ctx context.Context, key string) { - if err := repository.InvalidateSystemConfigCache(ctx, key); err != nil { + if err := InvalidateSystemConfigCache(ctx, key); err != nil { logger.WarnF(ctx, "清理系统配置缓存失败: %v", err) } if cap.IsRuntimeConfigKey(key) { @@ -352,18 +349,20 @@ func invalidateSystemConfigCaches(ctx context.Context, key string) { func invalidateCachesAfterConfigUpdate(ctx context.Context, key string) { invalidateSystemConfigCaches(ctx, key) - if key == model.ConfigKeyStorageConfig { - upload.ResetAccessCaches() - upload.PublishAccessCacheInvalidation(ctx) + if key == ConfigKeyStorageConfig { + if db.Redis != nil { + _ = db.Redis.Publish(ctx, "upload:access_cache:invalidate", "reset").Err() + } objectstore.ResetCache() objectstore.PublishCacheInvalidation(ctx) } - if key == model.ConfigKeyFileAccessWhitelist { - upload.ResetAccessCaches() - upload.PublishAccessCacheInvalidation(ctx) + if key == ConfigKeyFileAccessWhitelist { + if db.Redis != nil { + _ = db.Redis.Publish(ctx, "upload:access_cache:invalidate", "reset").Err() + } } - if err := repository.InvalidateVisibleSystemConfigsCache(ctx); err != nil { + if err := InvalidateVisibleSystemConfigsCache(ctx); err != nil { logger.WarnF(ctx, "清理公共配置列表缓存失败: %v", err) } } @@ -404,7 +403,7 @@ func TestSMTP(c *gin.Context) { password := req.SMTPPassword if password == maskedConfigValue { - if sc, err := repository.GetSystemConfigByKey(c.Request.Context(), model.ConfigKeySMTPPassword); err == nil { + if sc, err := GetSystemConfigByKey(c.Request.Context(), ConfigKeySMTPPassword); err == nil { password = sc.Value } } @@ -449,9 +448,9 @@ func maskSensitiveConfig(key, value string) string { return value } switch key { - case model.ConfigKeySMTPPassword: + case ConfigKeySMTPPassword: return maskedConfigValue - case model.ConfigKeyStorageConfig: + case ConfigKeyStorageConfig: var cfg objectstore.Config if err := json.Unmarshal([]byte(value), &cfg); err == nil { masked := objectstore.MaskSecrets(cfg) @@ -494,8 +493,8 @@ func validateAndMergeStorageConfig(ctx context.Context, value string, currentCon func validateMergedStorageConfig(ctx context.Context, currentCfg, newCfg, targetCfg objectstore.Config) error { if newCfg.Driver != "" && newCfg.Driver != currentCfg.Driver { var uploadCount int64 - if err := db.DB(ctx).Model(&model.Upload{}). - Where("status != ?", model.UploadStatusDeleted). + if err := db.DB(ctx).Table("w_uploads"). + Where("status != ?", "deleted"). Count(&uploadCount).Error; err != nil { return fmt.Errorf("检查存量文件失败: %w", err) } diff --git a/plugins/domain/admin/handlers_db.go b/plugins/domain/admin/handlers_db.go index 721100cc..630b9dc7 100644 --- a/plugins/domain/admin/handlers_db.go +++ b/plugins/domain/admin/handlers_db.go @@ -19,9 +19,9 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" - "github.com/Rain-kl/Wavelet/internal/infra/config" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/pkg/config" + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/response" ) const ( diff --git a/plugins/domain/admin/handlers_logs.go b/plugins/domain/admin/handlers_logs.go index c503ee6c..6999cebf 100644 --- a/plugins/domain/admin/handlers_logs.go +++ b/plugins/domain/admin/handlers_logs.go @@ -15,13 +15,12 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/pkg/config" "github.com/Rain-kl/Wavelet/pkg/logger" + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/persistence/logstore" + "github.com/Rain-kl/Wavelet/pkg/response" + "github.com/Rain-kl/Wavelet/pkg/task" "github.com/Rain-kl/Wavelet/pkg/util" "github.com/Rain-kl/Wavelet/plugins/domain/risk_control" "github.com/gin-gonic/gin" @@ -164,8 +163,10 @@ func buildAccessLogFilter(ctx context.Context, c *gin.Context) (logstore.AccessL username := c.Query("username") if username != "" { - userIDs, err := repository.ListUserIDsByUsernameContains(ctx, username) - if err != nil { + var userIDs []uint64 + if err := db.DB(ctx).Table("w_users"). + Where("username LIKE ? ESCAPE '\\'", "%"+util.EscapeLike(username)+"%"). + Pluck("id", &userIDs).Error; err != nil { return filter, fmt.Errorf("查询用户信息失败: %w", err) } filter.UserIDs = userIDs @@ -213,7 +214,12 @@ func enrichAccessLogsWithUsers(ctx context.Context, list []accessLogItem) { } userMap := make(map[uint64]struct{ Username, Nickname string }) - if users, err := repository.ListUsersByIDs(ctx, userIDs); err == nil { + var users []struct { + ID uint64 + Username string + Nickname string + } + if err := db.DB(ctx).Table("w_users").Where("id IN ?", userIDs).Find(&users).Error; err == nil { for _, u := range users { userMap[u.ID] = struct{ Username, Nickname string }{Username: u.Username, Nickname: u.Nickname} } @@ -402,8 +408,12 @@ func GetLogsAnalytics(c *gin.Context) { Username string Nickname string }) - users, errProfile := repository.ListUsersByIDs(ctx, userIDs) - if errProfile == nil { + var users []struct { + ID uint64 + Username string + Nickname string + } + if errProfile := db.DB(ctx).Table("w_users").Where("id IN ?", userIDs).Find(&users).Error; errProfile == nil { for _, u := range users { userProfileMap[u.ID] = struct { Username string @@ -445,7 +455,7 @@ func getUpgrader() *websocket.Upgrader { // 2. 检查配置的允许跨域 Origin (Check allowed origins in system config) ctx := r.Context() - if sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress); err == nil && sc.Value != "" { + if sc, err := GetSystemConfigByKey(ctx, ConfigKeyServerAddress); err == nil && sc.Value != "" { originToCheck := strings.TrimRight(strings.TrimSpace(origin), "/") allowedOrigins := strings.Split(sc.Value, ",") for _, allowed := range allowedOrigins { @@ -632,7 +642,7 @@ func validateSwitch(ctx context.Context, target string) error { } func currentLogDatabase(ctx context.Context) (string, error) { - cfg, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyLogDatabase) + cfg, err := GetSystemConfigByKey(ctx, ConfigKeyLogDatabase) if err != nil { return "", fmt.Errorf("读取日志主库失败: %w", err) } @@ -643,11 +653,11 @@ func currentLogDatabase(ctx context.Context) (string, error) { } func setMigrationFlag(ctx context.Context, v string) error { - return repository.SaveOrUpdateSystemConfig(ctx, model.ConfigKeyLogDBMigration, v) + return SaveOrUpdateSystemConfig(ctx, ConfigKeyLogDBMigration, v) } func flipLogDatabase(ctx context.Context, target string) error { - return repository.SaveOrUpdateSystemConfig(ctx, model.ConfigKeyLogDatabase, target) + return SaveOrUpdateSystemConfig(ctx, ConfigKeyLogDatabase, target) } func copyUserAccessLogs(ctx context.Context, src, dst *logstore.Store) error { diff --git a/plugins/domain/admin/handlers_status.go b/plugins/domain/admin/handlers_status.go index 3aea8a81..7af31a51 100644 --- a/plugins/domain/admin/handlers_status.go +++ b/plugins/domain/admin/handlers_status.go @@ -16,12 +16,10 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/pkg/config" "github.com/Rain-kl/Wavelet/pkg/logger" + "github.com/Rain-kl/Wavelet/pkg/persistence/logstore" + "github.com/Rain-kl/Wavelet/pkg/response" ) var startTime = time.Now() @@ -200,16 +198,16 @@ func GetLogDatabaseStatus(c *gin.Context) { ActiveDatabase: activeDB, Migration: migration, RetentionDays: map[string]int{ - logDBNamePostgres: retentionOr(ctx, model.ConfigKeyLogRetentionDaysPostgres), - logDBNameSQLite: retentionOr(ctx, model.ConfigKeyLogRetentionDaysSQLite), - logDBNameClickHouse: retentionOr(ctx, model.ConfigKeyLogRetentionDaysClickHouse), + logDBNamePostgres: retentionOr(ctx, ConfigKeyLogRetentionDaysPostgres), + logDBNameSQLite: retentionOr(ctx, ConfigKeyLogRetentionDaysSQLite), + logDBNameClickHouse: retentionOr(ctx, ConfigKeyLogRetentionDaysClickHouse), }, AvailableTargets: availableLogTargets(activeDB), })) } func retentionOr(ctx context.Context, key string) int { - v, err := repository.GetIntByKey(ctx, key) + v, err := GetIntByKey(ctx, key) if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { logger.ErrorF(ctx, "读取日志保留天数配置失败 key=%s: %v", key, err) diff --git a/plugins/domain/admin/handlers_tasks.go b/plugins/domain/admin/handlers_tasks.go index b1d9fa7f..b8ef9e7c 100644 --- a/plugins/domain/admin/handlers_tasks.go +++ b/plugins/domain/admin/handlers_tasks.go @@ -14,12 +14,10 @@ import ( "github.com/gin-gonic/gin" "github.com/robfig/cron/v3" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/infra/task/scheduler" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" "github.com/Rain-kl/Wavelet/pkg/logger" + "github.com/Rain-kl/Wavelet/pkg/response" + "github.com/Rain-kl/Wavelet/pkg/task" + "github.com/Rain-kl/Wavelet/pkg/task/scheduler" ) // ListTaskTypes 获取支持的任务类型列表 @@ -107,7 +105,7 @@ func DispatchTask(c *gin.Context) { // @Failure 403 {object} response.Any "无管理员权限" // @Router /api/v1/admin/tasks/executions [get] func ListTaskExecutions(c *gin.Context) { - var req model.ListTaskExecutionsRequest + var req ListTaskExecutionsRequest if err := c.ShouldBindQuery(&req); err != nil { response.AbortBadRequest(c, err.Error()) return @@ -119,7 +117,7 @@ func ListTaskExecutions(c *gin.Context) { } } - executions, total, err := repository.ListTaskExecutions(c.Request.Context(), req) + executions, total, err := ListTaskExecutionRecords(c.Request.Context(), req) if err != nil { response.AbortInternal(c, err.Error()) return @@ -140,7 +138,7 @@ func ListTaskExecutions(c *gin.Context) { // @Produce json // @Security SessionCookie // @Param id path int true "任务执行记录 ID" -// @Success 200 {object} response.Any{data=model.TaskExecution} "任务执行详情" +// @Success 200 {object} response.Any{data=TaskExecution} "任务执行详情" // @Failure 400 {object} response.Any "参数错误" // @Failure 401 {object} response.Any "未登录" // @Failure 403 {object} response.Any "无管理员权限" @@ -153,7 +151,7 @@ func GetTaskExecution(c *gin.Context) { return } - execution, err := repository.GetTaskExecutionByID(c.Request.Context(), id) + execution, err := GetTaskExecutionByID(c.Request.Context(), id) if err != nil { response.AbortNotFound(c, TaskNotFound) return @@ -206,12 +204,12 @@ func RetryTask(c *gin.Context) { // @Tags admin // @Produce json // @Security SessionCookie -// @Success 200 {object} response.Any{data=[]model.Schedule} "定时任务列表" +// @Success 200 {object} response.Any{data=[]Schedule} "定时任务列表" // @Failure 401 {object} response.Any "未登录" // @Failure 403 {object} response.Any "无管理员权限" // @Router /api/v1/admin/tasks/schedules [get] func ListSchedules(c *gin.Context) { - schedules, err := repository.ListSchedules(c.Request.Context()) + schedules, err := ListSchedulesRecord(c.Request.Context()) if err != nil { response.AbortInternal(c, err.Error()) return @@ -236,7 +234,7 @@ type CreateScheduleRequest struct { // @Produce json // @Security SessionCookie // @Param request body CreateScheduleRequest true "创建定时任务请求参数" -// @Success 200 {object} response.Any{data=model.Schedule} "创建成功的定时任务信息" +// @Success 200 {object} response.Any{data=Schedule} "创建成功的定时任务信息" // @Failure 400 {object} response.Any "Cron 表达式无效、异步任务类型不存在或参数错误" // @Failure 401 {object} response.Any "未登录" // @Failure 403 {object} response.Any "无管理员权限" @@ -273,7 +271,7 @@ func CreateSchedule(c *gin.Context) { return } - schedule := &model.Schedule{ + schedule := &Schedule{ Name: req.Name, TaskType: req.TaskType, Cron: req.Cron, @@ -281,7 +279,7 @@ func CreateSchedule(c *gin.Context) { IsActive: *req.IsActive, } - if err := repository.CreateSchedule(c.Request.Context(), schedule); err != nil { + if err := CreateScheduleRecord(c.Request.Context(), schedule); err != nil { response.AbortInternal(c, fmt.Sprintf("%s: %v", ScheduleSaveFailed, err)) return } @@ -312,7 +310,7 @@ type UpdateScheduleRequest struct { // @Security SessionCookie // @Param id path int true "定时任务 ID" // @Param request body UpdateScheduleRequest true "修改定时任务请求参数" -// @Success 200 {object} response.Any{data=model.Schedule} "修改后的定时任务信息" +// @Success 200 {object} response.Any{data=Schedule} "修改后的定时任务信息" // @Failure 400 {object} response.Any "Cron 表达式无效、参数错误" // @Failure 401 {object} response.Any "未登录" // @Failure 403 {object} response.Any "无管理员权限" @@ -332,7 +330,7 @@ func UpdateSchedule(c *gin.Context) { return } - schedule, err := repository.GetScheduleByID(c.Request.Context(), id) + schedule, err := GetScheduleByID(c.Request.Context(), id) if err != nil { response.AbortNotFound(c, ScheduleNotFound) return @@ -368,7 +366,7 @@ func UpdateSchedule(c *gin.Context) { schedule.Payload = string(validated) schedule.IsActive = *req.IsActive - if err := repository.UpdateSchedule(c.Request.Context(), schedule); err != nil { + if err := UpdateScheduleRecord(c.Request.Context(), schedule); err != nil { response.AbortInternal(c, fmt.Sprintf("%s: %v", ScheduleSaveFailed, err)) return } @@ -401,7 +399,7 @@ func DeleteSchedule(c *gin.Context) { return } - if err := repository.DeleteSchedule(c.Request.Context(), id); err != nil { + if err := DeleteScheduleRecord(c.Request.Context(), id); err != nil { response.AbortInternal(c, fmt.Sprintf("%s: %v", ScheduleDeleteFailed, err)) return } diff --git a/plugins/domain/admin/handlers_templates.go b/plugins/domain/admin/handlers_templates.go index 759e6bf9..a4d2c88a 100644 --- a/plugins/domain/admin/handlers_templates.go +++ b/plugins/domain/admin/handlers_templates.go @@ -11,9 +11,7 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/pkg/response" ) // CreateTemplateRequest 创建模板请求 @@ -88,7 +86,7 @@ func CreateTemplate(c *gin.Context) { // @Tags admin // @Produce json // @Security SessionCookie -// @Success 200 {object} response.Any{data=[]model.Template} "模板列表" +// @Success 200 {object} response.Any{data=[]Template} "模板列表" // @Failure 401 {object} response.Any "未登录" // @Failure 403 {object} response.Any "无管理员权限" // @Failure 500 {object} response.Any "内部错误" @@ -110,7 +108,7 @@ func ListTemplates(c *gin.Context) { // @Produce json // @Security SessionCookie // @Param key path string true "模板标识符" -// @Success 200 {object} response.Any{data=model.Template} "模板详情" +// @Success 200 {object} response.Any{data=Template} "模板详情" // @Failure 401 {object} response.Any "未登录" // @Failure 403 {object} response.Any "无管理员权限" // @Failure 404 {object} response.Any "模板不存在" @@ -134,7 +132,7 @@ func GetTemplate(c *gin.Context) { // @Security SessionCookie // @Param key path string true "模板标识符" // @Param request body UpdateTemplateRequest true "更新请求参数" -// @Success 200 {object} response.Any{data=model.Template} "更新成功" +// @Success 200 {object} response.Any{data=Template} "更新成功" // @Failure 400 {object} response.Any "参数错误" // @Failure 401 {object} response.Any "未登录" // @Failure 403 {object} response.Any "无管理员权限" @@ -178,16 +176,16 @@ func DeleteTemplate(c *gin.Context) { c.JSON(http.StatusOK, response.OKNil()) } -func createTemplate(ctx context.Context, req CreateTemplateRequest) (model.Template, error) { - exists, err := repository.TemplateExistsByKey(ctx, req.Key) +func createTemplate(ctx context.Context, req CreateTemplateRequest) (Template, error) { + exists, err := TemplateExistsByKey(ctx, req.Key) if err != nil { - return model.Template{}, err + return Template{}, err } if exists { - return model.Template{}, errors.New(TemplateKeyExists) + return Template{}, errors.New(TemplateKeyExists) } - tmpl := model.Template{ + tmpl := Template{ Key: req.Key, Name: req.Name, Type: req.Type, @@ -197,26 +195,26 @@ func createTemplate(ctx context.Context, req CreateTemplateRequest) (model.Templ IsSystem: false, } if err := tmpl.Validate(); err != nil { - return model.Template{}, err + return Template{}, err } - if err := repository.CreateTemplate(ctx, &tmpl); err != nil { - return model.Template{}, err + if err := CreateTemplateRecord(ctx, &tmpl); err != nil { + return Template{}, err } return tmpl, nil } -func listTemplates(ctx context.Context) ([]model.Template, error) { - return repository.ListTemplates(ctx) +func listTemplates(ctx context.Context) ([]Template, error) { + return ListTemplatesRecord(ctx) } -func getTemplate(ctx context.Context, key string) (model.Template, error) { - return repository.GetTemplateByKey(ctx, key) +func getTemplate(ctx context.Context, key string) (Template, error) { + return GetTemplateByKey(ctx, key) } -func updateTemplate(ctx context.Context, key string, req UpdateTemplateRequest) (model.Template, error) { - tmpl, err := repository.GetTemplateByKey(ctx, key) +func updateTemplate(ctx context.Context, key string, req UpdateTemplateRequest) (Template, error) { + tmpl, err := GetTemplateByKey(ctx, key) if err != nil { - return model.Template{}, err + return Template{}, err } tmpl.Name = req.Name @@ -225,21 +223,21 @@ func updateTemplate(ctx context.Context, key string, req UpdateTemplateRequest) tmpl.Content = req.Content tmpl.Description = req.Description if err := tmpl.Validate(); err != nil { - return model.Template{}, err + return Template{}, err } - if err := repository.SaveTemplate(ctx, &tmpl); err != nil { - return model.Template{}, err + if err := SaveTemplateRecord(ctx, &tmpl); err != nil { + return Template{}, err } return tmpl, nil } func deleteTemplate(ctx context.Context, key string) error { - tmpl, err := repository.GetTemplateByKey(ctx, key) + tmpl, err := GetTemplateByKey(ctx, key) if err != nil { return err } if tmpl.IsSystem { return errors.New(SystemTemplateCannotDelete) } - return repository.DeleteTemplate(ctx, &tmpl) + return DeleteTemplateRecord(ctx, &tmpl) } diff --git a/plugins/domain/admin/handlers_updater.go b/plugins/domain/admin/handlers_updater.go index 5febb1fd..e6aa5f27 100644 --- a/plugins/domain/admin/handlers_updater.go +++ b/plugins/domain/admin/handlers_updater.go @@ -24,11 +24,9 @@ import ( "github.com/gin-gonic/gin" "golang.org/x/mod/semver" - "github.com/Rain-kl/Wavelet/internal/buildinfo" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/pkg/buildinfo" "github.com/Rain-kl/Wavelet/pkg/logger" + "github.com/Rain-kl/Wavelet/pkg/response" "github.com/Rain-kl/Wavelet/pkg/util" ) @@ -282,7 +280,7 @@ func (m *updaterManager) fetchRelease(ctx context.Context, repository string) (g } func loadRepository(ctx context.Context) (string, error) { - config, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyUpdateUpstreamRepository) + config, err := GetSystemConfigByKey(ctx, ConfigKeyUpdateUpstreamRepository) if err != nil { return "", fmt.Errorf("%s: %w", errInvalidRepository, err) } diff --git a/plugins/domain/admin/handlers_user.go b/plugins/domain/admin/handlers_user.go index ded5200a..92896805 100644 --- a/plugins/domain/admin/handlers_user.go +++ b/plugins/domain/admin/handlers_user.go @@ -15,11 +15,12 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/core/contracts" "github.com/Rain-kl/Wavelet/pkg/logger" + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/persistence/idgen" + "github.com/Rain-kl/Wavelet/pkg/response" + "github.com/Rain-kl/Wavelet/pkg/util" "github.com/Rain-kl/Wavelet/plugins/domain/auth" ) @@ -67,7 +68,10 @@ func parseUserID(c *gin.Context) (uint64, bool) { return id, true } -func toUserResponse(u model.User) userResponse { +func toUserResponse(u *contracts.UserDTO) userResponse { + if u == nil { + return userResponse{} + } return userResponse{ ID: u.ID, Username: u.Username, @@ -133,16 +137,16 @@ func ListUsers(c *gin.Context) { return } - total, modelUsers, err := listUsers(c.Request.Context(), req) + total, dtos, err := listUsers(c.Request.Context(), req) if err != nil { logger.ErrorF(c.Request.Context(), "List admin users failed: %v", err) response.AbortInternal(c, "获取用户列表失败") return } - users := make([]userResponse, 0, len(modelUsers)) - for _, modelUser := range modelUsers { - users = append(users, toUserResponse(modelUser)) + users := make([]userResponse, 0, len(dtos)) + for _, dto := range dtos { + users = append(users, toUserResponse(dto)) } c.JSON(http.StatusOK, response.OK(listUsersResponse{ @@ -243,7 +247,7 @@ func DeleteUser(c *gin.Context) { return } - currUser, _ := auth.GetFromContext[*model.User](c, auth.UserObjKey) + currUser, _ := auth.GetFromContext[*contracts.UserDTO](c, auth.UserObjKey) if currUser == nil { response.AbortUnauthorized(c, AdminRequired) return @@ -334,7 +338,7 @@ func UpdateUser(c *gin.Context) { return } - currUser, _ := auth.GetFromContext[*model.User](c, auth.UserObjKey) + currUser, _ := auth.GetFromContext[*contracts.UserDTO](c, auth.UserObjKey) if currUser == nil { response.AbortUnauthorized(c, AdminRequired) return @@ -358,40 +362,70 @@ func UpdateUser(c *gin.Context) { c.JSON(http.StatusOK, response.OKNil()) } -func listUsers(ctx context.Context, req listUsersRequest) (int64, []model.User, error) { - return repository.ListAdminUsers(ctx, repository.AdminUserListFilter{ - UserID: req.UserID, - Username: strings.TrimSpace(req.Username), - Email: strings.TrimSpace(req.Email), - Page: req.Page, - PageSize: req.PageSize, - }) +func listUsers(ctx context.Context, req listUsersRequest) (int64, []*contracts.UserDTO, error) { + query := db.DB(ctx).Table("w_users") + if req.UserID != nil { + query = query.Where("id = ?", *req.UserID) + } + if req.Username != "" { + query = query.Where("username LIKE ? ESCAPE '\\'", util.EscapeLike(req.Username)+"%") + } + if req.Email != "" { + query = query.Where("email LIKE ? ESCAPE '\\'", util.EscapeLike(req.Email)+"%") + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return 0, nil, err + } + + var users []*contracts.UserDTO + offset := (req.Page - 1) * req.PageSize + if err := query. + Select("id, username, nickname, email, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at"). + Order("id ASC"). + Offset(offset). + Limit(req.PageSize). + Find(&users).Error; err != nil { + return 0, nil, err + } + return total, users, nil } -func getUserDetail(ctx context.Context, id uint64) (model.User, error) { - return repository.GetAdminUserDetail(ctx, id) +func getUserDetail(ctx context.Context, id uint64) (*contracts.UserDTO, error) { + var user contracts.UserDTO + if err := db.DB(ctx).Table("w_users"). + Select("id, username, nickname, email, avatar_url, is_active, is_admin, bio, phone, gender, website, location, last_login_at, created_at, updated_at"). + Where("id = ?", id). + First(&user).Error; err != nil { + return nil, err + } + return &user, nil } func updateUserStatus(ctx context.Context, id uint64, active bool) error { - flags, err := repository.GetUserAdminFlags(ctx, id) - if err != nil { + var flags struct { + ID uint64 + IsAdmin bool + } + if err := db.DB(ctx).Table("w_users").Select("id, is_admin").Where("id = ?", id).First(&flags).Error; err != nil { return err } if !active && flags.IsAdmin { return errors.New(cannotDisable) } - var tokens []model.AccessToken + var tokenHashes []string if !active { - tokens, _ = repository.ListAccessTokensByUserID(ctx, id) + _ = db.DB(ctx).Table("w_access_tokens").Where("user_id = ?", id).Pluck("token_hash", &tokenHashes).Error } - err = repository.UpdateUserActive(ctx, id, active) + err := db.DB(ctx).Table("w_users").Where("id = ?", id).Update("is_active", active).Error if err == nil { auth.InvalidateCachedUser(ctx, id) if !active { - for _, token := range tokens { - auth.InvalidateCachedToken(ctx, token.TokenHash) + for _, hash := range tokenHashes { + auth.InvalidateCachedToken(ctx, hash) } } } @@ -402,77 +436,106 @@ func deleteUser(ctx context.Context, currentUserID, targetID uint64) error { if currentUserID == targetID { return errors.New(cannotDeleteSelf) } - flags, err := repository.GetUserAdminFlags(ctx, targetID) - if err != nil { + var flags struct { + ID uint64 + IsAdmin bool + } + if err := db.DB(ctx).Table("w_users").Select("id, is_admin").Where("id = ?", targetID).First(&flags).Error; err != nil { return err } if flags.IsAdmin { return errors.New(cannotDelete) } - tokens, _ := repository.ListAccessTokensByUserID(ctx, targetID) + var tokenHashes []string + _ = db.DB(ctx).Table("w_access_tokens").Where("user_id = ?", targetID).Pluck("token_hash", &tokenHashes).Error - err = repository.DeleteUserWithRelations(ctx, targetID) + err := db.DB(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Table("w_access_tokens").Where("user_id = ?", targetID).Delete(map[string]any{}).Error; err != nil { + return err + } + if err := tx.Table("w_external_accounts").Where("user_id = ?", targetID).Delete(map[string]any{}).Error; err != nil { + return err + } + return tx.Table("w_users").Where("id = ?", targetID).Delete(map[string]any{}).Error + }) if err == nil { auth.InvalidateCachedUser(ctx, targetID) - for _, token := range tokens { - auth.InvalidateCachedToken(ctx, token.TokenHash) + for _, hash := range tokenHashes { + auth.InvalidateCachedToken(ctx, hash) } } return err } -func createUser(ctx context.Context, req createUserRequest) (model.User, error) { +func createUser(ctx context.Context, req createUserRequest) (*contracts.UserDTO, error) { req.Username = strings.TrimSpace(req.Username) req.Nickname = strings.TrimSpace(req.Nickname) req.Password = strings.TrimSpace(req.Password) req.Email = strings.TrimSpace(req.Email) if req.Username == "" { - return model.User{}, errors.New(usernameRequired) + return nil, errors.New(usernameRequired) } if req.Email == "" { - return model.User{}, errors.New(emailRequired) + return nil, errors.New(emailRequired) } if len(req.Password) < minPasswordLength { - return model.User{}, errors.New(passwordTooShort) + return nil, errors.New(passwordTooShort) } - count, err := repository.CountUsersByUsername(ctx, req.Username) - if err != nil { - return model.User{}, err + var count int64 + if err := db.DB(ctx).Table("w_users").Where("username = ?", req.Username).Count(&count).Error; err != nil { + return nil, err } if count > 0 { - return model.User{}, errors.New(usernameExists) + return nil, errors.New(usernameExists) } - emailCount, err := repository.CountUsersByEmail(ctx, req.Email) - if err != nil { - return model.User{}, err + var emailCount int64 + if err := db.DB(ctx).Table("w_users").Where("email = ?", req.Email).Count(&emailCount).Error; err != nil { + return nil, err } if emailCount > 0 { - return model.User{}, errors.New(emailExists) + return nil, errors.New(emailExists) } - newUser := model.User{ - ID: idgen.NextUint64ID(), - Username: req.Username, - Nickname: req.Nickname, - Email: req.Email, - IsActive: req.IsActive, - IsAdmin: req.IsAdmin, - LastLoginAt: time.Time{}, + hash, err := util.HashPassword(req.Password) + if err != nil { + return nil, err } - if newUser.Nickname == "" { - newUser.Nickname = req.Username + + if req.Nickname == "" { + req.Nickname = req.Username } - if err := newUser.SetEncryptedPassword(req.Password); err != nil { - return model.User{}, err + + now := time.Now() + newUser := contracts.UserDTO{ + ID: idgen.NextUint64ID(), + Username: req.Username, + Nickname: req.Nickname, + Email: req.Email, + IsActive: req.IsActive, + IsAdmin: req.IsAdmin, + CreatedAt: now, + UpdatedAt: now, } - if err := repository.CreateUser(ctx, &newUser); err != nil { - return model.User{}, err + + row := map[string]any{ + "id": newUser.ID, + "username": newUser.Username, + "password": hash, + "nickname": newUser.Nickname, + "email": newUser.Email, + "is_active": newUser.IsActive, + "is_admin": newUser.IsAdmin, + "created_at": now, + "updated_at": now, } - return newUser, nil + if err := db.DB(ctx).Table("w_users").Create(row).Error; err != nil { + return nil, err + } + return &newUser, nil } type updateUserParam struct { @@ -492,20 +555,18 @@ func updateUser(ctx context.Context, currentUserID uint64, param updateUserParam return errors.New(emailRequired) } - targetUser, err := repository.GetAdminUserDetail(ctx, param.ID) - if err != nil { + var targetUser contracts.UserDTO + if err := db.DB(ctx).Table("w_users").Where("id = ?", param.ID).First(&targetUser).Error; err != nil { return err } - // 不能撤销当前登录用户的管理员权限 if currentUserID == param.ID && !param.IsAdmin && targetUser.IsAdmin { return errors.New(cannotRevokeSelfAdmin) } - // 如果修改了邮箱,检查邮箱是否被其他用户占用 if targetUser.Email != param.Email { - count, err := repository.CountUsersByEmail(ctx, param.Email) - if err != nil { + var count int64 + if err := db.DB(ctx).Table("w_users").Where("email = ? AND id != ?", param.Email, param.ID).Count(&count).Error; err != nil { return err } if count > 0 { @@ -513,36 +574,40 @@ func updateUser(ctx context.Context, currentUserID uint64, param updateUserParam } } - // 密码强度校验(如果输入了新密码) if param.Password != "" && len(param.Password) < minPasswordLength { return errors.New(passwordTooShort) } needRevokeTokens := (param.Password != "") || (targetUser.IsAdmin && !param.IsAdmin) - var tokens []model.AccessToken + var tokenHashes []string if needRevokeTokens { - tokens, _ = repository.ListAccessTokensByUserID(ctx, param.ID) + _ = db.DB(ctx).Table("w_access_tokens").Where("user_id = ?", param.ID).Pluck("token_hash", &tokenHashes).Error } - targetUser.Nickname = param.Nickname - if targetUser.Nickname == "" { - targetUser.Nickname = targetUser.Username + if param.Nickname == "" { + param.Nickname = targetUser.Username } - targetUser.Email = param.Email - targetUser.IsAdmin = param.IsAdmin + updates := map[string]any{ + "nickname": param.Nickname, + "email": param.Email, + "is_admin": param.IsAdmin, + "updated_at": time.Now(), + } if param.Password != "" { - if err := targetUser.SetEncryptedPassword(param.Password); err != nil { + hash, err := util.HashPassword(param.Password) + if err != nil { return err } + updates["password"] = hash } - err = repository.UpdateUser(ctx, &targetUser) + err := db.DB(ctx).Table("w_users").Where("id = ?", param.ID).Updates(updates).Error if err == nil { auth.InvalidateCachedUser(ctx, param.ID) if needRevokeTokens { - for _, token := range tokens { - auth.InvalidateCachedToken(ctx, token.TokenHash) + for _, hash := range tokenHashes { + auth.InvalidateCachedToken(ctx, hash) } } } diff --git a/plugins/domain/admin/middlewares.go b/plugins/domain/admin/middlewares.go index 98be6759..570ea672 100644 --- a/plugins/domain/admin/middlewares.go +++ b/plugins/domain/admin/middlewares.go @@ -4,9 +4,9 @@ package admin import ( - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/core/contracts" "github.com/Rain-kl/Wavelet/pkg/logger" + "github.com/Rain-kl/Wavelet/pkg/response" otel_trace "github.com/Rain-kl/Wavelet/pkg/trace" "github.com/Rain-kl/Wavelet/plugins/domain/auth" "github.com/gin-gonic/gin" @@ -18,7 +18,7 @@ func LoginAdminRequired() gin.HandlerFunc { ctx, span := otel_trace.Start(c.Request.Context(), "LoginAdminRequired") defer span.End() - user, _ := auth.GetFromContext[*model.User](c, auth.UserObjKey) + user, _ := auth.GetFromContext[*contracts.UserDTO](c, auth.UserObjKey) if user == nil { response.AbortNotFound(c, AdminRequired) return diff --git a/plugins/domain/admin/models.go b/plugins/domain/admin/models.go index 718e7647..6222ae75 100644 --- a/plugins/domain/admin/models.go +++ b/plugins/domain/admin/models.go @@ -92,6 +92,9 @@ func (Template) TableName() string { return "w_templates" } +// TemplateTypeEmail 邮件模板类型 +const TemplateTypeEmail = "email" + // Normalize 规范化模板字段 func (t *Template) Normalize() { t.Key = strings.TrimSpace(t.Key) @@ -101,7 +104,7 @@ func (t *Template) Normalize() { t.Content = strings.TrimSpace(t.Content) t.Description = strings.TrimSpace(t.Description) if t.Type == "" { - t.Type = "email" + t.Type = TemplateTypeEmail } } @@ -201,3 +204,19 @@ type TaskExecution struct { func (TaskExecution) TableName() string { return "w_task_executions" } + +// ListTaskExecutionsRequest 分页查询任务执行记录请求参数 +type ListTaskExecutionsRequest struct { + Page int `form:"page"` + PageSize int `form:"page_size"` + Status string `form:"status"` + TaskType string `form:"task_type"` + TaskTypes string `form:"task_types"` + TaskTypePrefix string `form:"task_type_prefix"` +} + +// TaskExecutionCleanupStats 任务日志清理结果统计 +type TaskExecutionCleanupStats struct { + HighFrequencyDeleted int64 `json:"high_frequency_deleted"` + LowFrequencyDeleted int64 `json:"low_frequency_deleted"` +} diff --git a/plugins/domain/admin/repository.go b/plugins/domain/admin/repository.go new file mode 100644 index 00000000..856e89ff --- /dev/null +++ b/plugins/domain/admin/repository.go @@ -0,0 +1,704 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package admin + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/redis/go-redis/v9" + "github.com/shopspring/decimal" + "gorm.io/gorm" + + "github.com/Rain-kl/Wavelet/pkg/cache/ram" + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/persistence/idgen" + "github.com/Rain-kl/Wavelet/pkg/util" +) + +const ( + configTypeSystem = "system" + errDatabaseNotInitialized = "database not initialized" + errConfigIntParseFailed = "配置 %s 的值 '%s' 无法转换为整数: %w" + errConfigDecimalParseFailed = "配置 %s 的值 '%s' 无法转换为decimal: %w" + errConfigBoolParseFailed = "配置 %s 的值 '%s' 无法转换为布尔值: %w" + errParseMenuDisplayConfigFailed = "解析目录显示配置失败: %w" + + taskExecutionLogRedisKeyPrefix = "task:execution:log:" + taskExecutionLogExpiration = 24 * time.Hour + taskExecutionLogMaxLines = 1000 +) + +// PreheatSystemConfigs loads all system configs from database. +func PreheatSystemConfigs(ctx context.Context) ([]SystemConfig, error) { + database := db.DB(ctx) + if database == nil { + return nil, errors.New(errDatabaseNotInitialized) + } + + var configs []SystemConfig + if err := database.Find(&configs).Error; err != nil { + return nil, err + } + return configs, nil +} + +// PreheatSystemConfigByKey loads a single config key from database. +func PreheatSystemConfigByKey(ctx context.Context, key string) (SystemConfig, error) { + database := db.DB(ctx) + if database == nil { + return SystemConfig{}, errors.New(errDatabaseNotInitialized) + } + + var sc SystemConfig + if err := database.Where("key = ?", key).First(&sc).Error; err != nil { + return SystemConfig{}, err + } + return sc, nil +} + +// GetSystemConfigByGroup queries a configuration by Type and Key. +func GetSystemConfigByGroup(ctx context.Context, configType string, key string) (SystemConfig, error) { + ensureSystemConfigCacheListener() + + if item, ok := ram.Get(configType, key); ok { + var sc SystemConfig + if err := json.Unmarshal([]byte(item.Value), &sc); err == nil { + return sc, nil + } + } + + database := db.DB(ctx) + if database == nil { + return SystemConfig{}, errors.New(errDatabaseNotInitialized) + } + + var sc SystemConfig + if err := database.Where("key = ?", key).First(&sc).Error; err != nil { + return SystemConfig{}, err + } + + valBytes, err := json.Marshal(sc) + if err == nil { + ram.Set(ram.CacheItem{ + Key: sc.Key, + Value: string(valBytes), + Type: configType, + TTL: determineTTL(sc.Key), + }) + } + + return sc, nil +} + +// GetSystemConfigByKey queries config by key. +func GetSystemConfigByKey(ctx context.Context, key string) (SystemConfig, error) { + return GetSystemConfigByGroup(ctx, ConfigCacheType, key) +} + +// ListSystemConfigsByKeys loads multiple config keys. +func ListSystemConfigsByKeys(ctx context.Context, keys []string) (map[string]SystemConfig, error) { + if len(keys) == 0 { + return map[string]SystemConfig{}, nil + } + + ensureSystemConfigCacheListener() + + result := make(map[string]SystemConfig, len(keys)) + missing := make([]string, 0, len(keys)) + + for _, key := range keys { + if item, ok := ram.Get(ConfigCacheType, key); ok { + var sc SystemConfig + if err := json.Unmarshal([]byte(item.Value), &sc); err == nil { + result[key] = sc + continue + } + } + missing = append(missing, key) + } + + if len(missing) == 0 { + return result, nil + } + + database := db.DB(ctx) + if database == nil { + return nil, errors.New(errDatabaseNotInitialized) + } + + var configs []SystemConfig + if err := database.Where("key IN ?", missing).Find(&configs).Error; err != nil { + return nil, err + } + + for i := range configs { + valBytes, err := json.Marshal(configs[i]) + if err == nil { + ram.Set(ram.CacheItem{ + Key: configs[i].Key, + Value: string(valBytes), + Type: ConfigCacheType, + TTL: determineTTL(configs[i].Key), + }) + } + result[configs[i].Key] = configs[i] + } + + return result, nil +} + +// InvalidateVisibleSystemConfigsCache clears the cached public config list. +func InvalidateVisibleSystemConfigsCache(ctx context.Context) error { + return InvalidateAllSystemConfigCaches(ctx) +} + +// ListVisibleSystemConfigs queries visible configs using local cache store. +func ListVisibleSystemConfigs(ctx context.Context) ([]SystemConfig, error) { + ensureSystemConfigCacheListener() + + items := ram.GetTypeItems(ConfigCacheType) + if len(items) > 0 { + var list []SystemConfig + for _, item := range items { + var sc SystemConfig + if err := json.Unmarshal([]byte(item.Value), &sc); err == nil { + if sc.Visibility == ConfigVisibilityVisible { + list = append(list, sc) + } + } + } + return list, nil + } + + database := db.DB(ctx) + if database == nil { + return nil, errors.New(errDatabaseNotInitialized) + } + + var configs []SystemConfig + if err := database.Where("visibility = ?", ConfigVisibilityVisible).Find(&configs).Error; err != nil { + return nil, err + } + + for _, cfg := range configs { + valBytes, err := json.Marshal(cfg) + if err == nil { + ram.Set(ram.CacheItem{ + Key: cfg.Key, + Value: string(valBytes), + Type: ConfigCacheType, + TTL: determineTTL(cfg.Key), + }) + } + } + + return configs, nil +} + +// GetIntByKey queries config and converts to int. +func GetIntByKey(ctx context.Context, key string) (int, error) { + sc, err := GetSystemConfigByKey(ctx, key) + if err != nil { + return 0, err + } + + value, err := strconv.Atoi(sc.Value) + if err != nil { + return 0, fmt.Errorf(errConfigIntParseFailed, key, sc.Value, err) + } + + return value, nil +} + +// GetDecimalByKey queries config and converts to decimal.Decimal. +func GetDecimalByKey(ctx context.Context, key string, precision int32) (decimal.Decimal, error) { + sc, err := GetSystemConfigByKey(ctx, key) + if err != nil { + return decimal.Zero, err + } + + value, err := decimal.NewFromString(sc.Value) + if err != nil { + return decimal.Zero, fmt.Errorf(errConfigDecimalParseFailed, key, sc.Value, err) + } + + return value.Truncate(precision), nil +} + +// GetBoolByKey queries config and converts to bool. +func GetBoolByKey(ctx context.Context, key string) (bool, error) { + sc, err := GetSystemConfigByKey(ctx, key) + if err != nil { + return false, err + } + + value, err := strconv.ParseBool(sc.Value) + if err != nil { + return false, fmt.Errorf(errConfigBoolParseFailed, key, sc.Value, err) + } + + return value, nil +} + +// GetMenuDisplayConfig queries and parses menu config. +func GetMenuDisplayConfig(ctx context.Context) (map[string]bool, error) { + sc, err := GetSystemConfigByKey(ctx, ConfigKeyMenuDisplayConfig) + if err != nil { + return nil, err + } + + config := make(map[string]bool) + if sc.Value == "" || sc.Value == "{}" { + return config, nil + } + + if err := json.Unmarshal([]byte(sc.Value), &config); err != nil { + return nil, fmt.Errorf(errParseMenuDisplayConfigFailed, err) + } + + return config, nil +} + +// ListAdminSystemConfigs returns all configs, optionally filtered by type. +func ListAdminSystemConfigs(ctx context.Context, configType string) ([]SystemConfig, error) { + query := db.DB(ctx).Order("created_at DESC") + if configType != "" { + query = query.Where("type = ?", configType) + } + var configs []SystemConfig + if err := query.Find(&configs).Error; err != nil { + return nil, err + } + return configs, nil +} + +// GetAdminSystemConfigByKey loads a config directly from DB. +func GetAdminSystemConfigByKey(ctx context.Context, key string) (SystemConfig, error) { + var config SystemConfig + if err := db.DB(ctx).Where("key = ?", key).First(&config).Error; err != nil { + return SystemConfig{}, err + } + return config, nil +} + +// SystemConfigExists reports whether a config key already exists. +func SystemConfigExists(ctx context.Context, key string) (bool, error) { + var existing SystemConfig + err := db.DB(ctx).Where("key = ?", key).First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// CreateSystemConfigRecord persists a new system config row. +func CreateSystemConfigRecord(ctx context.Context, config *SystemConfig) error { + return db.DB(ctx).Create(config).Error +} + +// UpdateSystemConfigFields applies partial updates to a system config row. +func UpdateSystemConfigFields(ctx context.Context, config *SystemConfig, updates map[string]any) error { + return db.DB(ctx).Model(config).Updates(updates).Error +} + +// SaveOrUpdateSystemConfig creates or updates a config row and invalidates cache. +func SaveOrUpdateSystemConfig(ctx context.Context, key, value string) error { + var sc SystemConfig + err := db.DB(ctx).Where("key = ?", key).First(&sc).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + if errors.Is(err, gorm.ErrRecordNotFound) { + sc = SystemConfig{ + Key: key, + Value: value, + Type: configTypeSystem, + Visibility: ConfigVisibilityHidden, + } + if err := db.DB(ctx).Create(&sc).Error; err != nil { + return err + } + } else { + sc.Value = value + if err := db.DB(ctx).Save(&sc).Error; err != nil { + return err + } + } + return InvalidateSystemConfigCache(ctx, key) +} + +// ListTemplatesRecord returns all templates ordered by system flag and creation time. +func ListTemplatesRecord(ctx context.Context) ([]Template, error) { + var templates []Template + if err := db.DB(ctx).Order("is_system DESC, created_at DESC").Find(&templates).Error; err != nil { + return nil, err + } + return templates, nil +} + +// GetTemplateByKey loads a template by its key. +func GetTemplateByKey(ctx context.Context, key string) (Template, error) { + var tmpl Template + if err := db.DB(ctx).Where("key = ?", key).First(&tmpl).Error; err != nil { + return Template{}, err + } + return tmpl, nil +} + +// TemplateExistsByKey reports whether a template key is already taken. +func TemplateExistsByKey(ctx context.Context, key string) (bool, error) { + var existing Template + err := db.DB(ctx).Where("key = ?", key).First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// CreateTemplateRecord persists a new template. +func CreateTemplateRecord(ctx context.Context, tmpl *Template) error { + return db.DB(ctx).Create(tmpl).Error +} + +// SaveTemplateRecord updates an existing template. +func SaveTemplateRecord(ctx context.Context, tmpl *Template) error { + return db.DB(ctx).Save(tmpl).Error +} + +// DeleteTemplateRecord removes a template record. +func DeleteTemplateRecord(ctx context.Context, tmpl *Template) error { + return db.DB(ctx).Delete(tmpl).Error +} + +// CreateScheduleRecord 创建定时任务 +func CreateScheduleRecord(ctx context.Context, schedule *Schedule) error { + return db.DB(ctx).Create(schedule).Error +} + +// UpdateScheduleRecord 更新定时任务 +func UpdateScheduleRecord(ctx context.Context, schedule *Schedule) error { + return db.DB(ctx).Save(schedule).Error +} + +// DeleteScheduleRecord 删除定时任务 +func DeleteScheduleRecord(ctx context.Context, id uint64) error { + return db.DB(ctx).Delete(&Schedule{}, id).Error +} + +// GetScheduleByID 根据 ID 获取定时任务 +func GetScheduleByID(ctx context.Context, id uint64) (*Schedule, error) { + var schedule Schedule + if err := db.DB(ctx).Where("id = ?", id).First(&schedule).Error; err != nil { + return nil, err + } + return &schedule, nil +} + +// ListSchedulesRecord 获取所有定时任务 +func ListSchedulesRecord(ctx context.Context) ([]Schedule, error) { + var schedules []Schedule + if err := db.DB(ctx).Order("id DESC").Find(&schedules).Error; err != nil { + return nil, err + } + return schedules, nil +} + +// ListActiveSchedules 获取所有启用的定时任务 +func ListActiveSchedules(ctx context.Context) ([]Schedule, error) { + var schedules []Schedule + if err := db.DB(ctx).Where("is_active = ?", true).Find(&schedules).Error; err != nil { + return nil, err + } + return schedules, nil +} + +// CreateTaskExecutionRecord 创建任务执行记录 +func CreateTaskExecutionRecord(ctx context.Context, execution *TaskExecution) error { + execution.ID = idgen.NextUint64ID() + return db.DB(ctx).Create(execution).Error +} + +// UpdateTaskExecutionRecord 更新任务执行记录,忽略由 Redis 缓冲和归档流程管理的 log 字段。 +func UpdateTaskExecutionRecord(ctx context.Context, execution *TaskExecution) error { + return db.DB(ctx).Omit("log").Save(execution).Error +} + +// GetTaskExecutionByTaskID 根据 TaskID 获取执行记录 +func GetTaskExecutionByTaskID(ctx context.Context, taskID string) (*TaskExecution, error) { + var execution TaskExecution + if err := db.DB(ctx).Where("task_id = ?", taskID).First(&execution).Error; err != nil { + return nil, err + } + if err := loadTaskExecutionLog(ctx, &execution); err != nil { + return nil, err + } + return &execution, nil +} + +// GetTaskExecutionByID 根据 ID 获取执行记录 +func GetTaskExecutionByID(ctx context.Context, id uint64) (*TaskExecution, error) { + var execution TaskExecution + if err := db.DB(ctx).Where("id = ?", id).First(&execution).Error; err != nil { + return nil, err + } + if err := loadTaskExecutionLog(ctx, &execution); err != nil { + return nil, err + } + return &execution, nil +} + +// GetLatestTaskExecutionByTaskType returns the most recent execution for a task type. +func GetLatestTaskExecutionByTaskType(ctx context.Context, taskType string) (*TaskExecution, bool, error) { + var execution TaskExecution + err := db.DB(ctx). + Where("task_type = ?", taskType). + Order("id DESC"). + First(&execution).Error + if err == nil { + if loadErr := loadTaskExecutionLog(ctx, &execution); loadErr != nil { + return nil, false, loadErr + } + return &execution, true, nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, nil + } + return nil, false, err +} + +// AppendTaskExecutionLog 将日志追加到 Redis 缓冲,任务完成后再持久化到数据库。 +func AppendTaskExecutionLog(ctx context.Context, taskID string, logLine string) error { + if db.Redis == nil { + return errors.New("redis client is not initialized") + } + + now := time.Now().Format("15:04:05") + line := fmt.Sprintf("[%s] %s\n", now, logLine) + key := taskExecutionLogRedisKey(taskID) + + _, err := db.Redis.TxPipelined(ctx, func(pipe redis.Pipeliner) error { + pipe.RPush(ctx, key, line) + pipe.LTrim(ctx, key, -taskExecutionLogMaxLines, -1) + pipe.Expire(ctx, key, taskExecutionLogExpiration) + return nil + }) + if err != nil { + return fmt.Errorf("append task execution log to redis: %w", err) + } + return nil +} + +// FlushTaskExecutionLog 将 Redis 中的完整任务日志写入数据库,并在成功后清理缓存。 +func FlushTaskExecutionLog(ctx context.Context, taskID string) error { + if db.Redis == nil { + return errors.New("redis client is not initialized") + } + + key := taskExecutionLogRedisKey(taskID) + logLines, err := db.Redis.LRange(ctx, key, 0, -1).Result() + if err != nil { + return fmt.Errorf("get task execution log from redis: %w", err) + } + if len(logLines) == 0 { + return nil + } + logText := strings.Join(logLines, "") + + result := db.DB(ctx).Model(&TaskExecution{}). + Where("task_id = ?", taskID). + Update("log", logText) + if result.Error != nil { + return fmt.Errorf("persist task execution log: %w", result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("persist task execution log: task %q not found", taskID) + } + + if err := db.Redis.Del(ctx, key).Err(); err != nil { + return fmt.Errorf("delete persisted task execution log from redis: %w", err) + } + return nil +} + +// ListTaskExecutionRecords 分页查询任务执行记录 +func ListTaskExecutionRecords(ctx context.Context, req ListTaskExecutionsRequest) ([]TaskExecution, int64, error) { + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 20 + } + + query := db.DB(ctx).Model(&TaskExecution{}) + + if req.Status != "" { + query = query.Where("status = ?", req.Status) + } + if req.TaskType != "" { + query = query.Where("task_type = ?", req.TaskType) + } else if types := parseTaskTypesFilter(req.TaskTypes); len(types) > 0 { + query = query.Where("task_type IN ?", types) + } else if req.TaskTypePrefix != "" { + query = query.Where("task_type LIKE ? ESCAPE '\\'", util.EscapeLike(req.TaskTypePrefix)+"%") + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var executions []TaskExecution + offset := (req.Page - 1) * req.PageSize + if err := query.Order("id DESC").Offset(offset).Limit(req.PageSize).Find(&executions).Error; err != nil { + return nil, 0, err + } + if err := loadTaskExecutionLogs(ctx, executions); err != nil { + return nil, 0, err + } + + return executions, total, nil +} + +func parseTaskTypesFilter(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +// MarkFailedTaskExecutionsSucceededTx marks failed executions of a task type as succeeded within a transaction. +func MarkFailedTaskExecutionsSucceededTx( + tx *gorm.DB, + taskType string, + result string, + finishedAt time.Time, +) error { + return tx.Model(&TaskExecution{}). + Where("task_type = ? AND status = ?", taskType, TaskExecutionStatusFailed). + Updates(map[string]any{ + "status": TaskExecutionStatusSucceeded, + "result": result, + "finished_at": finishedAt, + }).Error +} + +// CleanupTaskExecutionLogs removes finished task execution logs according to frequency-based retention. +func CleanupTaskExecutionLogs(ctx context.Context, now time.Time) (TaskExecutionCleanupStats, error) { + const ( + frequencyWindowDays = 30 + highFrequencyThreshold = frequencyWindowDays + ) + + frequencyWindowStart := now.AddDate(0, 0, -frequencyWindowDays) + highFrequencyCutoff := now.AddDate(0, 0, -3) + lowFrequencyCutoff := now.AddDate(0, 0, -30) + terminalStatuses := []TaskExecutionStatus{TaskExecutionStatusSucceeded, TaskExecutionStatusFailed} + + var highFrequencyTaskTypes []string + if err := db.DB(ctx). + Model(&TaskExecution{}). + Select("task_type"). + Where("created_at >= ?", frequencyWindowStart). + Group("task_type"). + Having("COUNT(*) > ?", highFrequencyThreshold). + Pluck("task_type", &highFrequencyTaskTypes).Error; err != nil { + return TaskExecutionCleanupStats{}, fmt.Errorf("query high-frequency task types: %w", err) + } + + var highFrequencyDeleted int64 + if len(highFrequencyTaskTypes) > 0 { + highFrequencyResult := db.DB(ctx). + Where("status IN ?", terminalStatuses). + Where("created_at < ?", highFrequencyCutoff). + Where("task_type IN ?", highFrequencyTaskTypes). + Delete(&TaskExecution{}) + if highFrequencyResult.Error != nil { + return TaskExecutionCleanupStats{}, fmt.Errorf("delete high-frequency task execution logs: %w", highFrequencyResult.Error) + } + highFrequencyDeleted = highFrequencyResult.RowsAffected + } + + lowFrequencyQuery := db.DB(ctx). + Where("status IN ?", terminalStatuses). + Where("created_at < ?", lowFrequencyCutoff) + if len(highFrequencyTaskTypes) > 0 { + lowFrequencyQuery = lowFrequencyQuery.Where("task_type NOT IN ?", highFrequencyTaskTypes) + } + lowFrequencyResult := lowFrequencyQuery.Delete(&TaskExecution{}) + if lowFrequencyResult.Error != nil { + return TaskExecutionCleanupStats{}, fmt.Errorf("delete low-frequency task execution logs: %w", lowFrequencyResult.Error) + } + + return TaskExecutionCleanupStats{ + HighFrequencyDeleted: highFrequencyDeleted, + LowFrequencyDeleted: lowFrequencyResult.RowsAffected, + }, nil +} + +func taskExecutionLogRedisKey(taskID string) string { + return db.PrefixedKey(taskExecutionLogRedisKeyPrefix + taskID) +} + +func loadTaskExecutionLog(ctx context.Context, execution *TaskExecution) error { + if db.Redis == nil { + return nil + } + + logLines, err := db.Redis.LRange(ctx, taskExecutionLogRedisKey(execution.TaskID), 0, -1).Result() + if err != nil { + return fmt.Errorf("get task execution log from redis: %w", err) + } + if len(logLines) == 0 { + return nil + } + + execution.Log = strings.Join(logLines, "") + return nil +} + +func loadTaskExecutionLogs(ctx context.Context, executions []TaskExecution) error { + if db.Redis == nil || len(executions) == 0 { + return nil + } + + commands := make([]*redis.StringSliceCmd, len(executions)) + _, err := db.Redis.Pipelined(ctx, func(pipe redis.Pipeliner) error { + for i := range executions { + commands[i] = pipe.LRange(ctx, taskExecutionLogRedisKey(executions[i].TaskID), 0, -1) + } + return nil + }) + if err != nil { + return fmt.Errorf("get task execution logs from redis: %w", err) + } + + for i := range executions { + logLines := commands[i].Val() + if len(logLines) > 0 { + executions[i].Log = strings.Join(logLines, "") + } + } + return nil +} diff --git a/internal/repository/system_config_cache.go b/plugins/domain/admin/system_config_cache.go similarity index 98% rename from internal/repository/system_config_cache.go rename to plugins/domain/admin/system_config_cache.go index be116226..981f3949 100644 --- a/internal/repository/system_config_cache.go +++ b/plugins/domain/admin/system_config_cache.go @@ -1,7 +1,7 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -package repository +package admin import ( "context" @@ -12,8 +12,8 @@ import ( "gorm.io/gorm" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" "github.com/Rain-kl/Wavelet/pkg/cache/ram" + db "github.com/Rain-kl/Wavelet/pkg/persistence" "github.com/Rain-kl/Wavelet/pkg/util" ) diff --git a/internal/repository/system_config_test.go b/plugins/domain/admin/system_config_test.go similarity index 79% rename from internal/repository/system_config_test.go rename to plugins/domain/admin/system_config_test.go index 921f9778..6beadce7 100644 --- a/internal/repository/system_config_test.go +++ b/plugins/domain/admin/system_config_test.go @@ -1,7 +1,7 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -package repository +package admin import ( "context" @@ -14,8 +14,7 @@ import ( "github.com/redis/go-redis/v9/maintnotifications" "gorm.io/gorm" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "github.com/Rain-kl/Wavelet/pkg/persistence" ) func setupSystemConfigTest(t *testing.T) (*gorm.DB, func()) { @@ -27,12 +26,12 @@ func setupSystemConfigTest(t *testing.T) (*gorm.DB, func()) { if err != nil { t.Fatalf("gorm.Open(sqlite) error = %v", err) } - if err := sqliteDB.AutoMigrate(&model.SystemConfig{}); err != nil { + if err := sqliteDB.AutoMigrate(&SystemConfig{}); err != nil { t.Fatalf("AutoMigrate(SystemConfig) error = %v", err) } - siteConfig := model.SystemConfig{ - Key: model.ConfigKeySiteName, + siteConfig := SystemConfig{ + Key: ConfigKeySiteName, Value: "Wavelet", Type: "system", Description: "系统平台的展示名称", @@ -86,7 +85,7 @@ func TestListSystemConfigsByKeys_LoadsFromRAMCache(t *testing.T) { ResetSystemConfigRAMCacheForTest() // Initial load - warm, err := GetSystemConfigByKey(ctx, model.ConfigKeySiteName) + warm, err := GetSystemConfigByKey(ctx, ConfigKeySiteName) if err != nil { t.Fatalf("GetSystemConfigByKey(site_name) warm error = %v", err) } @@ -95,19 +94,19 @@ func TestListSystemConfigsByKeys_LoadsFromRAMCache(t *testing.T) { } // Update DB directly - if err := dbConn.Model(&model.SystemConfig{}). - Where("key = ?", model.ConfigKeySiteName). + if err := dbConn.Model(&SystemConfig{}). + Where("key = ?", ConfigKeySiteName). Update("value", "db_only_value").Error; err != nil { t.Fatalf("Update(site_name) error = %v", err) } // Fetch via ListSystemConfigsByKeys should serve from local store (meaning the old value "Wavelet") - configs, err := ListSystemConfigsByKeys(ctx, []string{model.ConfigKeySiteName}) + configs, err := ListSystemConfigsByKeys(ctx, []string{ConfigKeySiteName}) if err != nil { t.Fatalf("ListSystemConfigsByKeys(site_name) error = %v", err) } - sc, ok := configs[model.ConfigKeySiteName] + sc, ok := configs[ConfigKeySiteName] if !ok { t.Fatal("ListSystemConfigsByKeys(site_name) missing site_name entry") } @@ -124,7 +123,7 @@ func TestGetSystemConfigByGroupAndInvalidation(t *testing.T) { ResetSystemConfigRAMCacheForTest() // Get via specific group/type - cfg, err := GetSystemConfigByGroup(ctx, ConfigCacheType, model.ConfigKeySiteName) + cfg, err := GetSystemConfigByGroup(ctx, ConfigCacheType, ConfigKeySiteName) if err != nil { t.Fatalf("GetSystemConfigByGroup error = %v", err) } @@ -133,14 +132,14 @@ func TestGetSystemConfigByGroupAndInvalidation(t *testing.T) { } // Direct DB update - if err := dbConn.Model(&model.SystemConfig{}). - Where("key = ?", model.ConfigKeySiteName). + if err := dbConn.Model(&SystemConfig{}). + Where("key = ?", ConfigKeySiteName). Update("value", "new_site_name").Error; err != nil { t.Fatalf("DB Update error = %v", err) } // Invalidate - if err := InvalidateSystemConfigCache(ctx, model.ConfigKeySiteName); err != nil { + if err := InvalidateSystemConfigCache(ctx, ConfigKeySiteName); err != nil { t.Fatalf("InvalidateSystemConfigCache error = %v", err) } @@ -148,7 +147,7 @@ func TestGetSystemConfigByGroupAndInvalidation(t *testing.T) { time.Sleep(100 * time.Millisecond) // Fetch again - updated, err := GetSystemConfigByKey(ctx, model.ConfigKeySiteName) + updated, err := GetSystemConfigByKey(ctx, ConfigKeySiteName) if err != nil { t.Fatalf("GetSystemConfigByKey error = %v", err) } diff --git a/plugins/domain/auth/audit.go b/plugins/domain/auth/audit.go index f617cf3a..6e2958f2 100644 --- a/plugins/domain/auth/audit.go +++ b/plugins/domain/auth/audit.go @@ -8,13 +8,13 @@ import ( "context" "encoding/json" - "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/core/contracts" "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/gin-gonic/gin" ) // LogForAudit 将登录鉴权审计日志写入 Logger -func LogForAudit(ctx context.Context, user *model.User, c *gin.Context) { +func LogForAudit(ctx context.Context, user *contracts.UserDTO, c *gin.Context) { if user == nil || c == nil { return } diff --git a/plugins/domain/auth/auth_source_resolver.go b/plugins/domain/auth/auth_source_resolver.go index 642760f4..9a4fcd17 100644 --- a/plugins/domain/auth/auth_source_resolver.go +++ b/plugins/domain/auth/auth_source_resolver.go @@ -7,44 +7,58 @@ import ( "context" "errors" "fmt" + "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/Rain-kl/Wavelet/core/contracts" + + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" ) func isOIDCLoginEnabled(ctx context.Context) bool { - enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyOIDCLoginEnabled) + var val string + if err := db.DB(ctx).Table("w_system_configs").Where("key = ?", "oidc_login_enabled").Pluck("value", &val).Error; err != nil || val == "" { + return true + } + b, err := strconv.ParseBool(val) if err != nil { return true } - return enabled + return b } -func resolveAuthSource(ctx context.Context, sourceName string) (*model.AuthSource, error) { +func resolveAuthSource(ctx context.Context, sourceName string) (*AuthSource, error) { name := strings.TrimSpace(strings.ToLower(sourceName)) if name == "" { - sources, err := repository.GetActiveAuthSourcesCached(ctx) + sources, err := GetActiveAuthSourcesCached(ctx) if err != nil { return nil, err } if len(sources) == 0 { return nil, errors.New(errNoActiveAuthSource) } - return repository.GetAuthSourceByNameCached(ctx, sources[0].Name) + src, err := GetAuthSourceByNameCached(ctx, sources[0].Name) + if err != nil { + return nil, err + } + return src, nil } - return repository.GetAuthSourceByNameCached(ctx, name) + src, err := GetAuthSourceByNameCached(ctx, name) + if err != nil { + return nil, err + } + return src, nil } func activeLoginSources(ctx context.Context) []AuthSourceView { - enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyOIDCLoginEnabled) - if err == nil && !enabled { + if !isOIDCLoginEnabled(ctx) { return nil } - dbSources, err := repository.GetActiveAuthSourcesCached(ctx) + dbSources, err := GetActiveAuthSourcesCached(ctx) if err != nil { return nil } @@ -64,14 +78,14 @@ func activeLoginSources(ctx context.Context) []AuthSourceView { } func getFrontendLoginRedirectURL(ctx context.Context) (string, error) { - sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress) - if err != nil || strings.TrimSpace(sc.Value) == "" { + var val string + if err := db.DB(ctx).Table("w_system_configs").Where("key = ?", "server_address").Pluck("value", &val).Error; err != nil || strings.TrimSpace(val) == "" { return "", errors.New(errServerAddressMissing) } - return strings.TrimRight(sc.Value, "/") + "/login", nil + return strings.TrimRight(val, "/") + "/login", nil } -func buildOAuthConfig(ctx context.Context, source *model.AuthSource, redirectURL string) (*oauth2.Config, *oidc.IDTokenVerifier, error) { +func buildOAuthConfig(ctx context.Context, source *AuthSource, redirectURL string) (*oauth2.Config, *oidc.IDTokenVerifier, error) { if source == nil { return nil, nil, errors.New(errAuthSourceRequired) } @@ -116,37 +130,7 @@ func containsScope(scopes []string, scope string) bool { return false } -func uniqueUsername(ctx context.Context, base string) (string, error) { - base = strings.TrimSpace(base) - if base == "" { - base = "user" - } - - existingUsernames, err := repository.ListUsernamesMatchingBase(ctx, base) - if err != nil { - return "", err - } - - exists := make(map[string]bool, len(existingUsernames)) - for _, u := range existingUsernames { - exists[strings.ToLower(u)] = true - } - - if !exists[strings.ToLower(base)] { - return base, nil - } - - for i := 1; i <= 1000; i++ { - candidate := fmt.Sprintf("%s-%d", base, i) - if !exists[strings.ToLower(candidate)] { - return candidate, nil - } - } - - return "", errors.New(errUsernameGenerateFailed) -} - -func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code string, nonce string, redirectURL string) (*model.OAuthUserInfo, error) { +func buildOAuthUserInfo(ctx context.Context, source *AuthSource, code string, nonce string, redirectURL string) (*contracts.OAuthUserInfoDTO, error) { authConfig, verifier, err := buildOAuthConfig(ctx, source, redirectURL) if err != nil { return nil, err @@ -157,7 +141,7 @@ func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code stri return nil, err } - userInfo := &model.OAuthUserInfo{Active: true} + userInfo := &contracts.OAuthUserInfoDTO{Active: true} if verifier != nil { if verifyErr := verifyIDToken(ctx, verifier, token, nonce, userInfo); verifyErr != nil { return nil, verifyErr @@ -180,7 +164,7 @@ func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code stri return userInfo, nil } -func verifyIDToken(ctx context.Context, verifier *oidc.IDTokenVerifier, token *oauth2.Token, nonce string, userInfo *model.OAuthUserInfo) error { +func verifyIDToken(ctx context.Context, verifier *oidc.IDTokenVerifier, token *oauth2.Token, nonce string, userInfo *contracts.OAuthUserInfoDTO) error { rawIDToken, ok := token.Extra("id_token").(string) if !ok { return nil @@ -198,7 +182,7 @@ func verifyIDToken(ctx context.Context, verifier *oidc.IDTokenVerifier, token *o return nil } -func normalizeOAuthUserInfo(userInfo *model.OAuthUserInfo) error { +func normalizeOAuthUserInfo(userInfo *contracts.OAuthUserInfoDTO) error { userInfo.Username = strings.TrimSpace(userInfo.Username) userInfo.PreferredUsername = strings.TrimSpace(userInfo.PreferredUsername) userInfo.Email = strings.TrimSpace(userInfo.Email) @@ -226,7 +210,7 @@ func normalizeOAuthUserInfo(userInfo *model.OAuthUserInfo) error { return nil } -func buildCallbackResult(user *model.User, status string) OAuthCallbackResult { +func buildCallbackResult(user *contracts.UserDTO, status string) OAuthCallbackResult { result := OAuthCallbackResult{Status: status} if user != nil { info := BuildBasicUserInfo(user, false) diff --git a/plugins/domain/auth/cache.go b/plugins/domain/auth/cache.go index 0780fa3a..f6736d11 100644 --- a/plugins/domain/auth/cache.go +++ b/plugins/domain/auth/cache.go @@ -10,9 +10,9 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/core/contracts" "github.com/Rain-kl/Wavelet/pkg/cache/ram" + db "github.com/Rain-kl/Wavelet/pkg/persistence" "github.com/Rain-kl/Wavelet/pkg/util" ) @@ -25,9 +25,16 @@ const ( oauthUserInvalidationChannel = "oauth:user_invalidation" ) +// CachedToken represents the minimal cached representation of an access token. +type CachedToken struct { + ID uint64 `json:"id"` + UserID uint64 `json:"user_id"` + IsAdmin bool `json:"is_admin"` +} + var ( - tokenRAM = ram.MustNew[string, *model.AccessToken](ram.Options{MaximumSize: 2048}) - userRAM = ram.MustNew[uint64, *model.User](ram.Options{MaximumSize: 2048}) + tokenRAM = ram.MustNew[string, *CachedToken](ram.Options{MaximumSize: 2048}) + userRAM = ram.MustNew[uint64, *contracts.UserDTO](ram.Options{MaximumSize: 2048}) tokenListenerOnce sync.Once tokenListenerCtx context.Context @@ -136,8 +143,8 @@ func publishUserRAMInvalidation(ctx context.Context, userID uint64) { _ = db.Redis.Publish(ctx, oauthUserInvalidationChannel, strconv.FormatUint(userID, 10)).Err() } -// GetCachedToken 获取缓存的 AccessToken -func GetCachedToken(ctx context.Context, tokenHash string) (*model.AccessToken, error) { +// GetCachedToken 获取缓存的 Token +func GetCachedToken(ctx context.Context, tokenHash string) (*CachedToken, error) { ensureTokenCacheListener() if val, ok := tokenRAM.GetIfPresent(tokenHash); ok { @@ -145,7 +152,7 @@ func GetCachedToken(ctx context.Context, tokenHash string) (*model.AccessToken, } if db.Redis != nil { - var token model.AccessToken + var token CachedToken key := tokenCacheKey(tokenHash) if err := db.GetJSON(ctx, key, &token); err == nil { // Write back to local cache @@ -156,8 +163,8 @@ func GetCachedToken(ctx context.Context, tokenHash string) (*model.AccessToken, return nil, fmt.Errorf("cache miss") } -// SetCachedToken 设置 AccessToken 缓存 -func SetCachedToken(ctx context.Context, tokenHash string, token *model.AccessToken) { +// SetCachedToken 设置 Token 缓存 +func SetCachedToken(ctx context.Context, tokenHash string, token *CachedToken) { ensureTokenCacheListener() tokenRAM.Set(tokenHash, token) @@ -179,8 +186,8 @@ func InvalidateCachedToken(ctx context.Context, tokenHash string) { } } -// GetCachedUser 获取缓存的 User -func GetCachedUser(ctx context.Context, userID uint64) (*model.User, error) { +// GetCachedUser 获取缓存的 UserDTO +func GetCachedUser(ctx context.Context, userID uint64) (*contracts.UserDTO, error) { ensureUserCacheListener() if val, ok := userRAM.GetIfPresent(userID); ok { @@ -188,7 +195,7 @@ func GetCachedUser(ctx context.Context, userID uint64) (*model.User, error) { } if db.Redis != nil { - var u model.User + var u contracts.UserDTO key := userCacheKey(userID) if err := db.GetJSON(ctx, key, &u); err == nil { // Write back to local cache @@ -199,8 +206,8 @@ func GetCachedUser(ctx context.Context, userID uint64) (*model.User, error) { return nil, fmt.Errorf("cache miss") } -// SetCachedUser 设置 User 缓存 -func SetCachedUser(ctx context.Context, userID uint64, u *model.User) { +// SetCachedUser 设置 UserDTO 缓存 +func SetCachedUser(ctx context.Context, userID uint64, u *contracts.UserDTO) { ensureUserCacheListener() userRAM.Set(userID, u) @@ -210,7 +217,7 @@ func SetCachedUser(ctx context.Context, userID uint64, u *model.User) { } } -// InvalidateCachedUser 吊销/失效 User 缓存 +// InvalidateCachedUser 吊销/失效 UserDTO 缓存 func InvalidateCachedUser(ctx context.Context, userID uint64) { ensureUserCacheListener() diff --git a/plugins/domain/auth/cache_test.go b/plugins/domain/auth/cache_test.go index 3383868a..e4d67127 100644 --- a/plugins/domain/auth/cache_test.go +++ b/plugins/domain/auth/cache_test.go @@ -11,8 +11,8 @@ import ( "github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9/maintnotifications" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/core/contracts" + db "github.com/Rain-kl/Wavelet/pkg/persistence" "github.com/Rain-kl/Wavelet/plugins/domain/auth" ) @@ -49,11 +49,10 @@ func TestTokenCache_GetSetInvalidate(t *testing.T) { ctx := context.Background() tokenHash := "test-token-hash" - token := &model.AccessToken{ - ID: 123, - UserID: 456, - TokenHash: tokenHash, - Name: "test-token", + token := &auth.CachedToken{ + ID: 123, + UserID: 456, + IsAdmin: true, } // 1. Get from empty cache -> miss @@ -70,7 +69,7 @@ func TestTokenCache_GetSetInvalidate(t *testing.T) { if err != nil { t.Fatalf("GetCachedToken() failed: %v", err) } - if cached.ID != token.ID || cached.UserID != token.UserID { + if cached.ID != token.ID || cached.UserID != token.UserID || cached.IsAdmin != token.IsAdmin { t.Fatalf("expected cached token %+v, got %+v", token, cached) } @@ -90,7 +89,7 @@ func TestUserCache_GetSetInvalidate(t *testing.T) { ctx := context.Background() userID := uint64(789) - user := &model.User{ + user := &contracts.UserDTO{ ID: userID, Username: "testuser", Email: "test@example.com", diff --git a/plugins/domain/auth/handlers.go b/plugins/domain/auth/handlers.go index c96d8b44..8678ab00 100644 --- a/plugins/domain/auth/handlers.go +++ b/plugins/domain/auth/handlers.go @@ -13,13 +13,16 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/listener" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/Rain-kl/Wavelet/pkg/logger" + + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/persistence/idgen" + + "github.com/Rain-kl/Wavelet/pkg/response" + "github.com/Rain-kl/Wavelet/pkg/shared" + "github.com/Rain-kl/Wavelet/pkg/util" "github.com/coreos/go-oidc/v3/oidc" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" @@ -91,7 +94,7 @@ func GetLoginURL(c *gin.Context) { c.JSON(http.StatusOK, response.OK(OAuthAuthorizeResponse{AuthorizeURL: authorizeURL})) } -func buildAuthorizeURL(ctx context.Context, source *model.AuthSource, state string) (string, error) { +func buildAuthorizeURL(ctx context.Context, source *AuthSource, state string) (string, error) { redirectURL, err := getFrontendLoginRedirectURL(ctx) if err != nil { return "", err @@ -282,18 +285,18 @@ func Callback(c *gin.Context) { handleCallbackLogin(ctx, c, source, userInfo) } -func handleCallbackBind(ctx context.Context, c *gin.Context, source *model.AuthSource, userInfo *model.OAuthUserInfo) { +func handleCallbackBind(ctx context.Context, c *gin.Context, source *AuthSource, userInfo *contracts.OAuthUserInfoDTO) { userID := GetUserIDFromContext(c) if userID == 0 { response.AbortUnauthorized(c, shared.UnAuthorized) return } - user, err := repository.GetUserByID(ctx, userID) - if err != nil { + var user contracts.UserDTO + if err := db.DB(ctx).Table("w_users").Where("id = ?", userID).First(&user).Error; err != nil { response.AbortInternal(c, err.Error()) return } - if err := repository.BindExternalAccount(ctx, &model.ExternalAccount{ + if err := BindExternalAccount(ctx, &ExternalAccount{ AuthSourceID: source.ID, UserID: user.ID, ExternalID: userInfo.Sub, @@ -304,22 +307,20 @@ func handleCallbackBind(ctx context.Context, c *gin.Context, source *model.AuthS return } user.LastLoginAt = time.Now() - _ = repository.UpdateUserLastLoginAt(ctx, user.ID, user.LastLoginAt) + _ = db.DB(ctx).Table("w_users").Where("id = ?", user.ID).Update("last_login_at", user.LastLoginAt).Error c.JSON(http.StatusOK, response.OK(buildCallbackResult(&user, "bound"))) } -func handleCallbackLogin(ctx context.Context, c *gin.Context, source *model.AuthSource, userInfo *model.OAuthUserInfo) { - var user model.User +func handleCallbackLogin(ctx context.Context, c *gin.Context, source *AuthSource, userInfo *contracts.OAuthUserInfoDTO) { + var user contracts.UserDTO - account, err := repository.FindExternalAccount(ctx, source.ID, userInfo.Sub) + account, err := FindExternalAccount(ctx, source.ID, userInfo.Sub) switch { case err == nil: - loaded, loadErr := repository.GetUserByID(ctx, account.UserID) - if loadErr != nil { + if loadErr := db.DB(ctx).Table("w_users").Where("id = ?", account.UserID).First(&user).Error; loadErr != nil { response.AbortInternal(c, loadErr.Error()) return } - user = loaded case errors.Is(err, gorm.ErrRecordNotFound): newUser, ok := handleCallbackRegister(ctx, c, source, userInfo) if !ok { @@ -332,7 +333,7 @@ func handleCallbackLogin(ctx context.Context, c *gin.Context, source *model.Auth } user.LastLoginAt = time.Now() - _ = repository.UpdateUserLastLoginAt(ctx, user.ID, user.LastLoginAt) + _ = db.DB(ctx).Table("w_users").Where("id = ?", user.ID).Update("last_login_at", user.LastLoginAt).Error if err := SetLoginSession(ctx, c, &user); err != nil { response.AbortInternal(c, err.Error()) return @@ -340,37 +341,81 @@ func handleCallbackLogin(ctx context.Context, c *gin.Context, source *model.Auth SetCachedUser(ctx, user.ID, &user) - logger.InfoF(ctx, "[LoginAudit] successful OAuth login via source: %s, external ID: %s, user: %s, ID: %d, IP: %s", source.Name, userInfo.Sub, user.Username, user.ID, c.ClientIP()) - - listener.EmitAdminLoggedIn(ctx, &user, c.ClientIP()) - c.JSON(http.StatusOK, response.OK(buildCallbackResult(&user, "logged_in"))) } -func handleCallbackRegister(ctx context.Context, c *gin.Context, source *model.AuthSource, userInfo *model.OAuthUserInfo) (model.User, bool) { - registrationEnabled, regErr := repository.GetBoolByKey(ctx, model.ConfigKeyRegistrationEnabled) - if regErr != nil { - registrationEnabled = false +func uniqueUsername(ctx context.Context, base string) (string, error) { + base = strings.TrimSpace(base) + if base == "" { + base = "user" + } + + var existingUsernames []string + if err := db.DB(ctx).Table("w_users"). + Where("username = ? OR username LIKE ? ESCAPE '\\'", base, util.EscapeLike(base)+"-%"). + Pluck("username", &existingUsernames).Error; err != nil { + return "", err + } + + exists := make(map[string]bool, len(existingUsernames)) + for _, u := range existingUsernames { + exists[strings.ToLower(u)] = true + } + + if !exists[strings.ToLower(base)] { + return base, nil + } + + for i := 1; i <= 1000; i++ { + candidate := fmt.Sprintf("%s-%d", base, i) + if !exists[strings.ToLower(candidate)] { + return candidate, nil + } + } + + return "", errors.New(errUsernameGenerateFailed) +} + +func handleCallbackRegister(ctx context.Context, c *gin.Context, source *AuthSource, userInfo *contracts.OAuthUserInfoDTO) (contracts.UserDTO, bool) { + registrationEnabled := true + var val string + if err := db.DB(ctx).Table("w_system_configs").Where("key = ?", "registration_enabled").Pluck("value", &val).Error; err == nil && val != "" { + if b, err := strconv.ParseBool(val); err == nil { + registrationEnabled = b + } } if !registrationEnabled { c.JSON(http.StatusOK, response.OK(buildCallbackResult(nil, "need_bind"))) - return model.User{}, false + return contracts.UserDTO{}, false } username, uniqueErr := uniqueUsername(ctx, userInfo.Username) if uniqueErr != nil { response.AbortInternal(c, uniqueErr.Error()) - return model.User{}, false + return contracts.UserDTO{}, false } userInfo.Username = username - var user model.User - if err := repository.CreateUserFromOAuth(ctx, &user, userInfo); err != nil { - response.AbortInternal(c, err.Error()) - return model.User{}, false + now := time.Now() + user := contracts.UserDTO{ + ID: idgen.NextUint64ID(), + Username: userInfo.Username, + Nickname: userInfo.Name, + Email: userInfo.Email, + AvatarURL: userInfo.AvatarURL, + IsActive: userInfo.Active, + LastLoginAt: now, + CreatedAt: now, + UpdatedAt: now, } - if err := repository.BindExternalAccount(ctx, &model.ExternalAccount{ + + if err := db.DB(ctx).Table("w_users").Create(&user).Error; err != nil { + response.AbortInternal(c, err.Error()) + return contracts.UserDTO{}, false + } + + if err := BindExternalAccount(ctx, &ExternalAccount{ AuthSourceID: source.ID, UserID: user.ID, ExternalID: userInfo.Sub, @@ -378,7 +423,7 @@ func handleCallbackRegister(ctx context.Context, c *gin.Context, source *model.A Email: userInfo.Email, }); err != nil { response.AbortBadRequest(c, err.Error()) - return model.User{}, false + return contracts.UserDTO{}, false } logger.InfoF(ctx, "[LoginAudit] successful OAuth registration via source: %s, external ID: %s, user: %s, ID: %d, IP: %s", source.Name, userInfo.Sub, user.Username, user.ID, c.ClientIP()) @@ -387,7 +432,7 @@ func handleCallbackRegister(ctx context.Context, c *gin.Context, source *model.A // UserInfo 获取当前登录用户信息 func UserInfo(c *gin.Context) { - user, _ := GetFromContext[*model.User](c, UserObjKey) + user, _ := GetFromContext[*contracts.UserDTO](c, UserObjKey) session := sessions.Default(c) needChange := session.Get("need_change_password") == true @@ -420,7 +465,7 @@ func Logout(c *gin.Context) { // ListExternalAccounts 获取当前用户的外部帐号绑定列表 func ListExternalAccounts(c *gin.Context) { userID := GetUserIDFromContext(c) - accounts, err := repository.ListExternalAccountsByUserID(c.Request.Context(), userID) + accounts, err := ListExternalAccountsByUserID(c.Request.Context(), userID) if err != nil { response.AbortInternal(c, err.Error()) return @@ -441,7 +486,7 @@ func DeleteExternalAccount(c *gin.Context) { response.AbortBadRequest(c, errInvalidExternalAccountBindingID) return } - if err := repository.DeleteExternalAccountForUser(c.Request.Context(), id, userID); err != nil { + if err := UnbindExternalAccount(c.Request.Context(), id, userID); err != nil { response.AbortBadRequest(c, err.Error()) return } diff --git a/plugins/domain/auth/middleware.go b/plugins/domain/auth/middleware.go index 478e7abc..184da390 100644 --- a/plugins/domain/auth/middleware.go +++ b/plugins/domain/auth/middleware.go @@ -6,14 +6,15 @@ package auth import ( "context" + "crypto/sha256" + "encoding/hex" "errors" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/core/contracts" + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/response" + "github.com/Rain-kl/Wavelet/pkg/shared" otel_trace "github.com/Rain-kl/Wavelet/pkg/trace" - "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) @@ -33,32 +34,47 @@ func SetToContext[T any](c *gin.Context, key string, value T) { c.Set(key, value) } -func getUserByToken(ctx context.Context, tokenStr string) (*model.User, *model.AccessToken, error) { - tokenHash := model.HashToken(tokenStr) +func hashToken(token string) string { + h := sha256.New() + h.Write([]byte(token)) + return hex.EncodeToString(h.Sum(nil)) +} + +func getUserByToken(ctx context.Context, tokenStr string) (*contracts.UserDTO, *CachedToken, error) { + tokenHash := hashToken(tokenStr) tokenRecord, err := GetCachedToken(ctx, tokenHash) - if err != nil { - dbToken, err := repository.GetAccessTokenByHash(ctx, tokenHash) - if err != nil { - return nil, nil, err + if err == nil { + user, err := GetCachedUser(ctx, tokenRecord.UserID) + if err == nil && user != nil && user.IsActive { + return user, tokenRecord, nil } - tokenRecord = &dbToken - SetCachedToken(ctx, tokenHash, tokenRecord) } - user, err := GetCachedUser(ctx, tokenRecord.UserID) - if err != nil || !user.IsActive { - dbUser, err := repository.GetActiveUserByID(ctx, tokenRecord.UserID) - if err != nil { - return nil, nil, err - } - user = &dbUser - SetCachedUser(ctx, tokenRecord.UserID, user) + var tokenRow struct { + ID uint64 + UserID uint64 + IsAdmin bool } - return user, tokenRecord, nil + if err := db.DB(ctx).Table("w_access_tokens").Where("token_hash = ?", tokenHash).First(&tokenRow).Error; err != nil { + return nil, nil, err + } + tokenRecord = &CachedToken{ + ID: tokenRow.ID, + UserID: tokenRow.UserID, + IsAdmin: tokenRow.IsAdmin, + } + SetCachedToken(ctx, tokenHash, tokenRecord) + + var userRow contracts.UserDTO + if err := db.DB(ctx).Table("w_users").Where("id = ? AND is_active = ?", tokenRow.UserID, true).First(&userRow).Error; err != nil { + return nil, nil, err + } + SetCachedUser(ctx, userRow.ID, &userRow) + return &userRow, tokenRecord, nil } // GetUserFromRequest 校验 Access Token 或 Session 并返回用户对象,如果未登录或用户失效则返回 error -func GetUserFromRequest(c *gin.Context) (*model.User, error) { +func GetUserFromRequest(c *gin.Context) (*contracts.UserDTO, error) { ctx := c.Request.Context() // Check token in headers @@ -89,24 +105,15 @@ func GetUserFromRequest(c *gin.Context) (*model.User, error) { } user, err := GetCachedUser(ctx, userID) - if err != nil || !user.IsActive { - dbUser, loadErr := repository.GetActiveUserByID(ctx, userID) - if loadErr != nil { - return nil, loadErr + if err != nil || user == nil || !user.IsActive { + var dbUser contracts.UserDTO + if err := db.DB(ctx).Table("w_users").Where("id = ? AND is_active = ?", userID, true).First(&dbUser).Error; err != nil { + return nil, err } user = &dbUser SetCachedUser(ctx, userID, user) } - // 密码哈希校验:当用户存在本地密码时,要求 Session 中的密码哈希必须与当前数据库中一致 - if user.Password != "" { - session := sessions.Default(c) - sessionHash, _ := session.Get(PasswordHashKey).(string) - if sessionHash != user.Password { - return nil, errors.New("session expired due to password change") - } - } - SetToContext(c, TokenAuthKey, false) SetToContext(c, TokenAdminKey, false) diff --git a/plugins/domain/auth/models.go b/plugins/domain/auth/models.go index 2fa98b3d..10cb2e54 100644 --- a/plugins/domain/auth/models.go +++ b/plugins/domain/auth/models.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/core/contracts" ) var authSourceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$`) @@ -162,8 +162,8 @@ type BasicUserInfo struct { Location string `json:"location"` } -// BuildBasicUserInfo 将 User 模型转换为 BasicUserInfo -func BuildBasicUserInfo(user *model.User, needChange bool) BasicUserInfo { +// BuildBasicUserInfo 将 UserDTO 转换为 BasicUserInfo +func BuildBasicUserInfo(user *contracts.UserDTO, needChange bool) BasicUserInfo { if user == nil { return BasicUserInfo{} } diff --git a/plugins/domain/auth/plugin_test.go b/plugins/domain/auth/plugin_test.go index 827e07b3..7f38d9aa 100644 --- a/plugins/domain/auth/plugin_test.go +++ b/plugins/domain/auth/plugin_test.go @@ -5,8 +5,11 @@ package auth_test import ( "context" + "crypto/sha256" + "encoding/hex" "path/filepath" "testing" + "time" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" @@ -15,11 +18,35 @@ import ( "github.com/Rain-kl/Wavelet/core" "github.com/Rain-kl/Wavelet/core/contracts" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "github.com/Rain-kl/Wavelet/pkg/persistence" "github.com/Rain-kl/Wavelet/plugins/domain/auth" ) +type testUser struct { + ID uint64 `gorm:"primaryKey"` + Username string + IsActive bool + LastLoginAt time.Time +} + +func (testUser) TableName() string { return "w_users" } + +type testAccessToken struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 + TokenHash string + Name string + IsAdmin bool +} + +func (testAccessToken) TableName() string { return "w_access_tokens" } + +func hashToken(token string) string { + h := sha256.New() + h.Write([]byte(token)) + return hex.EncodeToString(h.Sum(nil)) +} + func setupTestDB(t *testing.T) *gorm.DB { t.Helper() dbPath := filepath.Join(t.TempDir(), "auth_test.db") @@ -27,10 +54,10 @@ func setupTestDB(t *testing.T) *gorm.DB { require.NoError(t, err) require.NoError(t, testDB.AutoMigrate( - &model.User{}, - &model.AccessToken{}, - &model.AuthSource{}, - &model.ExternalAccount{}, + &testUser{}, + &testAccessToken{}, + &auth.AuthSource{}, + &auth.ExternalAccount{}, )) db.SetDB(testDB) @@ -75,7 +102,7 @@ func TestAuthPluginUnit(t *testing.T) { assert.Equal(t, "custom", prov.Name()) // Test User Token Verification with dummy token - user := model.User{ + user := testUser{ ID: 101, Username: "token_user", IsActive: true, @@ -83,8 +110,8 @@ func TestAuthPluginUnit(t *testing.T) { require.NoError(t, testDB.Create(&user).Error) tokenStr := "test-secret-token-123456" - tokenHash := model.HashToken(tokenStr) - tokenRecord := model.AccessToken{ + tokenHash := hashToken(tokenStr) + tokenRecord := testAccessToken{ ID: 201, UserID: user.ID, TokenHash: tokenHash, @@ -106,7 +133,7 @@ func TestAuthPluginUnit(t *testing.T) { require.NoError(t, authSvc.RevokeUserSessions(context.Background(), user.ID)) // GetCurrentUser from context - userCtx := context.WithValue(context.Background(), "user_obj", userDTO) + userCtx := context.WithValue(context.Background(), auth.UserObjKey, userDTO) current, err := authSvc.GetCurrentUser(userCtx) require.NoError(t, err) assert.Equal(t, user.ID, current.ID) diff --git a/plugins/domain/auth/repository.go b/plugins/domain/auth/repository.go new file mode 100644 index 00000000..74bdd224 --- /dev/null +++ b/plugins/domain/auth/repository.go @@ -0,0 +1,76 @@ +// Copyright 2025 linux.do +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + + db "github.com/Rain-kl/Wavelet/pkg/persistence" +) + +// GetAuthSourceByID 根据 ID 获取认证源 +func GetAuthSourceByID(ctx context.Context, id uint64) (*AuthSource, error) { + var src AuthSource + if err := db.DB(ctx).First(&src, id).Error; err != nil { + return nil, err + } + return &src, nil +} + +// GetAuthSourceByName 根据名称获取认证源 +func GetAuthSourceByName(ctx context.Context, name string) (*AuthSource, error) { + var src AuthSource + if err := db.DB(ctx).Where("name = ?", name).First(&src).Error; err != nil { + return nil, err + } + return &src, nil +} + +// ListActiveAuthSources 获取所有启用的认证源 +func ListActiveAuthSources(ctx context.Context) ([]AuthSource, error) { + var sources []AuthSource + if err := db.DB(ctx).Where("is_active = ?", true).Order("id ASC").Find(&sources).Error; err != nil { + return nil, err + } + return sources, nil +} + +// GetActiveAuthSourcesCached 获取所有启用的认证源(带缓存或直接查询) +func GetActiveAuthSourcesCached(ctx context.Context) ([]AuthSource, error) { + return ListActiveAuthSources(ctx) +} + +// GetAuthSourceByNameCached 根据名称获取认证源(带缓存或直接查询) +func GetAuthSourceByNameCached(ctx context.Context, name string) (*AuthSource, error) { + return GetAuthSourceByName(ctx, name) +} + +// FindExternalAccount 查询指定认证源的外部账号绑定 +func FindExternalAccount(ctx context.Context, authSourceID uint64, externalID string) (*ExternalAccount, error) { + var account ExternalAccount + if err := db.DB(ctx).Where("auth_source_id = ? AND external_id = ?", authSourceID, externalID).First(&account).Error; err != nil { + return nil, err + } + return &account, nil +} + +// BindExternalAccount 绑定外部账号 +func BindExternalAccount(ctx context.Context, account *ExternalAccount) error { + return db.DB(ctx).Create(account).Error +} + +// ListExternalAccountsByUserID 获取用户绑定的所有外部账号 +func ListExternalAccountsByUserID(ctx context.Context, userID uint64) ([]ExternalAccount, error) { + var accounts []ExternalAccount + if err := db.DB(ctx).Where("user_id = ?", userID).Find(&accounts).Error; err != nil { + return nil, err + } + return accounts, nil +} + +// UnbindExternalAccount 解绑外部账号 +func UnbindExternalAccount(ctx context.Context, id uint64, userID uint64) error { + return db.DB(ctx).Where("id = ? AND user_id = ?", id, userID).Delete(&ExternalAccount{}).Error +} diff --git a/plugins/domain/auth/service.go b/plugins/domain/auth/service.go index 8f34acc7..22e7a877 100644 --- a/plugins/domain/auth/service.go +++ b/plugins/domain/auth/service.go @@ -9,34 +9,10 @@ import ( "sync" "github.com/Rain-kl/Wavelet/core/contracts" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + db "github.com/Rain-kl/Wavelet/pkg/persistence" "github.com/gin-gonic/gin" ) -func toUserDTO(u *model.User) *contracts.UserDTO { - if u == nil { - return nil - } - return &contracts.UserDTO{ - ID: u.ID, - Username: u.Username, - Nickname: u.Nickname, - Email: u.Email, - AvatarURL: u.AvatarURL, - IsActive: u.IsActive, - IsAdmin: u.IsAdmin, - Bio: u.Bio, - Phone: u.Phone, - Gender: u.Gender, - Website: u.Website, - Location: u.Location, - LastLoginAt: u.LastLoginAt, - CreatedAt: u.CreatedAt, - UpdatedAt: u.UpdatedAt, - } -} - type authServiceImpl struct{} func newAuthService() contracts.AuthService { @@ -53,15 +29,12 @@ func (s *authServiceImpl) RequireAdminMiddleware() any { func (s *authServiceImpl) GetCurrentUser(ctx context.Context) (*contracts.UserDTO, error) { if ginCtx, ok := ctx.(*gin.Context); ok { - if u, ok := GetFromContext[*model.User](ginCtx, UserObjKey); ok && u != nil { - return toUserDTO(u), nil + if u, ok := GetFromContext[*contracts.UserDTO](ginCtx, UserObjKey); ok && u != nil { + return u, nil } } if v := ctx.Value(UserObjKey); v != nil { - if u, ok := v.(*model.User); ok && u != nil { - return toUserDTO(u), nil - } if u, ok := v.(*contracts.UserDTO); ok && u != nil { return u, nil } @@ -75,21 +48,29 @@ func (s *authServiceImpl) VerifyToken(ctx context.Context, token string) (*contr return nil, errors.New("auth: empty token") } - tokenHash := model.HashToken(token) + tokenHash := hashToken(token) tokenRecord, err := GetCachedToken(ctx, tokenHash) if err != nil { - dbToken, err := repository.GetAccessTokenByHash(ctx, tokenHash) - if err != nil { + var tokenRow struct { + ID uint64 + UserID uint64 + IsAdmin bool + } + if err := db.DB(ctx).Table("w_access_tokens").Where("token_hash = ?", tokenHash).First(&tokenRow).Error; err != nil { return nil, err } - tokenRecord = &dbToken + tokenRecord = &CachedToken{ + ID: tokenRow.ID, + UserID: tokenRow.UserID, + IsAdmin: tokenRow.IsAdmin, + } SetCachedToken(ctx, tokenHash, tokenRecord) } user, err := GetCachedUser(ctx, tokenRecord.UserID) - if err != nil || !user.IsActive { - dbUser, err := repository.GetActiveUserByID(ctx, tokenRecord.UserID) - if err != nil { + if err != nil || user == nil || !user.IsActive { + var dbUser contracts.UserDTO + if err := db.DB(ctx).Table("w_users").Where("id = ? AND is_active = ?", tokenRecord.UserID, true).First(&dbUser).Error; err != nil { return nil, err } user = &dbUser @@ -100,7 +81,7 @@ func (s *authServiceImpl) VerifyToken(ctx context.Context, token string) (*contr return nil, errors.New("auth: system user token not allowed") } - return toUserDTO(user), nil + return user, nil } func (s *authServiceImpl) CreateSession(_ context.Context, _ uint64, _ map[string]any) (string, error) { diff --git a/plugins/domain/auth/session.go b/plugins/domain/auth/session.go index d8fc3b88..f575ae07 100644 --- a/plugins/domain/auth/session.go +++ b/plugins/domain/auth/session.go @@ -8,11 +8,12 @@ import ( "crypto/sha256" "encoding/hex" "net/http" + "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/Rain-kl/Wavelet/pkg/config" + db "github.com/Rain-kl/Wavelet/pkg/persistence" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -66,7 +67,10 @@ func GetUserIDFromSession(s sessions.Session) uint64 { } // GetUserIDFromContext 从 Gin Context 的 Session 中提取用户 ID -func GetUserIDFromContext(c *gin.Context) uint64 { +func GetUserIDFromContext(c *gin.Context) (uid uint64) { + defer func() { + _ = recover() + }() session := sessions.Default(c) return GetUserIDFromSession(session) } @@ -96,14 +100,13 @@ func rotateSessionID(s sessions.Session) { } // SetLoginSession writes the authenticated user into a freshly rotated session. -func SetLoginSession(ctx context.Context, c *gin.Context, user *model.User, extras ...map[string]any) error { +func SetLoginSession(ctx context.Context, c *gin.Context, user *contracts.UserDTO, extras ...map[string]any) error { session := sessions.Default(c) session.Clear() rotateSessionID(session) session.Set(UserIDKey, user.ID) session.Set(UserNameKey, user.Username) - session.Set(PasswordHashKey, user.Password) if len(extras) > 0 { for key, value := range extras[0] { session.Set(key, value) @@ -114,16 +117,18 @@ func SetLoginSession(ctx context.Context, c *gin.Context, user *model.User, extr maxAge := config.Config.App.SessionAge isSessionCookie := false - ttlHours, err := repository.GetIntByKey(ctx, model.ConfigKeyLoginSessionTTLHours) - if err == nil { - switch { - case ttlHours == -1: - // 永不过期,设置为 10 年 - maxAge = 10 * 365 * 24 * 3600 - case ttlHours > 0: - maxAge = ttlHours * 3600 - case ttlHours == 0: - isSessionCookie = true + var val string + if err := db.DB(ctx).Table("w_system_configs").Where("key = ?", "login_session_ttl_hours").Pluck("value", &val).Error; err == nil && val != "" { + if ttlHours, err := strconv.Atoi(val); err == nil { + switch { + case ttlHours == -1: + // 永不过期,设置为 10 年 + maxAge = 10 * 365 * 24 * 3600 + case ttlHours > 0: + maxAge = ttlHours * 3600 + case ttlHours == 0: + isSessionCookie = true + } } } session.Options(GetSessionOptions(maxAge)) diff --git a/plugins/domain/cap/handlers.go b/plugins/domain/cap/handlers.go index 0baa9cb1..63aca708 100644 --- a/plugins/domain/cap/handlers.go +++ b/plugins/domain/cap/handlers.go @@ -6,9 +6,9 @@ package cap import ( "net/http" - "github.com/Rain-kl/Wavelet/internal/shared/response" pkgcap "github.com/Rain-kl/Wavelet/pkg/cap" "github.com/Rain-kl/Wavelet/pkg/logger" + "github.com/Rain-kl/Wavelet/pkg/response" "github.com/gin-gonic/gin" ) diff --git a/plugins/domain/cap/manager.go b/plugins/domain/cap/manager.go index e6e2fb37..1d843744 100644 --- a/plugins/domain/cap/manager.go +++ b/plugins/domain/cap/manager.go @@ -13,9 +13,9 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" pkgcap "github.com/Rain-kl/Wavelet/pkg/cap" + "github.com/Rain-kl/Wavelet/pkg/config" + "github.com/Rain-kl/Wavelet/pkg/persistence" ) const ( diff --git a/plugins/domain/cap/middleware.go b/plugins/domain/cap/middleware.go index f462baf7..b8ca625a 100644 --- a/plugins/domain/cap/middleware.go +++ b/plugins/domain/cap/middleware.go @@ -6,7 +6,7 @@ package cap import ( "github.com/gin-gonic/gin" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/pkg/response" ) // VerifyMiddleware returns a Gin middleware that checks and consumes the X-Cap-Token header. diff --git a/plugins/domain/cap/runtime_settings.go b/plugins/domain/cap/runtime_settings.go index badbc7b2..a594d2f4 100644 --- a/plugins/domain/cap/runtime_settings.go +++ b/plugins/domain/cap/runtime_settings.go @@ -14,9 +14,8 @@ import ( "golang.org/x/sync/singleflight" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/util" ) @@ -38,13 +37,25 @@ type RuntimeSettings struct { TokenTTL time.Duration } +// CAP 动态配置键常量 +const ( + ConfigKeyCapLoginEnabled = "cap_login_enabled" + ConfigKeyCapChallengeCount = "cap_challenge_count" + ConfigKeyCapChallengeSize = "cap_challenge_size" + ConfigKeyCapChallengeDifficulty = "cap_challenge_difficulty" + ConfigKeyCapChallengeTTL = "cap_challenge_ttl" + // ConfigKeyCapTokenTTL 验证码 Token 过期时间键 + // #nosec G101 + ConfigKeyCapTokenTTL = "cap_token_ttl" +) + var runtimeConfigKeys = []string{ - model.ConfigKeyCapLoginEnabled, - model.ConfigKeyCapChallengeCount, - model.ConfigKeyCapChallengeSize, - model.ConfigKeyCapChallengeDifficulty, - model.ConfigKeyCapChallengeTTL, - model.ConfigKeyCapTokenTTL, + ConfigKeyCapLoginEnabled, + ConfigKeyCapChallengeCount, + ConfigKeyCapChallengeSize, + ConfigKeyCapChallengeDifficulty, + ConfigKeyCapChallengeTTL, + ConfigKeyCapTokenTTL, } var runtimeConfigKeySet = func() map[string]struct{} { @@ -132,14 +143,22 @@ func (s *runtimeSettingsStore) current(ctx context.Context) (RuntimeSettings, er } func loadRuntimeSettings(ctx context.Context) (RuntimeSettings, error) { - configs, err := repository.ListSystemConfigsByKeys(ctx, runtimeConfigKeys) - if err != nil { + type configRecord struct { + Key string `gorm:"column:key"` + Value string `gorm:"column:value"` + } + var records []configRecord + if err := db.DB(ctx).Table("w_system_configs").Where("key IN ?", runtimeConfigKeys).Find(&records).Error; err != nil { return RuntimeSettings{}, err } + configs := make(map[string]string, len(records)) + for _, r := range records { + configs[r.Key] = r.Value + } return parseRuntimeSettings(configs), nil } -func parseRuntimeSettings(configs map[string]model.SystemConfig) RuntimeSettings { +func parseRuntimeSettings(configs map[string]string) RuntimeSettings { settings := RuntimeSettings{ ChallengeCount: defaultChallengeCount, ChallengeSize: defaultChallengeSize, @@ -148,33 +167,33 @@ func parseRuntimeSettings(configs map[string]model.SystemConfig) RuntimeSettings TokenTTL: defaultTokenTTL, } - if sc, ok := configs[model.ConfigKeyCapLoginEnabled]; ok { - if enabled, err := strconv.ParseBool(sc.Value); err == nil { + if val, ok := configs[ConfigKeyCapLoginEnabled]; ok { + if enabled, err := strconv.ParseBool(val); err == nil { settings.LoginEnabled = enabled } } - if sc, ok := configs[model.ConfigKeyCapChallengeCount]; ok { - if count, err := strconv.Atoi(sc.Value); err == nil && count > 0 { + if val, ok := configs[ConfigKeyCapChallengeCount]; ok { + if count, err := strconv.Atoi(val); err == nil && count > 0 { settings.ChallengeCount = count } } - if sc, ok := configs[model.ConfigKeyCapChallengeSize]; ok { - if size, err := strconv.Atoi(sc.Value); err == nil && size > 0 { + if val, ok := configs[ConfigKeyCapChallengeSize]; ok { + if size, err := strconv.Atoi(val); err == nil && size > 0 { settings.ChallengeSize = size } } - if sc, ok := configs[model.ConfigKeyCapChallengeDifficulty]; ok { - if difficulty, err := strconv.Atoi(sc.Value); err == nil && difficulty > 0 { - settings.ChallengeDifficulty = difficulty + if val, ok := configs[ConfigKeyCapChallengeDifficulty]; ok { + if diff, err := strconv.Atoi(val); err == nil && diff > 0 { + settings.ChallengeDifficulty = diff } } - if sc, ok := configs[model.ConfigKeyCapChallengeTTL]; ok { - if ttlSeconds, err := strconv.Atoi(sc.Value); err == nil && ttlSeconds > 0 { + if val, ok := configs[ConfigKeyCapChallengeTTL]; ok { + if ttlSeconds, err := strconv.Atoi(val); err == nil && ttlSeconds > 0 { settings.ChallengeTTL = time.Duration(ttlSeconds) * time.Second } } - if sc, ok := configs[model.ConfigKeyCapTokenTTL]; ok { - if ttlSeconds, err := strconv.Atoi(sc.Value); err == nil && ttlSeconds > 0 { + if val, ok := configs[ConfigKeyCapTokenTTL]; ok { + if ttlSeconds, err := strconv.Atoi(val); err == nil && ttlSeconds > 0 { settings.TokenTTL = time.Duration(ttlSeconds) * time.Second } } @@ -186,13 +205,17 @@ func (s *runtimeSettingsStore) ensureInvalidationListener() { s.listenerOnce.Do(startRuntimeSettingsInvalidationListener) } +// SystemConfigInvalidationChannel 系统配置失效广播通道 +const SystemConfigInvalidationChannel = "system_config:invalidation" + func startRuntimeSettingsInvalidationListener() { - if db.Redis == nil { + rdb := db.Redis + if rdb == nil { return } util.Go(func() { - pubsub := db.Redis.Subscribe(context.Background(), repository.SystemConfigInvalidationChannel) + pubsub := rdb.Subscribe(context.Background(), SystemConfigInvalidationChannel) defer func() { _ = pubsub.Close() }() diff --git a/plugins/domain/domain_test.go b/plugins/domain/domain_test.go index 96edfdda..11ca8b7a 100644 --- a/plugins/domain/domain_test.go +++ b/plugins/domain/domain_test.go @@ -18,8 +18,8 @@ import ( "github.com/Rain-kl/Wavelet/core" "github.com/Rain-kl/Wavelet/core/contracts" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + + db "github.com/Rain-kl/Wavelet/pkg/persistence" "github.com/Rain-kl/Wavelet/plugins/domain/admin" "github.com/Rain-kl/Wavelet/plugins/domain/auth" "github.com/Rain-kl/Wavelet/plugins/domain/message_gateway" @@ -38,17 +38,17 @@ func setupTestDB(t *testing.T) *gorm.DB { require.NoError(t, err) require.NoError(t, testDB.AutoMigrate( - &model.User{}, - &model.AccessToken{}, - &model.AuthSource{}, - &model.ExternalAccount{}, - &model.MessageChannel{}, - &model.MessageBinding{}, - &model.MessagePairingCode{}, - &model.SystemConfig{}, - &model.PushChannel{}, - &model.PushEvent{}, - &model.PushHistory{}, + &user.User{}, + &user.AccessToken{}, + &auth.AuthSource{}, + &auth.ExternalAccount{}, + &message_gateway.MessageChannel{}, + &message_gateway.MessageBinding{}, + &message_gateway.MessagePairingCode{}, + &admin.SystemConfig{}, + &message_gateway.PushChannel{}, + &message_gateway.PushEvent{}, + &message_gateway.PushHistory{}, )) db.SetDB(testDB) diff --git a/plugins/domain/message_gateway/admin_handlers.go b/plugins/domain/message_gateway/admin_handlers.go index 3e9ec63e..f8d9b286 100644 --- a/plugins/domain/message_gateway/admin_handlers.go +++ b/plugins/domain/message_gateway/admin_handlers.go @@ -7,7 +7,7 @@ import ( "net/http" "strconv" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/pkg/response" "github.com/gin-gonic/gin" ) @@ -20,7 +20,7 @@ import ( // @Success 200 {object} response.Any{data=[]Definition} // @Router /api/v1/admin/message-gateway/channels/definitions [get] func ListAdminChannelDefinitions(c *gin.Context) { - c.JSON(http.StatusOK, response.OK(channelDefinitions())) + c.JSON(http.StatusOK, response.OK(listDefinitions())) } // ListAdminChannels lists configured messaging channels with secrets masked. diff --git a/plugins/domain/message_gateway/admin_logics.go b/plugins/domain/message_gateway/admin_logics.go index 33b69a20..e533b139 100644 --- a/plugins/domain/message_gateway/admin_logics.go +++ b/plugins/domain/message_gateway/admin_logics.go @@ -13,8 +13,6 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" "github.com/tencent-connect/botgo/token" "gorm.io/gorm" ) @@ -31,68 +29,52 @@ type Field struct { // Definition describes a channel type form. type Definition struct { Type string `json:"type"` - Name string `json:"name"` Fields []Field `json:"fields"` } -// CreateChannelRequest is the admin create body. -type CreateChannelRequest struct { - Name string `json:"name"` - Type string `json:"type"` - Enabled *bool `json:"enabled"` - BotToken string `json:"bot_token"` - AppID string `json:"app_id"` - AppSecret string `json:"app_secret"` - BaseURL string `json:"base_url"` - PortalHost string `json:"portal_host"` - Sandbox string `json:"sandbox"` -} - -// UpdateChannelRequest is the admin patch body. -type UpdateChannelRequest struct { - Name *string `json:"name"` - Enabled *bool `json:"enabled"` - BotToken string `json:"bot_token"` - AppID string `json:"app_id"` - AppSecret string `json:"app_secret"` - BaseURL *string `json:"base_url"` - PortalHost *string `json:"portal_host"` - Sandbox *string `json:"sandbox"` -} - -// ChannelDTO is a list/detail view with secrets masked. +// ChannelDTO represents a channel for admin consumption. type ChannelDTO struct { - ID uint64 `json:"id,string"` - Name string `json:"name"` - Type string `json:"type"` - OwnerScope string `json:"owner_scope"` - Enabled bool `json:"enabled"` - BotToken string `json:"bot_token,omitempty"` - AppID string `json:"app_id,omitempty"` - AppSecret string `json:"app_secret,omitempty"` - BaseURL string `json:"base_url,omitempty"` - PortalHost string `json:"portal_host,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id,string"` + Name string `json:"name"` + Type string `json:"type"` + OwnerScope string `json:"owner_scope"` + OwnerID *uint64 `json:"owner_id,string,omitempty"` + Enabled bool `json:"enabled"` + Credentials map[string]string `json:"credentials"` + Extra map[string]string `json:"extra"` } -func channelDefinitions() []Definition { +// CreateChannelRequest is admin create payload. +type CreateChannelRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Enabled *bool `json:"enabled"` + Credentials map[string]string `json:"credentials"` + Extra map[string]string `json:"extra"` +} + +// UpdateChannelRequest is admin update payload. +type UpdateChannelRequest struct { + Name string `json:"name"` + Enabled *bool `json:"enabled"` + Credentials map[string]string `json:"credentials"` + Extra map[string]string `json:"extra"` +} + +func listDefinitions() []Definition { return []Definition{ { - Type: model.MessageChannelTypeTelegram, - Name: "Telegram", + Type: MessageChannelTypeTelegram, Fields: []Field{ - {Key: "bot_token", Type: "password", Required: true}, - {Key: "base_url", Type: "text"}, + {Key: "token", Type: "password", Required: true}, + {Key: "api_base", Type: "text", Required: false}, }, }, { - Type: model.MessageChannelTypeQQ, - Name: "QQ", + Type: MessageChannelTypeQQ, Fields: []Field{ - {Key: "app_id", Required: true}, - {Key: "app_secret", Type: "password", Required: true}, - {Key: "portal_host", Type: "text"}, + {Key: "app_id", Type: "text", Required: true}, + {Key: "client_secret", Type: "password", Required: true}, }, }, } @@ -103,35 +85,45 @@ func createChannel(ctx context.Context, req CreateChannelRequest) (ChannelDTO, e if name == "" { return ChannelDTO{}, errors.New(errNameRequired) } - typ := strings.TrimSpace(req.Type) - creds, extra, err := credentialsFromCreate(req) - if err != nil { + channelType := strings.TrimSpace(req.Type) + if channelType != MessageChannelTypeTelegram && channelType != MessageChannelTypeQQ { + return ChannelDTO{}, errors.New(errTypeInvalid) + } + creds := req.Credentials + if creds == nil { + creds = map[string]string{} + } + if err := validateCredentials(channelType, creds, false); err != nil { return ChannelDTO{}, err } cipher, err := EncryptCredentials(creds) if err != nil { return ChannelDTO{}, err } + extra := req.Extra + if extra == nil { + extra = map[string]string{} + } enabled := true if req.Enabled != nil { enabled = *req.Enabled } - row := &model.MessageChannel{ + row := &MessageChannel{ Name: name, - Type: typ, - OwnerScope: model.MessageOwnerScopeSystem, + Type: channelType, + OwnerScope: MessageOwnerScopeSystem, Enabled: enabled, Credentials: cipher, Extra: EncodeExtra(extra), } - if err := repository.CreateMessageChannel(ctx, row); err != nil { + if err := CreateMessageChannel(ctx, row); err != nil { return ChannelDTO{}, err } return toDTO(row, creds, extra), nil } func updateChannel(ctx context.Context, id uint64, req UpdateChannelRequest) (ChannelDTO, error) { - row, err := repository.GetMessageChannel(ctx, id) + row, err := GetMessageChannel(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ChannelDTO{}, errors.New(errChannelNotFound) @@ -140,80 +132,74 @@ func updateChannel(ctx context.Context, id uint64, req UpdateChannelRequest) (Ch } creds, err := DecryptCredentials(row.Credentials) if err != nil { - creds = map[string]string{} + return ChannelDTO{}, err } extra := ParseExtra(row.Extra) - if req.Name != nil { - name := strings.TrimSpace(*req.Name) - if name == "" { - return ChannelDTO{}, errors.New(errNameRequired) - } + + if name := strings.TrimSpace(req.Name); name != "" { row.Name = name } if req.Enabled != nil { row.Enabled = *req.Enabled } - if token := strings.TrimSpace(req.BotToken); token != "" { - creds["bot_token"] = token + if req.Extra != nil { + extra = req.Extra } - if appID := strings.TrimSpace(req.AppID); appID != "" { - creds["app_id"] = appID - } - if secret := strings.TrimSpace(req.AppSecret); secret != "" { - creds["app_secret"] = secret - } - if req.BaseURL != nil { - extra["base_url"] = strings.TrimSpace(*req.BaseURL) - } - if req.PortalHost != nil { - extra["portal_host"] = strings.TrimSpace(*req.PortalHost) - } - if req.Sandbox != nil { - extra["sandbox"] = strings.TrimSpace(*req.Sandbox) - } - if err := validateCredentials(row.Type, creds); err != nil { - return ChannelDTO{}, err + if len(req.Credentials) > 0 { + merged := make(map[string]string, len(creds)) + for k, v := range creds { + merged[k] = v + } + for k, v := range req.Credentials { + if strings.TrimSpace(v) == "" { + continue + } + merged[k] = v + } + if err := validateCredentials(row.Type, merged, true); err != nil { + return ChannelDTO{}, err + } + creds = merged } + cipher, err := EncryptCredentials(creds) if err != nil { return ChannelDTO{}, err } row.Credentials = cipher row.Extra = EncodeExtra(extra) - if err := repository.UpdateMessageChannel(ctx, row); err != nil { + if err := UpdateMessageChannel(ctx, row); err != nil { return ChannelDTO{}, err } return toDTO(row, creds, extra), nil } func listChannels(ctx context.Context) ([]ChannelDTO, error) { - rows, err := repository.ListMessageChannels(ctx) + rows, err := ListMessageChannels(ctx) if err != nil { return nil, err } out := make([]ChannelDTO, 0, len(rows)) for i := range rows { - creds, err := DecryptCredentials(rows[i].Credentials) - if err != nil { - creds = map[string]string{} - } - out = append(out, toDTO(&rows[i], creds, ParseExtra(rows[i].Extra))) + creds, _ := DecryptCredentials(rows[i].Credentials) + extra := ParseExtra(rows[i].Extra) + out = append(out, toDTO(&rows[i], creds, extra)) } return out, nil } func deleteChannel(ctx context.Context, id uint64) error { - if _, err := repository.GetMessageChannel(ctx, id); err != nil { + if _, err := GetMessageChannel(ctx, id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New(errChannelNotFound) } return err } - return repository.DeleteMessageChannel(ctx, id) + return DeleteMessageChannel(ctx, id) } func probeChannel(ctx context.Context, id uint64) error { - row, err := repository.GetMessageChannel(ctx, id) + row, err := GetMessageChannel(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New(errChannelNotFound) @@ -224,51 +210,90 @@ func probeChannel(ctx context.Context, id uint64) error { if err != nil { return err } - extra := ParseExtra(row.Extra) - if err := probeCredentials(ctx, row.Type, creds, extra); err != nil { - return fmt.Errorf("%s: %w", errChannelProbeFailed, err) + switch row.Type { + case MessageChannelTypeTelegram: + return probeTelegram(ctx, creds) + case MessageChannelTypeQQ: + return probeQQ(ctx, creds) + default: + return errors.New(errTypeInvalid) + } +} + +func probeTelegram(ctx context.Context, creds map[string]string) error { + tok := creds["token"] + if strings.TrimSpace(tok) == "" { + return errors.New("missing telegram bot token") + } + base := creds["api_base"] + base = strings.TrimRight(strings.TrimSpace(base), "/") + if base == "" { + base = defaultTelegramAPI + } + url := fmt.Sprintf("%s/bot%s/getMe", base, tok) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("telegram getMe failed (%d): %s", resp.StatusCode, string(body)) + } + var res struct { + OK bool `json:"ok"` + } + if err := json.Unmarshal(body, &res); err != nil { + return err + } + if !res.OK { + return fmt.Errorf("telegram returned ok=false: %s", string(body)) } return nil } -func credentialsFromCreate(req CreateChannelRequest) (map[string]string, map[string]string, error) { - typ := strings.TrimSpace(req.Type) - creds := map[string]string{} - extra := map[string]string{} - switch typ { - case model.MessageChannelTypeTelegram: - creds["bot_token"] = strings.TrimSpace(req.BotToken) - if base := strings.TrimSpace(req.BaseURL); base != "" { - extra["base_url"] = base - } - case model.MessageChannelTypeQQ: - creds["app_id"] = strings.TrimSpace(req.AppID) - creds["app_secret"] = strings.TrimSpace(req.AppSecret) - if host := strings.TrimSpace(req.PortalHost); host != "" { - extra["portal_host"] = host - } else { - extra["portal_host"] = "q.qq.com" - } - if sandbox := strings.TrimSpace(req.Sandbox); sandbox != "" { - extra["sandbox"] = sandbox - } - default: - return nil, nil, errors.New(errTypeInvalid) +func probeQQ(_ context.Context, creds map[string]string) error { + appID := strings.TrimSpace(creds["app_id"]) + secret := strings.TrimSpace(creds["app_secret"]) + if appID == "" || secret == "" { + return errors.New("missing qq app_id or app_secret") } - if err := validateCredentials(typ, creds); err != nil { - return nil, nil, err + credentials := &token.QQBotCredentials{ + AppID: appID, + AppSecret: secret, } - return creds, extra, nil + tokSrc := token.NewQQBotTokenSource(credentials) + tok, err := tokSrc.Token() + if err != nil { + return fmt.Errorf("qq token fetch failed: %w", err) + } + if tok == nil || tok.AccessToken == "" { + return errors.New("qq returned empty access token") + } + return nil } -func validateCredentials(typ string, creds map[string]string) error { - switch typ { - case model.MessageChannelTypeTelegram: - if strings.TrimSpace(creds["bot_token"]) == "" { +func validateCredentials(t string, creds map[string]string, isUpdate bool) error { + switch t { + case MessageChannelTypeTelegram: + tok := creds["token"] + if strings.TrimSpace(tok) == "" && !isUpdate { return errors.New(errTelegramTokenRequired) } - case model.MessageChannelTypeQQ: - if strings.TrimSpace(creds["app_id"]) == "" || strings.TrimSpace(creds["app_secret"]) == "" { + if base, ok := creds["api_base"]; ok && strings.TrimSpace(base) != "" { + if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") { + return errors.New("api_base must start with http:// or https://") + } + } + case MessageChannelTypeQQ: + appID := creds["app_id"] + secret := creds["client_secret"] + if (strings.TrimSpace(appID) == "" || strings.TrimSpace(secret) == "") && !isUpdate { return errors.New(errQQCredentialsRequired) } default: @@ -277,70 +302,37 @@ func validateCredentials(typ string, creds map[string]string) error { return nil } -func toDTO(row *model.MessageChannel, creds, extra map[string]string) ChannelDTO { - dto := ChannelDTO{ - ID: row.ID, - Name: row.Name, - Type: row.Type, - OwnerScope: row.OwnerScope, - Enabled: row.Enabled, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, +func toDTO(row *MessageChannel, creds, extra map[string]string) ChannelDTO { + return ChannelDTO{ + ID: row.ID, + Name: row.Name, + Type: row.Type, + OwnerScope: row.OwnerScope, + OwnerID: row.OwnerID, + Enabled: row.Enabled, + Credentials: maskCredentials(row.Type, creds), + Extra: extra, } - if strings.TrimSpace(creds["bot_token"]) != "" { - dto.BotToken = maskedSecret - } - if id := strings.TrimSpace(creds["app_id"]); id != "" { - dto.AppID = id - } - if strings.TrimSpace(creds["app_secret"]) != "" { - dto.AppSecret = maskedSecret - } - dto.BaseURL = extra["base_url"] - dto.PortalHost = extra["portal_host"] - return dto } -func probeCredentials(ctx context.Context, typ string, creds, extra map[string]string) error { - switch typ { - case model.MessageChannelTypeTelegram: - base := strings.TrimSpace(extra["base_url"]) - if base == "" { - base = defaultTelegramAPI +func maskCredentials(_ string, in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for k, v := range in { + if k == "token" || k == "client_secret" { + out[k] = maskSecret(v) + } else { + out[k] = v } - url := strings.TrimRight(base, "/") + "/bot" + creds["bot_token"] + "/getMe" - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return err - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer func() { _ = resp.Body.Close() }() - const probeBodyLimit = 4096 - body, _ := io.ReadAll(io.LimitReader(resp.Body, probeBodyLimit)) - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("telegram getMe status %d", resp.StatusCode) - } - var parsed struct { - OK bool `json:"ok"` - } - if err := json.Unmarshal(body, &parsed); err != nil { - return err - } - if !parsed.OK { - return errors.New("telegram getMe returned ok=false") - } - return nil - case model.MessageChannelTypeQQ: - src := token.NewQQBotTokenSource(&token.QQBotCredentials{ - AppID: creds["app_id"], - AppSecret: creds["app_secret"], - }) - _, err := src.Token() - return err - default: - return errors.New(errTypeInvalid) } + return out +} + +const minMaskSecretLength = 8 + +func maskSecret(s string) string { + s = strings.TrimSpace(s) + if len(s) <= minMaskSecretLength { + return "******" + } + return s[:4] + "..." + s[len(s)-4:] } diff --git a/plugins/domain/message_gateway/custom_events_admin_login.go b/plugins/domain/message_gateway/custom_events_admin_login.go index 7bb244f4..4e09c1f8 100644 --- a/plugins/domain/message_gateway/custom_events_admin_login.go +++ b/plugins/domain/message_gateway/custom_events_admin_login.go @@ -7,7 +7,7 @@ import ( "context" "time" - "github.com/Rain-kl/Wavelet/internal/listener" + "github.com/Rain-kl/Wavelet/core/contracts" ) // AdminLogin is the metadata definition for the admin login event. @@ -22,7 +22,8 @@ var AdminLogin = EventMetadata{ Description: "当管理员成功登录系统时触发此通知", } -func handleAdminLogin(ctx context.Context, event listener.AdminLoggedIn) { +// HandleAdminLoggedIn 处理管理员登录事件并触发通知 +func HandleAdminLoggedIn(ctx context.Context, event contracts.AdminLoggedIn) { if event.User == nil { return } @@ -38,5 +39,4 @@ func handleAdminLogin(ctx context.Context, event listener.AdminLoggedIn) { // RegisterCustomEvents registers default domain push notification events. func RegisterCustomEvents() { RegisterBuiltInEvent(AdminLogin) - listener.OnAdminLoggedIn(handleAdminLogin) } diff --git a/plugins/domain/message_gateway/handlers.go b/plugins/domain/message_gateway/handlers.go index 346cba17..7fce9f89 100644 --- a/plugins/domain/message_gateway/handlers.go +++ b/plugins/domain/message_gateway/handlers.go @@ -8,14 +8,14 @@ import ( "net/http" "strconv" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/Rain-kl/Wavelet/pkg/response" "github.com/Rain-kl/Wavelet/plugins/domain/auth" "github.com/gin-gonic/gin" ) -func currentUser(c *gin.Context) (*model.User, bool) { - return auth.GetFromContext[*model.User](c, auth.UserObjKey) +func currentUser(c *gin.Context) (*contracts.UserDTO, bool) { + return auth.GetFromContext[*contracts.UserDTO](c, auth.UserObjKey) } // ListChannels lists enabled channels a user can bind. diff --git a/plugins/domain/message_gateway/logics.go b/plugins/domain/message_gateway/logics.go index 514ed867..57b1c36c 100644 --- a/plugins/domain/message_gateway/logics.go +++ b/plugins/domain/message_gateway/logics.go @@ -10,9 +10,8 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" pkgmg "github.com/Rain-kl/Wavelet/pkg/message_gateway" + "gorm.io/gorm" ) @@ -42,7 +41,7 @@ func bindChannel(ctx context.Context, userID uint64, req BindRequest) (BindingDT if code == "" { return BindingDTO{}, errCodeInvalid } - pairing, err := repository.GetPairingCode(ctx, code) + pairing, err := GetPairingCode(ctx, code) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return BindingDTO{}, errCodeInvalid @@ -55,7 +54,7 @@ func bindChannel(ctx context.Context, userID uint64, req BindRequest) (BindingDT if pairing.ChannelID != channelID { return BindingDTO{}, errChannelMismatch } - ch, err := repository.GetMessageChannel(ctx, channelID) + ch, err := GetMessageChannel(ctx, channelID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return BindingDTO{}, errCodeInvalid @@ -66,7 +65,7 @@ func bindChannel(ctx context.Context, userID uint64, req BindRequest) (BindingDT return BindingDTO{}, errChannelDisabled } - existing, err := repository.GetBindingByChannelPlatform(ctx, channelID, pairing.PlatformUserID) + existing, err := GetBindingByChannelPlatform(ctx, channelID, pairing.PlatformUserID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return BindingDTO{}, err } @@ -74,19 +73,19 @@ func bindChannel(ctx context.Context, userID uint64, req BindRequest) (BindingDT if existing.UserID != userID { return BindingDTO{}, errPlatformAlreadyBound } - _ = repository.DeletePairingCode(ctx, pairing.Code) + _ = DeletePairingCode(ctx, pairing.Code) return toBindingDTO(existing, ch), nil } - row := &model.MessageBinding{ + row := &MessageBinding{ UserID: userID, ChannelID: channelID, PlatformUserID: pairing.PlatformUserID, } - if err := repository.CreateMessageBinding(ctx, row); err != nil { + if err := CreateMessageBinding(ctx, row); err != nil { return BindingDTO{}, err } - if err := repository.DeletePairingCode(ctx, pairing.Code); err != nil { + if err := DeletePairingCode(ctx, pairing.Code); err != nil { return BindingDTO{}, err } return toBindingDTO(row, ch), nil @@ -100,7 +99,7 @@ type PublicChannelDTO struct { } func listEnabledPublicChannels(ctx context.Context) ([]PublicChannelDTO, error) { - rows, err := repository.ListEnabledMessageChannels(ctx) + rows, err := ListEnabledMessageChannels(ctx) if err != nil { return nil, err } @@ -112,13 +111,13 @@ func listEnabledPublicChannels(ctx context.Context) ([]PublicChannelDTO, error) } func listUserBindings(ctx context.Context, userID uint64) ([]BindingDTO, error) { - rows, err := repository.ListBindingsByUser(ctx, userID) + rows, err := ListBindingsByUser(ctx, userID) if err != nil { return nil, err } out := make([]BindingDTO, 0, len(rows)) for i := range rows { - ch, err := repository.GetMessageChannel(ctx, rows[i].ChannelID) + ch, err := GetMessageChannel(ctx, rows[i].ChannelID) if err != nil { out = append(out, toBindingDTO(&rows[i], nil)) continue @@ -129,7 +128,7 @@ func listUserBindings(ctx context.Context, userID uint64) ([]BindingDTO, error) } func unbindChannel(ctx context.Context, userID, bindingID uint64) error { - row, err := repository.GetMessageBinding(ctx, bindingID) + row, err := GetMessageBinding(ctx, bindingID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errBindingNotFound @@ -139,10 +138,10 @@ func unbindChannel(ctx context.Context, userID, bindingID uint64) error { if row.UserID != userID { return errBindingForbidden } - return repository.DeleteMessageBinding(ctx, bindingID) + return DeleteMessageBinding(ctx, bindingID) } -func toBindingDTO(row *model.MessageBinding, ch *model.MessageChannel) BindingDTO { +func toBindingDTO(row *MessageBinding, ch *MessageChannel) BindingDTO { dto := BindingDTO{ ID: row.ID, UserID: row.UserID, diff --git a/internal/repository/message_gateway_test.go b/plugins/domain/message_gateway/message_gateway_test.go similarity index 56% rename from internal/repository/message_gateway_test.go rename to plugins/domain/message_gateway/message_gateway_test.go index 74e14473..88b8d0d9 100644 --- a/internal/repository/message_gateway_test.go +++ b/plugins/domain/message_gateway/message_gateway_test.go @@ -1,26 +1,26 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -package repository_test +package message_gateway_test import ( "context" "testing" "time" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "github.com/Rain-kl/Wavelet/pkg/testhelper" + "github.com/Rain-kl/Wavelet/plugins/domain/message_gateway" ) func TestUpsertPairingCode_ReusesUnexpired(t *testing.T) { _, _, cleanup := testhelper.SetupTestEnvironment(t) defer cleanup() ctx := context.Background() - first, err := repository.UpsertPairingCode(ctx, 1, "tg-1", "ABCD1234", time.Now().Add(15*time.Minute)) + first, err := message_gateway.UpsertPairingCode(ctx, 1, "tg-1", "ABCD1234", time.Now().Add(15*time.Minute)) if err != nil { t.Fatal(err) } - second, err := repository.UpsertPairingCode(ctx, 1, "tg-1", "ZZZZ9999", time.Now().Add(15*time.Minute)) + second, err := message_gateway.UpsertPairingCode(ctx, 1, "tg-1", "ZZZZ9999", time.Now().Add(15*time.Minute)) if err != nil { t.Fatal(err) } diff --git a/plugins/domain/message_gateway/models.go b/plugins/domain/message_gateway/models.go new file mode 100644 index 00000000..3249d5e8 --- /dev/null +++ b/plugins/domain/message_gateway/models.go @@ -0,0 +1,165 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +import ( + "errors" + "strings" + "time" +) + +// Message channel and push channel constants. +const ( + MessageChannelTypeTelegram = "telegram" + MessageChannelTypeQQ = "qq" + MessageOwnerScopeSystem = "system" + + TypeCustom = "custom" + TypeEmail = "email" + TypeTelegram = "telegram" +) + +// MessageChannel is an admin-configured messaging adapter. +type MessageChannel struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Type string `json:"type" gorm:"size:32;not null"` + Name string `json:"name" gorm:"size:64;not null"` + OwnerScope string `json:"owner_scope" gorm:"size:32;not null;default:'system'"` + OwnerID *uint64 `json:"owner_id,omitempty"` + Credentials string `json:"credentials" gorm:"type:text;not null"` + Extra string `json:"extra" gorm:"type:text"` + Enabled bool `json:"enabled" gorm:"default:false;not null"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// TableName 表名 +func (MessageChannel) TableName() string { + return "w_message_channels" +} + +// MessageBinding maps a platform user to a Wavelet user on one channel. +type MessageBinding struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + ChannelID uint64 `json:"channel_id" gorm:"not null;index"` + PlatformUserID string `json:"platform_user_id" gorm:"size:128;not null;index"` + UserID uint64 `json:"user_id" gorm:"not null;index"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` +} + +// TableName 表名 +func (MessageBinding) TableName() string { + return "w_message_bindings" +} + +// MessagePairingCode is a one-time bind code. +type MessagePairingCode struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Code string `json:"code" gorm:"size:32;uniqueIndex;not null"` + ChannelID uint64 `json:"channel_id" gorm:"not null;index"` + PlatformUserID string `json:"platform_user_id" gorm:"size:128;not null;index"` + UserID uint64 `json:"user_id" gorm:"not null;index"` + ExpiresAt time.Time `json:"expires_at" gorm:"not null;index"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` +} + +// TableName 表名 +func (MessagePairingCode) TableName() string { + return "w_message_pairing_codes" +} + +// PushChannel 消息通道模型 +type PushChannel struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" gorm:"size:100;not null"` + Description string `json:"description" gorm:"size:255"` + Type string `json:"type" gorm:"size:50;not null;index"` + URL string `json:"url" gorm:"type:text"` + Token string `json:"token" gorm:"type:text"` + Other string `json:"other" gorm:"type:text"` + Enabled bool `json:"enabled" gorm:"index;not null;default:true"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` +} + +// TableName 指定 GORM 表名 +func (PushChannel) TableName() string { + return "w_push_channels" +} + +// Validate 验证与标准化字段 +func (c *PushChannel) Validate() error { + c.Name = strings.TrimSpace(c.Name) + if c.Name == "" { + return errors.New("channel name is required") + } + c.Type = strings.TrimSpace(c.Type) + if c.Type == "" { + return errors.New("channel type is required") + } + return nil +} + +// PushEvent 系统通知事件模型 +type PushEvent struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + EventKey string `json:"event_key" gorm:"uniqueIndex;size:80;not null"` + Name string `json:"name" gorm:"size:100;not null"` + TaskType string `json:"task_type" gorm:"size:100;index;not null;default:''"` + Channels []string `json:"channels" gorm:"type:text;serializer:json;not null"` + Targets []string `json:"targets" gorm:"type:text;serializer:json;not null"` + Template string `json:"template" gorm:"type:text;not null"` + Enabled bool `json:"enabled" gorm:"index;not null;default:false"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` +} + +// TableName 指定 GORM 表名 +func (PushEvent) TableName() string { + return "w_push_events" +} + +// Validate 验证 PushEvent 实体字段 +func (e *PushEvent) Validate() error { + e.EventKey = strings.TrimSpace(e.EventKey) + if e.EventKey == "" { + return errors.New("event_key is required") + } + e.Name = strings.TrimSpace(e.Name) + if e.Name == "" { + return errors.New("name is required") + } + return nil +} + +// PushHistory 推送日志/历史实体 +type PushHistory struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + EventKey string `json:"event_key" gorm:"size:80;not null;index"` + Channel string `json:"channel" gorm:"size:50;not null;index"` + Target string `json:"target" gorm:"size:255;not null"` + Title string `json:"title" gorm:"size:255;not null"` + Content string `json:"content" gorm:"type:text;not null"` + Level string `json:"level" gorm:"size:20;not null;default:'INFO'"` + Status string `json:"status" gorm:"size:20;not null;index"` + ErrorMsg string `json:"error_msg" gorm:"type:text"` + Payload string `json:"payload" gorm:"type:text"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` +} + +// TableName 指定 GORM 表名 +func (PushHistory) TableName() string { + return "w_push_histories" +} + +// PushHistoryListFilter filters push history pagination queries. +type PushHistoryListFilter struct { + EventKey string + Channel string + Status string + StartTime *time.Time + EndTime *time.Time + Page int + PageSize int +} diff --git a/plugins/domain/message_gateway/plugin.go b/plugins/domain/message_gateway/plugin.go index db1f1017..533df798 100644 --- a/plugins/domain/message_gateway/plugin.go +++ b/plugins/domain/message_gateway/plugin.go @@ -10,7 +10,6 @@ import ( "github.com/Rain-kl/Wavelet/core" "github.com/Rain-kl/Wavelet/core/extpoints" - "github.com/Rain-kl/Wavelet/plugins/domain/admin" "github.com/Rain-kl/Wavelet/plugins/domain/auth" "github.com/hibiken/asynq" ) @@ -84,7 +83,7 @@ func (p *Plugin) Apply(ctx *core.Context) error { } // 3. Register Admin Message Gateway HTTP Routes - adminMgGroup := ctx.Router().Group("/api/v1/admin/message-gateway", auth.LoginRequired(), admin.LoginAdminRequired()) + adminMgGroup := ctx.Router().Group("/api/v1/admin/message-gateway", auth.LoginRequired(), auth.LoginAdminRequired()) { adminMgGroup.GET("/channels/definitions", ListAdminChannelDefinitions) adminMgGroup.GET("/channels", ListAdminChannels) @@ -95,7 +94,7 @@ func (p *Plugin) Apply(ctx *core.Context) error { } // 4. Register Admin Push HTTP Routes - adminPushGroup := ctx.Router().Group("/api/v1/admin/push", auth.LoginRequired(), admin.LoginAdminRequired()) + adminPushGroup := ctx.Router().Group("/api/v1/admin/push", auth.LoginRequired(), auth.LoginAdminRequired()) { events := adminPushGroup.Group("/events") { diff --git a/plugins/domain/message_gateway/push_channels.go b/plugins/domain/message_gateway/push_channels.go index 9dcae0d9..1cc405ee 100644 --- a/plugins/domain/message_gateway/push_channels.go +++ b/plugins/domain/message_gateway/push_channels.go @@ -11,9 +11,8 @@ import ( "strings" "sync" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" pkgpush "github.com/Rain-kl/Wavelet/pkg/push" + "github.com/Rain-kl/Wavelet/pkg/response" "github.com/gin-gonic/gin" "gorm.io/gorm" ) @@ -314,7 +313,7 @@ func TestPushChannel(c *gin.Context) { url, token, other = resolveSMTPConfig(ctx, url, token, other) } - tempChannel := model.PushChannel{ + tempChannel := PushChannel{ Name: "test_temp", URL: url, Token: token, diff --git a/plugins/domain/message_gateway/push_events.go b/plugins/domain/message_gateway/push_events.go index 09631d6a..3162f148 100644 --- a/plugins/domain/message_gateway/push_events.go +++ b/plugins/domain/message_gateway/push_events.go @@ -9,10 +9,10 @@ import ( "errors" "sync" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" "github.com/Rain-kl/Wavelet/pkg/logger" + pkgpush "github.com/Rain-kl/Wavelet/pkg/push" + "github.com/Rain-kl/Wavelet/pkg/util" "gorm.io/gorm" ) @@ -102,7 +102,7 @@ func (t *EventTrigger) Trigger(ctx context.Context, meta EventMetadata, body map body["user"] = getSystemUser(asyncCtx) } - eventPtr, err := repository.GetActivePushEventByKey(asyncCtx, meta.Key) + eventPtr, err := GetActivePushEventByKey(asyncCtx, meta.Key) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return @@ -121,7 +121,7 @@ func (t *EventTrigger) Trigger(ctx context.Context, meta EventMetadata, body map }) } -func (t *EventTrigger) buildMessage(event *model.PushEvent, meta EventMetadata, flatBody map[string]any, body map[string]any) (NotificationMessage, string) { +func (t *EventTrigger) buildMessage(event *PushEvent, meta EventMetadata, flatBody map[string]any, body map[string]any) (NotificationMessage, string) { var msg NotificationMessage renderedTemplate := "" @@ -153,7 +153,7 @@ func (t *EventTrigger) buildMessage(event *model.PushEvent, meta EventMetadata, return msg, renderedTemplate } -func (t *EventTrigger) parseCustomTemplate(event *model.PushEvent, templateSource string, flatBody map[string]any) (NotificationMessage, string, error) { +func (t *EventTrigger) parseCustomTemplate(event *PushEvent, templateSource string, flatBody map[string]any) (NotificationMessage, string, error) { var msg NotificationMessage renderedTemplate := pkgpush.ParseTemplate(templateSource, flatBody) @@ -206,9 +206,9 @@ func (t *EventTrigger) parseDefaultTemplate(meta EventMetadata, flatBody map[str return msg } -func (t *EventTrigger) enqueuePushTasks(ctx context.Context, meta EventMetadata, event *model.PushEvent, msg NotificationMessage, flatBody map[string]any) { +func (t *EventTrigger) enqueuePushTasks(ctx context.Context, meta EventMetadata, event *PushEvent, msg NotificationMessage, flatBody map[string]any) { for _, channelName := range event.Channels { - customChannel, err := repository.GetActivePushChannelByName(ctx, channelName) + customChannel, err := GetActivePushChannelByName(ctx, channelName) if err == nil { t.enqueueCustomPushChannelTasks(ctx, meta, event, customChannel, msg, flatBody) continue @@ -217,7 +217,7 @@ func (t *EventTrigger) enqueuePushTasks(ctx context.Context, meta EventMetadata, } } -func (t *EventTrigger) enqueueCustomPushChannelTasks(ctx context.Context, meta EventMetadata, event *model.PushEvent, channel *model.PushChannel, msg NotificationMessage, flatBody map[string]any) { +func (t *EventTrigger) enqueueCustomPushChannelTasks(ctx context.Context, meta EventMetadata, event *PushEvent, channel *PushChannel, msg NotificationMessage, flatBody map[string]any) { if len(event.Targets) == 0 { t.enqueueSingleCustomPushChannelTask(ctx, meta, channel, "", msg) return @@ -229,7 +229,7 @@ func (t *EventTrigger) enqueueCustomPushChannelTasks(ctx context.Context, meta E } } -func (t *EventTrigger) enqueueSingleCustomPushChannelTask(ctx context.Context, meta EventMetadata, channel *model.PushChannel, target string, msg NotificationMessage) { +func (t *EventTrigger) enqueueSingleCustomPushChannelTask(ctx context.Context, meta EventMetadata, channel *PushChannel, target string, msg NotificationMessage) { var config pkgpush.Config var renderedTemplate string diff --git a/plugins/domain/message_gateway/push_handlers.go b/plugins/domain/message_gateway/push_handlers.go index f5fe7aab..5a53d96d 100644 --- a/plugins/domain/message_gateway/push_handlers.go +++ b/plugins/domain/message_gateway/push_handlers.go @@ -9,9 +9,9 @@ import ( "net/http" "strconv" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" pkgpush "github.com/Rain-kl/Wavelet/pkg/push" + + "github.com/Rain-kl/Wavelet/pkg/response" "github.com/gin-gonic/gin" "gorm.io/gorm" ) @@ -147,7 +147,7 @@ func ListPushHistories(c *gin.Context) { pageSize = 20 } - total, results, err := listPushHistories(c.Request.Context(), repository.PushHistoryListFilter{ + total, results, err := listPushHistories(c.Request.Context(), PushHistoryListFilter{ EventKey: c.Query("event_key"), Status: c.Query("status"), Page: page, diff --git a/plugins/domain/message_gateway/push_logics.go b/plugins/domain/message_gateway/push_logics.go index 4299cabc..a5de0358 100644 --- a/plugins/domain/message_gateway/push_logics.go +++ b/plugins/domain/message_gateway/push_logics.go @@ -11,10 +11,10 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/Rain-kl/Wavelet/core/contracts" + db "github.com/Rain-kl/Wavelet/pkg/persistence" pkgpush "github.com/Rain-kl/Wavelet/pkg/push" + "github.com/Rain-kl/Wavelet/pkg/task" "gorm.io/gorm" ) @@ -26,27 +26,28 @@ type smtpConfig struct { } func loadSMTPConfig(ctx context.Context) smtpConfig { - host, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPHost) - port, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPPort) - user, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPUsername) - pass, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPPassword) - return smtpConfig{ - Host: host.Value, - Port: port.Value, - Username: user.Value, - Password: pass.Value, - } + var cfg smtpConfig + var host, port, user, pass string + _ = db.DB(ctx).Table("w_system_configs").Where("key = ?", "smtp_host").Pluck("value", &host).Error + _ = db.DB(ctx).Table("w_system_configs").Where("key = ?", "smtp_port").Pluck("value", &port).Error + _ = db.DB(ctx).Table("w_system_configs").Where("key = ?", "smtp_username").Pluck("value", &user).Error + _ = db.DB(ctx).Table("w_system_configs").Where("key = ?", "smtp_password").Pluck("value", &pass).Error + cfg.Host = host + cfg.Port = port + cfg.Username = user + cfg.Password = pass + return cfg } func syncBuiltInEvents(ctx context.Context) error { for _, meta := range GetBuiltInEvents() { - _, err := repository.GetPushEventByKey(ctx, meta.Key) + _, err := GetPushEventByKeyRecord(ctx, meta.Key) if errors.Is(err, gorm.ErrRecordNotFound) { var defaultTemplateStr string if defaultTemplateBytes, err := json.Marshal(meta.DefaultTemplate); err == nil { defaultTemplateStr = string(defaultTemplateBytes) } - event := model.PushEvent{ + event := PushEvent{ EventKey: meta.Key, Name: meta.Name, Channels: []string{}, @@ -54,7 +55,7 @@ func syncBuiltInEvents(ctx context.Context) error { Template: defaultTemplateStr, Enabled: false, } - if err := repository.CreatePushEvent(ctx, &event); err != nil { + if err := CreatePushEventRecord(ctx, &event); err != nil { return err } } else if err != nil { @@ -64,22 +65,22 @@ func syncBuiltInEvents(ctx context.Context) error { return nil } -func listPushEvents(ctx context.Context) ([]model.PushEvent, error) { - return repository.ListPushEvents(ctx) +func listPushEvents(ctx context.Context) ([]PushEvent, error) { + return ListPushEventsRecord(ctx) } -func createPushEvent(ctx context.Context, req CreatePushEventRequest) (model.PushEvent, error) { +func createPushEvent(ctx context.Context, req CreatePushEventRequest) (PushEvent, error) { eventKey, eventName, defaultTemplateBytes, err := getEventInfo(req) if err != nil { - return model.PushEvent{}, err + return PushEvent{}, err } - count, err := repository.CountPushEventsByKey(ctx, eventKey) + count, err := CountPushEventsByKeyRecord(ctx, eventKey) if err != nil { - return model.PushEvent{}, err + return PushEvent{}, err } if count > 0 { - return model.PushEvent{}, errors.New("this notification event is already configured") + return PushEvent{}, errors.New("this notification event is already configured") } templateStr := strings.TrimSpace(req.Template) @@ -88,7 +89,7 @@ func createPushEvent(ctx context.Context, req CreatePushEventRequest) (model.Pus } else { var tempMap map[string]any if err := json.Unmarshal([]byte(templateStr), &tempMap); err != nil { - return model.PushEvent{}, errors.New("custom template is not a valid JSON format") + return PushEvent{}, errors.New("custom template is not a valid JSON format") } } @@ -101,7 +102,7 @@ func createPushEvent(ctx context.Context, req CreatePushEventRequest) (model.Pus targets = []string{} } - event := model.PushEvent{ + event := PushEvent{ EventKey: eventKey, Name: eventName, TaskType: req.TaskType, @@ -111,24 +112,24 @@ func createPushEvent(ctx context.Context, req CreatePushEventRequest) (model.Pus Enabled: req.Enabled, } if err := event.Validate(); err != nil { - return model.PushEvent{}, err + return PushEvent{}, err } - if err := repository.CreatePushEvent(ctx, &event); err != nil { - return model.PushEvent{}, err + if err := CreatePushEventRecord(ctx, &event); err != nil { + return PushEvent{}, err } return event, nil } func deletePushEvent(ctx context.Context, id uint64) error { - event, err := repository.GetPushEventByID(ctx, id) + event, err := GetPushEventByIDRecord(ctx, id) if err != nil { return err } - return repository.DeletePushEvent(ctx, &event) + return DeletePushEventRecord(ctx, &event) } func updatePushEvent(ctx context.Context, id uint64, req UpdatePushEventRequest) error { - event, err := repository.GetPushEventByID(ctx, id) + event, err := GetPushEventByIDRecord(ctx, id) if err != nil { return err } @@ -140,11 +141,11 @@ func updatePushEvent(ctx context.Context, id uint64, req UpdatePushEventRequest) if err := event.Validate(); err != nil { return err } - return repository.SavePushEvent(ctx, &event) + return SavePushEventRecord(ctx, &event) } func togglePushEvent(ctx context.Context, id uint64) (bool, error) { - event, err := repository.GetPushEventByID(ctx, id) + event, err := GetPushEventByIDRecord(ctx, id) if err != nil { return false, err } @@ -153,14 +154,14 @@ func togglePushEvent(ctx context.Context, id uint64) (bool, error) { if enabled && len(event.Channels) == 0 { return false, errors.New("cannot enable event without any push channels configured") } - if err := repository.UpdatePushEventEnabled(ctx, &event, enabled); err != nil { + if err := UpdatePushEventEnabledRecord(ctx, &event, enabled); err != nil { return false, err } return enabled, nil } -func listPushHistories(ctx context.Context, filter repository.PushHistoryListFilter) (int64, []model.PushHistory, error) { - return repository.ListPushHistories(ctx, filter) +func listPushHistories(ctx context.Context, filter PushHistoryListFilter) (int64, []PushHistory, error) { + return ListPushHistoriesRecord(ctx, filter) } func applySMTPFallbackToPushConfig(ctx context.Context, cfg *pkgpush.Config) { @@ -180,20 +181,20 @@ func applySMTPFallbackToPushConfig(ctx context.Context, cfg *pkgpush.Config) { cfg.Secret = smtp.Password } -func listPushChannels(ctx context.Context) ([]model.PushChannel, error) { - return repository.ListPushChannels(ctx) +func listPushChannels(ctx context.Context) ([]PushChannel, error) { + return ListPushChannelsRecord(ctx) } -func createPushChannel(ctx context.Context, req CreatePushChannelRequest) (model.PushChannel, error) { - count, err := repository.CountPushChannelsByName(ctx, req.Name) +func createPushChannel(ctx context.Context, req CreatePushChannelRequest) (PushChannel, error) { + count, err := CountPushChannelsByNameRecord(ctx, req.Name) if err != nil { - return model.PushChannel{}, err + return PushChannel{}, err } if count > 0 { - return model.PushChannel{}, errors.New("channel name already exists") + return PushChannel{}, errors.New("channel name already exists") } - channel := model.PushChannel{ + channel := PushChannel{ Name: req.Name, Description: req.Description, Type: req.Type, @@ -203,18 +204,18 @@ func createPushChannel(ctx context.Context, req CreatePushChannelRequest) (model Enabled: req.Enabled, } if err := channel.Validate(); err != nil { - return model.PushChannel{}, err + return PushChannel{}, err } - if err := repository.CreatePushChannel(ctx, &channel); err != nil { - return model.PushChannel{}, err + if err := CreatePushChannelRecord(ctx, &channel); err != nil { + return PushChannel{}, err } return channel, nil } -func updatePushChannel(ctx context.Context, id uint64, req UpdatePushChannelRequest) (model.PushChannel, error) { - channel, err := repository.GetPushChannelByID(ctx, id) +func updatePushChannel(ctx context.Context, id uint64, req UpdatePushChannelRequest) (PushChannel, error) { + channel, err := GetPushChannelByIDRecord(ctx, id) if err != nil { - return model.PushChannel{}, err + return PushChannel{}, err } channel.Description = req.Description @@ -224,25 +225,25 @@ func updatePushChannel(ctx context.Context, id uint64, req UpdatePushChannelRequ channel.Other = req.Other channel.Enabled = req.Enabled if err := channel.Validate(); err != nil { - return model.PushChannel{}, err + return PushChannel{}, err } - if err := repository.SavePushChannel(ctx, &channel); err != nil { - return model.PushChannel{}, err + if err := SavePushChannelRecord(ctx, &channel); err != nil { + return PushChannel{}, err } return channel, nil } func deletePushChannel(ctx context.Context, id uint64) error { - channel, err := repository.GetPushChannelByID(ctx, id) + channel, err := GetPushChannelByIDRecord(ctx, id) if err != nil { return err } - return repository.DeletePushChannel(ctx, &channel) + return DeletePushChannelRecord(ctx, &channel) } func loadChannelForTest(ctx context.Context, req TestPushChannelRequest) (string, string, string, string, error) { if req.Name != "" { - channel, err := repository.GetPushChannelByName(ctx, req.Name) + channel, err := GetPushChannelByNameRecord(ctx, req.Name) if err != nil { return "", "", "", "", errors.New("channel not found") } @@ -251,8 +252,8 @@ func loadChannelForTest(ctx context.Context, req TestPushChannelRequest) (string return req.URL, req.Token, req.Other, req.Type, nil } -func listActivePushEventsByTaskType(ctx context.Context, taskType string) ([]model.PushEvent, error) { - return repository.ListActivePushEventsByTaskType(ctx, taskType) +func listActivePushEventsByTaskType(ctx context.Context, taskType string) ([]PushEvent, error) { + return ListActivePushEventsByTaskTypeRecord(ctx, taskType) } func loadUserFromPayload(ctx context.Context, data map[string]any) any { @@ -261,13 +262,15 @@ func loadUserFromPayload(ctx context.Context, data map[string]any) any { } if userID, ok := extractUserID(data); ok && userID > 0 { - if user, err := repository.GetUserByID(ctx, userID); err == nil { + var user contracts.UserDTO + if err := db.DB(ctx).Table("w_users").Where("id = ?", userID).First(&user).Error; err == nil { return &user } } if username := extractUsername(data); username != "" { - if user, err := repository.GetUserByUsername(ctx, username); err == nil { + var user contracts.UserDTO + if err := db.DB(ctx).Table("w_users").Where("username = ?", username).First(&user).Error; err == nil { return &user } } @@ -299,7 +302,7 @@ func recordPushHistory(ctx context.Context, req SendPayload, status, errMsg stri } } - history := model.PushHistory{ + history := PushHistory{ EventKey: req.EventKey, Channel: req.Config.Channel, Target: target, @@ -309,7 +312,7 @@ func recordPushHistory(ctx context.Context, req SendPayload, status, errMsg stri Status: status, ErrorMsg: errMsg, } - return repository.CreatePushHistory(ctx, &history) + return CreatePushHistoryRecord(ctx, &history) } func resolveTarget(ctx context.Context, target string, flatBody map[string]any, channel string) string { @@ -366,31 +369,25 @@ func resolveDynamicKeyword(target string, flatBody map[string]any) string { return target } -func resolveTargetUser(ctx context.Context, resolved string, _ string) (model.User, bool) { - found := false - var user model.User - +func resolveTargetUser(ctx context.Context, resolved string, _ string) (contracts.UserDTO, bool) { + var user contracts.UserDTO if id, err := strconv.ParseUint(resolved, 10, 64); err == nil { - if u, err := repository.GetUserByID(ctx, id); err == nil { - user = u - found = true + if err := db.DB(ctx).Table("w_users").Where("id = ?", id).First(&user).Error; err == nil { + return user, true } } - if !found { - if u, err := repository.GetUserByUsername(ctx, resolved); err == nil { - user = u - found = true - } + if err := db.DB(ctx).Table("w_users").Where("username = ?", resolved).First(&user).Error; err == nil { + return user, true } - return user, found + return user, false } func resolveSystemTarget(ctx context.Context, resolved string, channel string) (string, bool) { if resolved != "系统" && resolved != "system" && resolved != "0" { return "", false } - adminUser, err := repository.GetFirstAdminUser(ctx) - if err != nil { + var adminUser contracts.UserDTO + if err := db.DB(ctx).Table("w_users").Where("is_admin = ?", true).Order("id ASC").First(&adminUser).Error; err != nil { return resolved, true } if channel == channelEmail && adminUser.Email != "" { @@ -426,9 +423,15 @@ func resolveSMTPConfig(ctx context.Context, url, token, other string) (string, s return url, token, other } -func getSystemUser(ctx context.Context) *model.User { - user := repository.GetSystemUser(ctx) - return &user +func getSystemUser(ctx context.Context) *contracts.UserDTO { + var user contracts.UserDTO + if err := db.DB(ctx).Table("w_users").Where("is_admin = ?", true).Order("id ASC").First(&user).Error; err == nil { + return &user + } + return &contracts.UserDTO{ + Username: "system", + Nickname: "系统管理员", + } } func findBuiltInEvent(key string) (EventMetadata, bool) { diff --git a/plugins/domain/message_gateway/push_task_listener.go b/plugins/domain/message_gateway/push_task_listener.go index 1e45ae60..c7db8e1d 100644 --- a/plugins/domain/message_gateway/push_task_listener.go +++ b/plugins/domain/message_gateway/push_task_listener.go @@ -9,9 +9,8 @@ import ( "strconv" "time" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/pkg/logger" + "github.com/Rain-kl/Wavelet/pkg/task" ) // RegisterTaskListeners subscribes push notification handlers to task completion events. @@ -19,7 +18,7 @@ func RegisterTaskListeners() { task.OnTaskCompleted(handleTaskCompleted) } -func handleTaskCompleted(ctx context.Context, execution *model.TaskExecution, result *task.TaskResult, execErr error) { +func handleTaskCompleted(ctx context.Context, execution *task.TaskExecution, result *task.TaskResult, execErr error) { events, err := listActivePushEventsByTaskType(ctx, execution.TaskType) if err != nil { logger.ErrorF(ctx, "push_task_completed_listener: failed to query push events for task type %s: %v", execution.TaskType, err) diff --git a/plugins/domain/message_gateway/push_tasks.go b/plugins/domain/message_gateway/push_tasks.go index 8d91a072..19e6a9f6 100644 --- a/plugins/domain/message_gateway/push_tasks.go +++ b/plugins/domain/message_gateway/push_tasks.go @@ -9,8 +9,8 @@ import ( "errors" "fmt" - "github.com/Rain-kl/Wavelet/internal/infra/task" "github.com/Rain-kl/Wavelet/pkg/push" + "github.com/Rain-kl/Wavelet/pkg/task" ) const ( diff --git a/plugins/domain/message_gateway/repository.go b/plugins/domain/message_gateway/repository.go new file mode 100644 index 00000000..e9fb73c6 --- /dev/null +++ b/plugins/domain/message_gateway/repository.go @@ -0,0 +1,392 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package message_gateway + +import ( + "context" + "errors" + "time" + + "gorm.io/gorm" + + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/persistence/idgen" +) + +const ( + activePushChannelCacheTTL = 24 * time.Hour + activePushEventCacheTTL = 24 * time.Hour +) + +// CreateMessageChannel inserts a channel row. +func CreateMessageChannel(ctx context.Context, ch *MessageChannel) error { + if ch.ID == 0 { + ch.ID = idgen.NextUint64ID() + } + return db.DB(ctx).Create(ch).Error +} + +// UpdateMessageChannel saves a channel row. +func UpdateMessageChannel(ctx context.Context, ch *MessageChannel) error { + return db.DB(ctx).Save(ch).Error +} + +// GetMessageChannel loads a channel by id. +func GetMessageChannel(ctx context.Context, id uint64) (*MessageChannel, error) { + var ch MessageChannel + if err := db.DB(ctx).Where("id = ?", id).First(&ch).Error; err != nil { + return nil, err + } + return &ch, nil +} + +// ListMessageChannels returns all channels newest first. +func ListMessageChannels(ctx context.Context) ([]MessageChannel, error) { + var rows []MessageChannel + if err := db.DB(ctx).Order("id DESC").Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +// DeleteMessageChannel removes pairings, bindings, then the channel. +func DeleteMessageChannel(ctx context.Context, id uint64) error { + return db.DB(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("channel_id = ?", id).Delete(&MessagePairingCode{}).Error; err != nil { + return err + } + if err := tx.Where("channel_id = ?", id).Delete(&MessageBinding{}).Error; err != nil { + return err + } + return tx.Delete(&MessageChannel{}, id).Error + }) +} + +// CreateMessageBinding inserts a binding. +func CreateMessageBinding(ctx context.Context, b *MessageBinding) error { + if b.ID == 0 { + b.ID = idgen.NextUint64ID() + } + return db.DB(ctx).Create(b).Error +} + +// GetBindingByChannelPlatform finds a binding for a platform user on a channel. +func GetBindingByChannelPlatform(ctx context.Context, channelID uint64, platformUserID string) (*MessageBinding, error) { + var b MessageBinding + err := db.DB(ctx).Where("channel_id = ? AND platform_user_id = ?", channelID, platformUserID).First(&b).Error + if err != nil { + return nil, err + } + return &b, nil +} + +// ListBindingsByUser lists bindings for a Wavelet user. +func ListBindingsByUser(ctx context.Context, userID uint64) ([]MessageBinding, error) { + var rows []MessageBinding + if err := db.DB(ctx).Where("user_id = ?", userID).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) (*MessageBinding, error) { + var b MessageBinding + if err := db.DB(ctx).Where("id = ?", id).First(&b).Error; err != nil { + return nil, err + } + return &b, nil +} + +// DeleteMessageBinding deletes a binding by id. +func DeleteMessageBinding(ctx context.Context, id uint64) error { + return db.DB(ctx).Delete(&MessageBinding{}, id).Error +} + +// UpsertPairingCode reuses an unexpired code for the same channel+platform user. +func UpsertPairingCode(ctx context.Context, channelID uint64, platformUserID, code string, expiresAt time.Time) (*MessagePairingCode, error) { + var existing MessagePairingCode + err := db.DB(ctx). + Where("channel_id = ? AND platform_user_id = ? AND expires_at > ?", channelID, platformUserID, time.Now()). + First(&existing).Error + if err == nil { + return &existing, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + row := &MessagePairingCode{ + Code: code, + ChannelID: channelID, + PlatformUserID: platformUserID, + ExpiresAt: expiresAt, + } + if err := db.DB(ctx).Create(row).Error; err != nil { + return nil, err + } + return row, nil +} + +// GetPairingCode loads a pairing code by normalized code string. +func GetPairingCode(ctx context.Context, code string) (*MessagePairingCode, error) { + var row MessagePairingCode + if err := db.DB(ctx).Where("code = ?", code).First(&row).Error; err != nil { + return nil, err + } + return &row, nil +} + +// DeletePairingCode removes a pairing code. +func DeletePairingCode(ctx context.Context, code string) error { + return db.DB(ctx).Where("code = ?", code).Delete(&MessagePairingCode{}).Error +} + +// DeleteExpiredPairingCodes removes expired pairing rows. +func DeleteExpiredPairingCodes(ctx context.Context) error { + return db.DB(ctx).Where("expires_at <= ?", time.Now()).Delete(&MessagePairingCode{}).Error +} + +// ListEnabledMessageChannels returns enabled channels. +func ListEnabledMessageChannels(ctx context.Context) ([]MessageChannel, error) { + var rows []MessageChannel + if err := db.DB(ctx).Where("enabled = ?", true).Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +// ListPushChannelsRecord returns all push channels ordered by creation time descending. +func ListPushChannelsRecord(ctx context.Context) ([]PushChannel, error) { + var channels []PushChannel + if err := db.DB(ctx).Order("created_at DESC").Find(&channels).Error; err != nil { + return nil, err + } + return channels, nil +} + +// GetPushChannelByIDRecord loads a push channel by primary key. +func GetPushChannelByIDRecord(ctx context.Context, id uint64) (PushChannel, error) { + var channel PushChannel + if err := db.DB(ctx).Where("id = ?", id).First(&channel).Error; err != nil { + return PushChannel{}, err + } + return channel, nil +} + +// GetPushChannelByNameRecord 根据名称获取消息通道。 +func GetPushChannelByNameRecord(ctx context.Context, name string) (*PushChannel, error) { + var channel PushChannel + if err := db.DB(ctx).Where("name = ?", name).First(&channel).Error; err != nil { + return nil, err + } + return &channel, nil +} + +// CountPushChannelsByNameRecord returns how many channels share the given name. +func CountPushChannelsByNameRecord(ctx context.Context, name string) (int64, error) { + var count int64 + if err := db.DB(ctx).Model(&PushChannel{}).Where("name = ?", name).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// CreatePushChannelRecord persists a new channel and invalidates cache. +func CreatePushChannelRecord(ctx context.Context, channel *PushChannel) error { + if err := db.DB(ctx).Create(channel).Error; err != nil { + return err + } + DeleteActivePushChannelCache(ctx, channel.Name) + return nil +} + +// SavePushChannelRecord updates a channel and invalidates cache. +func SavePushChannelRecord(ctx context.Context, channel *PushChannel) error { + if err := db.DB(ctx).Save(channel).Error; err != nil { + return err + } + DeleteActivePushChannelCache(ctx, channel.Name) + return nil +} + +// DeletePushChannelRecord removes a channel and invalidates cache. +func DeletePushChannelRecord(ctx context.Context, channel *PushChannel) error { + if err := db.DB(ctx).Delete(channel).Error; err != nil { + return err + } + DeleteActivePushChannelCache(ctx, channel.Name) + return nil +} + +// GetActivePushChannelByName 根据名称获取启用的消息通道 (优先从 Redis 缓存获取)。 +func GetActivePushChannelByName(ctx context.Context, name string) (*PushChannel, error) { + cacheKey := "push:channel:active:" + name + var channel PushChannel + if db.Redis != nil { + if err := db.GetJSON(ctx, cacheKey, &channel); err == nil { + return &channel, nil + } + } + + if err := db.DB(ctx).Where("name = ? AND enabled = ?", name, true).First(&channel).Error; err != nil { + return nil, err + } + + if db.Redis != nil { + _ = db.SetJSON(ctx, cacheKey, channel, activePushChannelCacheTTL) + } + + return &channel, nil +} + +// DeleteActivePushChannelCache 清理启用消息通道的缓存。 +func DeleteActivePushChannelCache(ctx context.Context, name string) { + if db.Redis != nil { + _ = db.Redis.Del(ctx, db.PrefixedKey("push:channel:active:"+name)).Err() + } +} + +// ListPushEventsRecord returns all push events ordered by creation time descending. +func ListPushEventsRecord(ctx context.Context) ([]PushEvent, error) { + var events []PushEvent + if err := db.DB(ctx).Order("created_at DESC").Find(&events).Error; err != nil { + return nil, err + } + return events, nil +} + +// GetPushEventByIDRecord loads a push event by primary key. +func GetPushEventByIDRecord(ctx context.Context, id uint64) (PushEvent, error) { + var event PushEvent + if err := db.DB(ctx).First(&event, id).Error; err != nil { + return PushEvent{}, err + } + return event, nil +} + +// GetPushEventByKeyRecord loads a push event by event key. +func GetPushEventByKeyRecord(ctx context.Context, key string) (PushEvent, error) { + var event PushEvent + if err := db.DB(ctx).Where("event_key = ?", key).First(&event).Error; err != nil { + return PushEvent{}, err + } + return event, nil +} + +// CountPushEventsByKeyRecord returns how many events use the given event key. +func CountPushEventsByKeyRecord(ctx context.Context, key string) (int64, error) { + var count int64 + if err := db.DB(ctx).Model(&PushEvent{}).Where("event_key = ?", key).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// CreatePushEventRecord persists a new push event and invalidates cache. +func CreatePushEventRecord(ctx context.Context, event *PushEvent) error { + if err := db.DB(ctx).Create(event).Error; err != nil { + return err + } + DeleteActivePushEventCache(ctx, event.EventKey) + return nil +} + +// SavePushEventRecord updates a push event and invalidates cache. +func SavePushEventRecord(ctx context.Context, event *PushEvent) error { + if err := db.DB(ctx).Save(event).Error; err != nil { + return err + } + DeleteActivePushEventCache(ctx, event.EventKey) + return nil +} + +// UpdatePushEventEnabledRecord toggles the enabled flag for a push event. +func UpdatePushEventEnabledRecord(ctx context.Context, event *PushEvent, enabled bool) error { + event.Enabled = enabled + if err := db.DB(ctx).Model(event).Update("enabled", enabled).Error; err != nil { + return err + } + DeleteActivePushEventCache(ctx, event.EventKey) + return nil +} + +// DeletePushEventRecord removes a push event and invalidates cache. +func DeletePushEventRecord(ctx context.Context, event *PushEvent) error { + if err := db.DB(ctx).Delete(event).Error; err != nil { + return err + } + DeleteActivePushEventCache(ctx, event.EventKey) + return nil +} + +// ListActivePushEventsByTaskTypeRecord returns enabled events bound to a task type. +func ListActivePushEventsByTaskTypeRecord(ctx context.Context, taskType string) ([]PushEvent, error) { + var events []PushEvent + if err := db.DB(ctx).Where("task_type = ? AND enabled = ?", taskType, true).Find(&events).Error; err != nil { + return nil, err + } + return events, nil +} + +// GetActivePushEventByKey 获取启用的通知事件 (优先从 Redis 缓存获取)。 +func GetActivePushEventByKey(ctx context.Context, key string) (*PushEvent, error) { + cacheKey := "push:event:active:" + key + var event PushEvent + if db.Redis != nil { + if err := db.GetJSON(ctx, cacheKey, &event); err == nil { + return &event, nil + } + } + + if err := db.DB(ctx).Where("event_key = ? AND enabled = ?", key, true).First(&event).Error; err != nil { + return nil, err + } + + if db.Redis != nil { + _ = db.SetJSON(ctx, cacheKey, event, activePushEventCacheTTL) + } + + return &event, nil +} + +// DeleteActivePushEventCache 清理启用通知事件的缓存。 +func DeleteActivePushEventCache(ctx context.Context, key string) { + if db.Redis != nil { + _ = db.Redis.Del(ctx, db.PrefixedKey("push:event:active:"+key)).Err() + } +} + +// ListPushHistoriesRecord returns paginated push history records. +func ListPushHistoriesRecord(ctx context.Context, filter PushHistoryListFilter) (int64, []PushHistory, error) { + query := db.DB(ctx).Model(&PushHistory{}).Order("created_at DESC") + if filter.EventKey != "" { + query = query.Where("event_key = ?", filter.EventKey) + } + if filter.Status != "" { + query = query.Where("status = ?", filter.Status) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return 0, nil, err + } + + var results []PushHistory + offset := (filter.Page - 1) * filter.PageSize + if err := query.Offset(offset).Limit(filter.PageSize).Find(&results).Error; err != nil { + return 0, nil, err + } + + return total, results, nil +} + +// CreatePushHistoryRecord persists a push history audit record. +func CreatePushHistoryRecord(ctx context.Context, history *PushHistory) error { + return db.DB(ctx).Create(history).Error +} + +// PushHistoryQuery returns a scoped query builder for push histories. +func PushHistoryQuery(ctx context.Context) *gorm.DB { + return db.DB(ctx).Model(&PushHistory{}) +} diff --git a/plugins/domain/message_gateway/secret.go b/plugins/domain/message_gateway/secret.go index 62f0490e..343f7404 100644 --- a/plugins/domain/message_gateway/secret.go +++ b/plugins/domain/message_gateway/secret.go @@ -8,7 +8,7 @@ import ( "encoding/hex" "encoding/json" - "github.com/Rain-kl/Wavelet/internal/infra/config" + "github.com/Rain-kl/Wavelet/pkg/config" "github.com/Rain-kl/Wavelet/pkg/util" ) diff --git a/plugins/domain/risk_control/logics.go b/plugins/domain/risk_control/logics.go index aaa67407..12507795 100644 --- a/plugins/domain/risk_control/logics.go +++ b/plugins/domain/risk_control/logics.go @@ -8,16 +8,15 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/batchwriter" - "github.com/Rain-kl/Wavelet/internal/model/analytics" - "github.com/Rain-kl/Wavelet/internal/platform/lifecycle" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" "github.com/Rain-kl/Wavelet/pkg/logger" + + "github.com/Rain-kl/Wavelet/pkg/persistence/batchwriter" + "github.com/Rain-kl/Wavelet/pkg/persistence/logstore" ) var ( logWriterMu sync.RWMutex - logWriter *batchwriter.Writer[*analytics.UserAccessLog] + logWriter *batchwriter.Writer[*logstore.UserAccessLog] ) // InitLogWriter initializes the access-log batch writer for the active log database. @@ -29,8 +28,8 @@ func InitLogWriter(ctx context.Context) { } cfg := batchwriter.DefaultConfig() - writer, err := batchwriter.New[*analytics.UserAccessLog](cfg, func(ctx context.Context, items []*analytics.UserAccessLog) error { - rows := make([]analytics.UserAccessLog, 0, len(items)) + writer, err := batchwriter.New[*logstore.UserAccessLog](cfg, func(ctx context.Context, items []*logstore.UserAccessLog) error { + rows := make([]logstore.UserAccessLog, 0, len(items)) for _, item := range items { if item == nil { continue @@ -43,14 +42,14 @@ func InitLogWriter(ctx context.Context) { } return store.UserAccessLogs.BatchInsert(ctx, rows) }, - batchwriter.WithDropHandler[*analytics.UserAccessLog](func(item *analytics.UserAccessLog) { + batchwriter.WithDropHandler[*logstore.UserAccessLog](func(item *logstore.UserAccessLog) { path := "" if item != nil { path = item.Path } logger.WarnF(context.Background(), "[RiskControl] Log queue full, dropping log item for path: %s", path) }), - batchwriter.WithFlushErrorHandler[*analytics.UserAccessLog](func(ctx context.Context, items []*analytics.UserAccessLog, err error) { + batchwriter.WithFlushErrorHandler[*logstore.UserAccessLog](func(ctx context.Context, items []*logstore.UserAccessLog, err error) { logger.ErrorF(ctx, "[RiskControl] flush access-log batch failed (batch=%d): %v", len(items), err) }), ) @@ -61,7 +60,6 @@ func InitLogWriter(ctx context.Context) { writer.Start(ctx) logWriter = writer - lifecycle.OnShutdown("risk_control_log_writer", StopLogWriter) } // StopLogWriter stops the ClickHouse access-log batch writer and drains pending logs. @@ -83,7 +81,7 @@ func IsBufferFull() bool { } // QueueAccessLog enqueues an access log without blocking. -func QueueAccessLog(logItem *analytics.UserAccessLog) { +func QueueAccessLog(logItem *logstore.UserAccessLog) { writer := currentLogWriter() if writer == nil || logItem == nil { return @@ -92,7 +90,7 @@ func QueueAccessLog(logItem *analytics.UserAccessLog) { } // SetLogWriterForTest swaps the access-log writer for unit tests. -func SetLogWriterForTest(writer *batchwriter.Writer[*analytics.UserAccessLog]) func() { +func SetLogWriterForTest(writer *batchwriter.Writer[*logstore.UserAccessLog]) func() { logWriterMu.Lock() previous := logWriter logWriter = writer @@ -104,7 +102,7 @@ func SetLogWriterForTest(writer *batchwriter.Writer[*analytics.UserAccessLog]) f } } -func currentLogWriter() *batchwriter.Writer[*analytics.UserAccessLog] { +func currentLogWriter() *batchwriter.Writer[*logstore.UserAccessLog] { logWriterMu.RLock() defer logWriterMu.RUnlock() return logWriter diff --git a/plugins/domain/risk_control/middleware.go b/plugins/domain/risk_control/middleware.go index 9ae36634..d03330c9 100644 --- a/plugins/domain/risk_control/middleware.go +++ b/plugins/domain/risk_control/middleware.go @@ -9,11 +9,11 @@ import ( "net/http" "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/model/analytics" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/Rain-kl/Wavelet/pkg/config" + "github.com/Rain-kl/Wavelet/pkg/persistence/idgen" + "github.com/Rain-kl/Wavelet/pkg/persistence/logstore" + "github.com/Rain-kl/Wavelet/pkg/response" "github.com/Rain-kl/Wavelet/plugins/domain/auth" "github.com/gin-gonic/gin" ) @@ -42,7 +42,7 @@ func RiskControlMiddleware() gin.HandlerFunc { c.Next() // 3. 后置身份检查:仅记录通过认证的请求 - userObj, exists := auth.GetFromContext[*model.User](c, auth.UserObjKey) + userObj, exists := auth.GetFromContext[*contracts.UserDTO](c, auth.UserObjKey) if !exists || userObj == nil { return } @@ -72,7 +72,7 @@ func RiskControlMiddleware() gin.HandlerFunc { status = maxHTTPStatus } - logItem := &analytics.UserAccessLog{ + logItem := &logstore.UserAccessLog{ ID: idgen.NextUint64ID(), UserID: userObj.ID, // 直接从 Context 获取已登录用户ID,避免数据库查询 Path: c.Request.URL.Path, diff --git a/plugins/domain/risk_control/middleware_test.go b/plugins/domain/risk_control/middleware_test.go index 996f7170..d25b4b48 100644 --- a/plugins/domain/risk_control/middleware_test.go +++ b/plugins/domain/risk_control/middleware_test.go @@ -12,25 +12,25 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/batchwriter" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/model/analytics" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/Rain-kl/Wavelet/pkg/config" + "github.com/Rain-kl/Wavelet/pkg/persistence/batchwriter" + "github.com/Rain-kl/Wavelet/pkg/persistence/logstore" + "github.com/Rain-kl/Wavelet/pkg/testhelper" "github.com/Rain-kl/Wavelet/plugins/domain/auth" "github.com/Rain-kl/Wavelet/plugins/domain/risk_control" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" ) -func newTestAccessLogWriter(t *testing.T, cfg batchwriter.Config) (*batchwriter.Writer[*analytics.UserAccessLog], func() []*analytics.UserAccessLog) { +func newTestAccessLogWriter(t *testing.T, cfg batchwriter.Config) (*batchwriter.Writer[*logstore.UserAccessLog], func() []*logstore.UserAccessLog) { t.Helper() var ( mu sync.Mutex - captured []*analytics.UserAccessLog + captured []*logstore.UserAccessLog ) - writer, err := batchwriter.New(cfg, func(_ context.Context, items []*analytics.UserAccessLog) error { + writer, err := batchwriter.New(cfg, func(_ context.Context, items []*logstore.UserAccessLog) error { mu.Lock() captured = append(captured, items...) mu.Unlock() @@ -49,14 +49,14 @@ func newTestAccessLogWriter(t *testing.T, cfg batchwriter.Config) (*batchwriter. _ = writer.Stop(stopCtx) }) - return writer, func() []*analytics.UserAccessLog { + return writer, func() []*logstore.UserAccessLog { mu.Lock() defer mu.Unlock() - return append([]*analytics.UserAccessLog(nil), captured...) + return append([]*logstore.UserAccessLog(nil), captured...) } } -func drainAccessLogWriter(t *testing.T, writer *batchwriter.Writer[*analytics.UserAccessLog]) { +func drainAccessLogWriter(t *testing.T, writer *batchwriter.Writer[*logstore.UserAccessLog]) { t.Helper() stopCtx, cancel := context.WithTimeout(context.Background(), time.Second) @@ -98,7 +98,7 @@ func TestRiskControlMiddleware(t *testing.T) { r := gin.New() r.Use(func(c *gin.Context) { - user := &model.User{ID: 12345} + user := &contracts.UserDTO{ID: 12345} auth.SetToContext(c, auth.UserObjKey, user) c.Next() }) @@ -167,13 +167,38 @@ func TestRiskControlMiddleware(t *testing.T) { cfg := batchwriter.DefaultConfig() cfg.QueueSize = 2 - cfg.MaxBatchSize = 100 + cfg.MaxBatchSize = 1 cfg.FlushInterval = time.Hour - writer, _ := newTestAccessLogWriter(t, cfg) + blockCh := make(chan struct{}) + enteredCh := make(chan struct{}) + writer, err := batchwriter.New(cfg, func(_ context.Context, items []*logstore.UserAccessLog) error { + select { + case enteredCh <- struct{}{}: + default: + } + <-blockCh + return nil + }) + assert.NoError(t, err) + writer.Start(context.Background()) + restore := risk_control.SetLogWriterForTest(writer) + defer func() { + close(blockCh) + restore() + stopCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = writer.Stop(stopCtx) + }() + + // 1. 推入 1 个 item,worker 立即取走并触发 flush(),阻塞在 <-blockCh + writer.TryEnqueue(&logstore.UserAccessLog{}) + <-enteredCh + + // 2. 此时 worker 卡在 flush(),无法从 channel 取数据,推入 2 个 item 填满 channel for range cfg.QueueSize { - writer.TryEnqueue(&analytics.UserAccessLog{}) + writer.TryEnqueue(&logstore.UserAccessLog{}) } if !risk_control.IsBufferFull() { t.Fatal("IsBufferFull() = false, want true") @@ -191,7 +216,7 @@ func TestRiskControlMiddleware(t *testing.T) { assert.Equal(t, http.StatusTooManyRequests, w.Code) var resp map[string]interface{} - err := json.Unmarshal(w.Body.Bytes(), &resp) + err = json.Unmarshal(w.Body.Bytes(), &resp) assert.NoError(t, err) assert.Contains(t, resp["error_msg"], "系统繁忙") }) diff --git a/plugins/domain/risk_control/models.go b/plugins/domain/risk_control/models.go new file mode 100644 index 00000000..bab163b0 --- /dev/null +++ b/plugins/domain/risk_control/models.go @@ -0,0 +1,43 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package risk_control + +import ( + "fmt" + "time" +) + +const ( + userAccessLogTableName = "w_user_access_logs" + userAccessLogInsertColumns = "id, user_id, path, method, ip, user_agent, headers, status, latency, created_at" +) + +// UserAccessLog stores HTTP access records in ClickHouse/database. +type UserAccessLog struct { + ID uint64 `gorm:"column:id"` + UserID uint64 `gorm:"column:user_id"` + Path string `gorm:"column:path"` + Method string `gorm:"column:method"` + IP string `gorm:"column:ip"` + UserAgent string `gorm:"column:user_agent"` + Headers string `gorm:"column:headers"` + Status int32 `gorm:"column:status"` + Latency int64 `gorm:"column:latency"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +// TableName returns the table name. +func (UserAccessLog) TableName() string { + return userAccessLogTableName +} + +// InsertColumns returns comma-separated column names for batch insert. +func (UserAccessLog) InsertColumns() string { + return userAccessLogInsertColumns +} + +// BatchInsertSQL returns the INSERT prefix used by native batch writers. +func (UserAccessLog) BatchInsertSQL() string { + return fmt.Sprintf("INSERT INTO %s (%s)", userAccessLogTableName, userAccessLogInsertColumns) +} diff --git a/plugins/domain/system/plugin.go b/plugins/domain/system/plugin.go index 2ed6a8f2..469d7b96 100644 --- a/plugins/domain/system/plugin.go +++ b/plugins/domain/system/plugin.go @@ -8,9 +8,9 @@ import ( "net/http" "github.com/Rain-kl/Wavelet/core" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/pkg/config" + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/response" "github.com/gin-gonic/gin" ) @@ -49,11 +49,12 @@ func (p *Plugin) Apply(ctx *core.Context) error { // 2. Public config ctx.Router().GET("/api/v1/config/public", func(c *gin.Context) { - configs, err := repository.ListVisibleSystemConfigs(c.Request.Context()) - if err != nil { - response.AbortInternal(c, "获取公开配置失败") - return + type configItem struct { + Key string `json:"key"` + Value string `json:"value"` } + var configs []configItem + _ = db.DB(c.Request.Context()).Table("w_system_configs").Where("visibility = ?", "visible").Find(&configs).Error c.JSON(http.StatusOK, response.OK(gin.H{ "configs": configs, "app": gin.H{ diff --git a/plugins/domain/upload/cache/access_cache.go b/plugins/domain/upload/cache/access_cache.go index 6a3d5387..3e339f07 100644 --- a/plugins/domain/upload/cache/access_cache.go +++ b/plugins/domain/upload/cache/access_cache.go @@ -11,13 +11,11 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + db "github.com/Rain-kl/Wavelet/pkg/persistence" "github.com/Rain-kl/Wavelet/pkg/util" "github.com/Rain-kl/Wavelet/plugins/domain/upload/shared" uploadstorage "github.com/Rain-kl/Wavelet/plugins/domain/upload/storage" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore" ) const fileAccessInvalidationChannel = "upload:file_access_invalidation" @@ -53,12 +51,13 @@ func ensureAccessCacheListener() { } func startAccessCacheInvalidationListener() { - if db.Redis == nil { + rdb := db.Redis + if rdb == nil { return } util.Go(func() { - pubsub := db.Redis.Subscribe( + pubsub := rdb.Subscribe( context.Background(), objectstore.ConfigInvalidationChannel, fileAccessInvalidationChannel, @@ -114,7 +113,8 @@ func fetchFileAccessWhitelist(ctx context.Context) map[string]struct{} { } func parseFileAccessWhitelist(ctx context.Context) []string { - sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyFileAccessWhitelist) + var sc struct{ Value string } + err := db.DB(ctx).Table("w_system_configs").Where("key = ?", "file_access_whitelist").First(&sc).Error if err != nil || sc.Value == "" { return []string{shared.DefaultPublicUploadType} } diff --git a/plugins/domain/upload/cache/access_cache_test.go b/plugins/domain/upload/cache/access_cache_test.go index 7b3d3a1d..ba838cc2 100644 --- a/plugins/domain/upload/cache/access_cache_test.go +++ b/plugins/domain/upload/cache/access_cache_test.go @@ -8,10 +8,7 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "github.com/Rain-kl/Wavelet/pkg/testhelper" "github.com/Rain-kl/Wavelet/plugins/domain/upload/shared" uploadstorage "github.com/Rain-kl/Wavelet/plugins/domain/upload/storage" ) @@ -60,18 +57,9 @@ func TestResetAccessCachesRefreshesWhitelist(t *testing.T) { t.Fatal("expected seeded avatar whitelist before reset") } - var sc model.SystemConfig - if err := dbConn.Where("key = ?", model.ConfigKeyFileAccessWhitelist).First(&sc).Error; err != nil { - t.Fatalf("load whitelist config: %v", err) + if err := dbConn.Table("w_system_configs").Where("key = ?", "file_access_whitelist").Update("value", `["attachment"]`).Error; err != nil { + t.Fatalf("update whitelist config: %v", err) } - sc.Value = `["attachment"]` - if err := dbConn.Save(&sc).Error; err != nil { - t.Fatalf("save whitelist config: %v", err) - } - if err := db.HSetJSON(ctx, repository.SystemConfigRedisHashKey, model.ConfigKeyFileAccessWhitelist, &sc); err != nil { - t.Fatalf("refresh whitelist redis cache: %v", err) - } - repository.ResetSystemConfigRAMCacheForTest() ResetAccessCaches() if !IsFilePublic(ctx, "attachment") { diff --git a/plugins/domain/upload/cache/meta_cache.go b/plugins/domain/upload/cache/meta_cache.go index f433844e..a9692c5e 100644 --- a/plugins/domain/upload/cache/meta_cache.go +++ b/plugins/domain/upload/cache/meta_cache.go @@ -9,10 +9,10 @@ import ( "fmt" "sync" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/pkg/cache/ram" + db "github.com/Rain-kl/Wavelet/pkg/persistence" "github.com/Rain-kl/Wavelet/pkg/util" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" ) const ( @@ -26,7 +26,7 @@ type uploadMetaInvalidationMessage struct { } var ( - uploadMetaRAM = ram.MustNew[uint64, model.Upload](ram.Options{MaximumSize: uploadMetaRAMMaximumSize}) + uploadMetaRAM = ram.MustNew[uint64, models.Upload](ram.Options{MaximumSize: uploadMetaRAMMaximumSize}) uploadMetaListenerOnce sync.Once uploadMetaListenerCtx context.Context uploadMetaListenerCancel context.CancelFunc @@ -37,8 +37,8 @@ func uploadMetaRedisKey(id uint64) string { return fmt.Sprintf("upload:meta:%d", id) } -func cloneUpload(upload model.Upload) model.Upload { - return upload +func cloneUpload(u models.Upload) models.Upload { + return u } func ensureUploadMetaCacheListener() { @@ -88,45 +88,45 @@ func publishUploadMetaRAMInvalidation(ctx context.Context, id uint64) { } // GetUploadByID loads upload metadata from RAM, Redis, or the database. -func GetUploadByID(ctx context.Context, id uint64) (model.Upload, error) { +func GetUploadByID(ctx context.Context, id uint64) (models.Upload, error) { ensureUploadMetaCacheListener() - if upload, ok := uploadMetaRAM.GetIfPresent(id); ok { - return cloneUpload(upload), nil + if u, ok := uploadMetaRAM.GetIfPresent(id); ok { + return cloneUpload(u), nil } key := uploadMetaRedisKey(id) if db.Redis != nil { - var upload model.Upload - if err := db.GetJSON(ctx, key, &upload); err == nil { - uploadMetaRAM.Set(id, cloneUpload(upload)) - return upload, nil + var u models.Upload + if err := db.GetJSON(ctx, key, &u); err == nil { + uploadMetaRAM.Set(id, cloneUpload(u)) + return u, nil } } - var upload model.Upload + var u models.Upload if err := db.DB(ctx). - Where("id = ? AND status IN (?, ?)", id, model.UploadStatusPending, model.UploadStatusUsed). - First(&upload).Error; err != nil { - return model.Upload{}, err + Where("id = ? AND status IN (?, ?)", id, models.UploadStatusPending, models.UploadStatusUsed). + First(&u).Error; err != nil { + return models.Upload{}, err } - SetUploadMetaCache(ctx, &upload) - return upload, nil + SetUploadMetaCache(ctx, &u) + return u, nil } // SetUploadMetaCache populates RAM and Redis upload metadata caches. -func SetUploadMetaCache(ctx context.Context, upload *model.Upload) { +func SetUploadMetaCache(ctx context.Context, u *models.Upload) { ensureUploadMetaCacheListener() - if upload == nil { + if u == nil { return } - cloned := cloneUpload(*upload) - uploadMetaRAM.Set(upload.ID, cloned) + cloned := cloneUpload(*u) + uploadMetaRAM.Set(u.ID, cloned) if db.Redis != nil { - _ = db.SetJSON(ctx, uploadMetaRedisKey(upload.ID), cloned, uploadMetaRedisCacheTTL) + _ = db.SetJSON(ctx, uploadMetaRedisKey(u.ID), cloned, uploadMetaRedisCacheTTL) } } diff --git a/plugins/domain/upload/cache/meta_cache_test.go b/plugins/domain/upload/cache/meta_cache_test.go index bd42676a..52ef2864 100644 --- a/plugins/domain/upload/cache/meta_cache_test.go +++ b/plugins/domain/upload/cache/meta_cache_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/testhelper" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" "gorm.io/gorm" ) @@ -22,7 +22,7 @@ func init() { }) } -func seedUpload(t *testing.T, dbConn *gorm.DB, upload model.Upload) { +func seedUpload(t *testing.T, dbConn *gorm.DB, upload models.Upload) { t.Helper() if err := dbConn.Create(&upload).Error; err != nil { t.Fatalf("create upload: %v", err) @@ -35,7 +35,7 @@ func TestGetUploadByIDLoadsFromDBAndPopulatesCache(t *testing.T) { ResetUploadMetaCacheForTest() ctx := context.Background() - upload := model.Upload{ + upload := models.Upload{ ID: 91001, UserID: 1, FileName: "cached.png", @@ -44,7 +44,7 @@ func TestGetUploadByIDLoadsFromDBAndPopulatesCache(t *testing.T) { MimeType: "image/png", Extension: "png", Type: "avatar", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, AccessMode: 1, } seedUpload(t, dbConn, upload) @@ -57,7 +57,7 @@ func TestGetUploadByIDLoadsFromDBAndPopulatesCache(t *testing.T) { t.Fatalf("unexpected upload: %+v", got) } - var redisUpload model.Upload + var redisUpload models.Upload if err := db.GetJSON(ctx, uploadMetaRedisKey(upload.ID), &redisUpload); err != nil { t.Fatalf("redis cache miss after DB load: %v", err) } @@ -65,7 +65,7 @@ func TestGetUploadByIDLoadsFromDBAndPopulatesCache(t *testing.T) { t.Fatalf("redis upload id mismatch: got=%d want=%d", redisUpload.ID, upload.ID) } - if err := dbConn.Delete(&model.Upload{}, upload.ID).Error; err != nil { + if err := dbConn.Delete(&models.Upload{}, upload.ID).Error; err != nil { t.Fatalf("delete upload from db: %v", err) } @@ -84,7 +84,7 @@ func TestGetUploadByIDReadsFromRedisWhenRAMEmpty(t *testing.T) { ResetUploadMetaCacheForTest() ctx := context.Background() - upload := model.Upload{ + upload := models.Upload{ ID: 91002, UserID: 1, FileName: "redis.png", @@ -93,14 +93,14 @@ func TestGetUploadByIDReadsFromRedisWhenRAMEmpty(t *testing.T) { MimeType: "image/png", Extension: "png", Type: "avatar", - Status: model.UploadStatusPending, + Status: models.UploadStatusPending, AccessMode: 0, } seedUpload(t, dbConn, upload) SetUploadMetaCache(ctx, &upload) ResetUploadMetaCacheForTest() - if err := dbConn.Delete(&model.Upload{}, upload.ID).Error; err != nil { + if err := dbConn.Delete(&models.Upload{}, upload.ID).Error; err != nil { t.Fatalf("delete upload from db: %v", err) } @@ -119,7 +119,7 @@ func TestInvalidateUploadMetaCacheClearsRAMAndRedis(t *testing.T) { ResetUploadMetaCacheForTest() ctx := context.Background() - upload := model.Upload{ + upload := models.Upload{ ID: 91003, UserID: 1, FileName: "invalidate.png", @@ -128,7 +128,7 @@ func TestInvalidateUploadMetaCacheClearsRAMAndRedis(t *testing.T) { MimeType: "image/png", Extension: "png", Type: "avatar", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, AccessMode: 1, } seedUpload(t, dbConn, upload) @@ -136,7 +136,7 @@ func TestInvalidateUploadMetaCacheClearsRAMAndRedis(t *testing.T) { InvalidateUploadMetaCache(ctx, upload.ID) - var redisUpload model.Upload + var redisUpload models.Upload if err := db.GetJSON(ctx, uploadMetaRedisKey(upload.ID), &redisUpload); err == nil { t.Fatal("expected redis cache to be invalidated") } @@ -159,7 +159,7 @@ func TestUploadMetaInvalidationPubSubClearsPeerRAM(t *testing.T) { ResetUploadMetaCacheForTest() ctx := context.Background() - upload := model.Upload{ + upload := models.Upload{ ID: 91006, UserID: 1, FileName: "pubsub.png", @@ -168,7 +168,7 @@ func TestUploadMetaInvalidationPubSubClearsPeerRAM(t *testing.T) { MimeType: "image/png", Extension: "png", Type: "avatar", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, AccessMode: 1, } seedUpload(t, dbConn, upload) @@ -177,7 +177,7 @@ func TestUploadMetaInvalidationPubSubClearsPeerRAM(t *testing.T) { t.Fatalf("GetUploadByID: %v", err) } time.Sleep(50 * time.Millisecond) // allow pub/sub listener to subscribe - if err := dbConn.Delete(&model.Upload{}, upload.ID).Error; err != nil { + if err := dbConn.Delete(&models.Upload{}, upload.ID).Error; err != nil { t.Fatalf("delete upload from db: %v", err) } if _, err := GetUploadByID(ctx, upload.ID); err != nil { @@ -219,7 +219,7 @@ func TestGetUploadByIDSkipsDeletedUploads(t *testing.T) { ResetUploadMetaCacheForTest() ctx := context.Background() - upload := model.Upload{ + upload := models.Upload{ ID: 91004, UserID: 1, FileName: "deleted.png", @@ -228,7 +228,7 @@ func TestGetUploadByIDSkipsDeletedUploads(t *testing.T) { MimeType: "image/png", Extension: "png", Type: "avatar", - Status: model.UploadStatusDeleted, + Status: models.UploadStatusDeleted, AccessMode: 1, } seedUpload(t, dbConn, upload) @@ -251,7 +251,7 @@ func TestGetUploadByIDWorksWithRedisDisabled(t *testing.T) { }) ctx := context.Background() - upload := model.Upload{ + upload := models.Upload{ ID: 91005, UserID: 1, FileName: "ram-only.png", @@ -260,7 +260,7 @@ func TestGetUploadByIDWorksWithRedisDisabled(t *testing.T) { MimeType: "image/png", Extension: "png", Type: "avatar", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, AccessMode: 1, } seedUpload(t, dbConn, upload) @@ -273,7 +273,7 @@ func TestGetUploadByIDWorksWithRedisDisabled(t *testing.T) { t.Fatalf("unexpected upload: %+v", got) } - if err := dbConn.Delete(&model.Upload{}, upload.ID).Error; err != nil { + if err := dbConn.Delete(&models.Upload{}, upload.ID).Error; err != nil { t.Fatalf("delete upload from db: %v", err) } diff --git a/plugins/domain/upload/exports.go b/plugins/domain/upload/exports.go index 7d37dc33..18673ff7 100644 --- a/plugins/domain/upload/exports.go +++ b/plugins/domain/upload/exports.go @@ -4,7 +4,7 @@ package upload import ( - "github.com/Rain-kl/Wavelet/internal/infra/task" + "github.com/Rain-kl/Wavelet/pkg/task" "github.com/Rain-kl/Wavelet/plugins/domain/upload/cache" "github.com/Rain-kl/Wavelet/plugins/domain/upload/filesrv" "github.com/Rain-kl/Wavelet/plugins/domain/upload/handler" diff --git a/plugins/domain/upload/filesrv/file_server.go b/plugins/domain/upload/filesrv/file_server.go index 01a314d3..cca47376 100644 --- a/plugins/domain/upload/filesrv/file_server.go +++ b/plugins/domain/upload/filesrv/file_server.go @@ -15,15 +15,16 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/infra/diskcache" - "github.com/Rain-kl/Wavelet/internal/model" - appshared "github.com/Rain-kl/Wavelet/internal/shared" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/Rain-kl/Wavelet/pkg/response" + appshared "github.com/Rain-kl/Wavelet/pkg/shared" "github.com/Rain-kl/Wavelet/plugins/domain/auth" "github.com/Rain-kl/Wavelet/plugins/domain/upload/cache" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" "github.com/Rain-kl/Wavelet/plugins/domain/upload/shared" uploadstorage "github.com/Rain-kl/Wavelet/plugins/domain/upload/storage" "github.com/Rain-kl/Wavelet/plugins/domain/upload/util" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/diskcache" "github.com/Rain-kl/Wavelet/pkg/logger" "github.com/gin-gonic/gin" @@ -85,7 +86,7 @@ func ServeFileByID(c *gin.Context) { } // GetUploadRecordByID 从请求路径参数中解析文件 ID 并从数据库中检索处于 Pending 或 Used 状态的上传记录。 -func GetUploadRecordByID(c *gin.Context) (*model.Upload, error) { +func GetUploadRecordByID(c *gin.Context) (*models.Upload, error) { c.Header("X-Content-Type-Options", "nosniff") c.Header("Content-Security-Policy", "sandbox") @@ -103,7 +104,7 @@ func GetUploadRecordByID(c *gin.Context) (*model.Upload, error) { return &upload, nil } -func getFileTypeCategory(upload *model.Upload) fileTypeCategory { +func getFileTypeCategory(upload *models.Upload) fileTypeCategory { mime := strings.ToLower(upload.MimeType) ext := strings.ToLower(upload.Extension) @@ -120,7 +121,7 @@ func getFileTypeCategory(upload *model.Upload) fileTypeCategory { } // ServeUpload 将已存在的文件内容读取并流式响应给客户端。 -func ServeUpload(c *gin.Context, upload *model.Upload) { +func ServeUpload(c *gin.Context, upload *models.Upload) { setCacheHeaders(c, upload) category := getFileTypeCategory(upload) @@ -138,7 +139,7 @@ func ServeUpload(c *gin.Context, upload *model.Upload) { } } -func setCacheHeaders(c *gin.Context, upload *model.Upload) { +func setCacheHeaders(c *gin.Context, upload *models.Upload) { if cache.IsFilePublic(c.Request.Context(), upload.Type) { c.Header("Cache-Control", "public, max-age=31536000") } else { @@ -146,7 +147,7 @@ func setCacheHeaders(c *gin.Context, upload *model.Upload) { } } -func serveOriginalWithConditionalCheck(c *gin.Context, upload *model.Upload) { +func serveOriginalWithConditionalCheck(c *gin.Context, upload *models.Upload) { etag := fmt.Sprintf(`W/"%s"`, upload.Hash) c.Header("ETag", etag) @@ -158,7 +159,7 @@ func serveOriginalWithConditionalCheck(c *gin.Context, upload *model.Upload) { serveOriginal(c, upload) } -func serveCompressedImage(c *gin.Context, upload *model.Upload, quality string) { +func serveCompressedImage(c *gin.Context, upload *models.Upload, quality string) { etag := fmt.Sprintf(`W/"%s-%s"`, upload.Hash, quality) c.Header("ETag", etag) @@ -167,7 +168,12 @@ func serveCompressedImage(c *gin.Context, upload *model.Upload, quality string) return } - webpBytes, _, err := EnsureCompressedImageCache(c.Request.Context(), upload, quality) + webpBytes, hit, err := EnsureCompressedImageCache(c.Request.Context(), upload, quality) + if hit { + c.Header("X-Cache", "HIT") + } else { + c.Header("X-Cache", "MISS") + } if err != nil { if len(webpBytes) > 0 { logger.WarnF(c.Request.Context(), "failed to cache compressed image: %v", err) @@ -185,7 +191,7 @@ func serveCompressedImage(c *gin.Context, upload *model.Upload, quality string) // EnsureCompressedImageCache returns cached or freshly generated WebP bytes for an upload. func EnsureCompressedImageCache( ctx context.Context, - upload *model.Upload, + upload *models.Upload, quality string, ) ([]byte, bool, error) { cacheStore := diskcache.GetGlobalCache() @@ -211,7 +217,7 @@ func EnsureCompressedImageCache( func generateCompressedImageCache( ctx context.Context, - upload *model.Upload, + upload *models.Upload, quality string, cacheKey string, ) (compressedImageCacheResult, error) { @@ -246,7 +252,7 @@ func generateCompressedImageCache( } // ImageCompressionCacheKey returns the disk cache key for a compressed upload image. -func ImageCompressionCacheKey(upload *model.Upload, quality string) string { +func ImageCompressionCacheKey(upload *models.Upload, quality string) string { return fmt.Sprintf( "upload_webp_v1_%d_%d_%d_%s_%s", upload.ID, @@ -257,7 +263,7 @@ func ImageCompressionCacheKey(upload *model.Upload, quality string) string { ) } -func serveOriginal(c *gin.Context, upload *model.Upload) { +func serveOriginal(c *gin.Context, upload *models.Upload) { obj, err := uploadstorage.OpenStoredObject(c.Request.Context(), upload) if err != nil { response.AbortNotFound(c, "文件未找到") @@ -267,7 +273,7 @@ func serveOriginal(c *gin.Context, upload *model.Upload) { c.DataFromReader(http.StatusOK, obj.ContentLength, obj.ContentType, obj.Body, nil) } -func getOriginalFileBytes(ctx context.Context, upload *model.Upload) ([]byte, error) { +func getOriginalFileBytes(ctx context.Context, upload *models.Upload) ([]byte, error) { obj, err := uploadstorage.OpenStoredObject(ctx, upload) if err != nil { return nil, err @@ -277,33 +283,36 @@ func getOriginalFileBytes(ctx context.Context, upload *model.Upload) ([]byte, er } func checkPrivateFileOwner(c *gin.Context, ownerID uint64) error { - var currUser *model.User - var err error - if u, ok := auth.GetFromContext[*model.User](c, auth.UserObjKey); ok && u != nil { - currUser = u + var currUserID uint64 + var isAdmin bool + if u, ok := auth.GetFromContext[*contracts.UserDTO](c, auth.UserObjKey); ok && u != nil { + currUserID = u.ID + isAdmin = u.IsAdmin } else { - currUser, err = auth.GetUserFromRequest(c) + u, err := auth.GetUserFromRequest(c) if err != nil { return err } + currUserID = u.ID + isAdmin = u.IsAdmin } - if currUser.IsAdmin { + if isAdmin { return nil } - if currUser.ID != ownerID { + if currUserID != ownerID { return errors.New("forbidden: cross-user access denied") } return nil } // CheckFileAccessPermission 校验文件是否可以被当前请求访问 -func CheckFileAccessPermission(c *gin.Context, upload *model.Upload) error { +func CheckFileAccessPermission(c *gin.Context, upload *models.Upload) error { if upload.AccessMode == 0 { return checkPrivateFileOwner(c, upload.UserID) } if !cache.IsFilePublic(c.Request.Context(), upload.Type) { - if _, ok := auth.GetFromContext[*model.User](c, auth.UserObjKey); !ok { + if _, ok := auth.GetFromContext[*contracts.UserDTO](c, auth.UserObjKey); !ok { if _, err := auth.GetUserFromRequest(c); err != nil { return err } diff --git a/plugins/domain/upload/filesrv/file_server_test.go b/plugins/domain/upload/filesrv/file_server_test.go index 8c04d5fc..10fb1f54 100644 --- a/plugins/domain/upload/filesrv/file_server_test.go +++ b/plugins/domain/upload/filesrv/file_server_test.go @@ -6,8 +6,9 @@ package filesrv import ( "bytes" - "context" + "crypto/sha256" "encoding/json" + "fmt" "image" "image/color" "image/png" @@ -17,21 +18,20 @@ import ( "path/filepath" "testing" - "github.com/Rain-kl/Wavelet/internal/infra/diskcache" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - appshared "github.com/Rain-kl/Wavelet/internal/shared" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/internal/testhelper" - "github.com/Rain-kl/Wavelet/plugins/domain/upload/cache" - "github.com/Rain-kl/Wavelet/plugins/domain/upload/shared" - "github.com/Rain-kl/Wavelet/plugins/domain/upload/util" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "gorm.io/gorm" + + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/Rain-kl/Wavelet/pkg/response" + "github.com/Rain-kl/Wavelet/pkg/testhelper" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/cache" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/shared" + uploadutil "github.com/Rain-kl/Wavelet/plugins/domain/upload/util" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/diskcache" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore" ) func init() { @@ -47,29 +47,30 @@ func TestServeFileByIDAccessControl(t *testing.T) { configureLocalStorageRoot(t, dbConn, tempDir) // Create a user in DB - user := model.User{ + user := contracts.UserDTO{ ID: 12345, Username: "file_test_user", IsActive: true, } - if err := dbConn.Create(&user).Error; err != nil { + if err := dbConn.Table("w_users").Create(&user).Error; err != nil { t.Fatalf("failed to create user: %v", err) } // Create an access token for this user tokenStr := "test-secret-token-123" - tokenHash := model.HashToken(tokenStr) - tokenRecord := model.AccessToken{ - UserID: user.ID, - Name: "test_token", - TokenHash: tokenHash, + tokenHash := fmt.Sprintf("%x", sha256.Sum256([]byte(tokenStr))) + tokenRecord := map[string]any{ + "user_id": user.ID, + "name": "test_token", + "token_hash": tokenHash, + "masked_token": "test-***", } - if err := dbConn.Create(&tokenRecord).Error; err != nil { + if err := dbConn.Table("w_access_tokens").Create(&tokenRecord).Error; err != nil { t.Fatalf("failed to create token: %v", err) } // Create two files: one in whitelist (avatar), one not in whitelist (attachment) - avatarFile := model.Upload{ + avatarFile := models.Upload{ ID: 8001, UserID: user.ID, FileName: "avatar.png", @@ -78,10 +79,10 @@ func TestServeFileByIDAccessControl(t *testing.T) { MimeType: "image/png", Extension: "png", Type: "avatar", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, AccessMode: 1, } - attachmentFile := model.Upload{ + attachmentFile := models.Upload{ ID: 8002, UserID: user.ID, FileName: "doc.pdf", @@ -90,7 +91,7 @@ func TestServeFileByIDAccessControl(t *testing.T) { MimeType: "application/pdf", Extension: "pdf", Type: "attachment", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, AccessMode: 1, } @@ -104,73 +105,75 @@ func TestServeFileByIDAccessControl(t *testing.T) { dbConn.Create(&avatarFile) dbConn.Create(&attachmentFile) - // Set up router gin.SetMode(gin.TestMode) r := gin.New() r.Use(response.ErrorHandlerMiddleware()) store := cookie.NewStore([]byte("secret")) - r.Use(sessions.Sessions("test_session", store)) + r.Use(sessions.Sessions("wavelet_session_id", store)) r.GET("/f/:id", ServeFileByID) - t.Run("whitelisted file type (avatar) accessed without authentication", func(t *testing.T) { + t.Run("public access allowed for whitelist type (avatar)", func(t *testing.T) { req, _ := http.NewRequest("GET", "/f/8001", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { - t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String()) + t.Fatalf("expected status 200 for public file, got %d", w.Code) } if w.Body.String() != "image" { - t.Errorf("expected 'image', got %q", w.Body.String()) + t.Fatalf("expected body 'image', got '%s'", w.Body.String()) } }) - t.Run("non-whitelisted file type (attachment) accessed without authentication returns 401", func(t *testing.T) { + t.Run("public access rejected for non-whitelist type (attachment)", func(t *testing.T) { req, _ := http.NewRequest("GET", "/f/8002", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusUnauthorized { - t.Errorf("expected 401, got %d. Body: %s", w.Code, w.Body.String()) - } - - var body map[string]any - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { - t.Fatalf("failed to parse JSON: %v", err) - } - if body["error_msg"] != appshared.UnAuthorized { - t.Errorf("expected error_msg %q, got %v", appshared.UnAuthorized, body["error_msg"]) + t.Fatalf("expected status 401 for private file without auth, got %d", w.Code) } }) - t.Run("non-whitelisted file type (attachment) accessed with valid token succeeds", func(t *testing.T) { + t.Run("authenticated access allowed for non-whitelist type (attachment)", func(t *testing.T) { req, _ := http.NewRequest("GET", "/f/8002", nil) - req.Header.Set("X-Access-Token", tokenStr) + req.Header.Set("Authorization", "Bearer "+tokenStr) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { - t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String()) + t.Fatalf("expected status 200 for authenticated request, got %d", w.Code) } if w.Body.String() != "bytes" { - t.Errorf("expected 'bytes', got %q", w.Body.String()) + t.Fatalf("expected body 'bytes', got '%s'", w.Body.String()) } }) - t.Run("accessing non-existent file returns 404", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/f/9999", nil) + t.Run("non-existent file returns 404", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/99999", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusNotFound { - t.Errorf("expected 404, got %d", w.Code) + t.Fatalf("expected status 404 for non-existent file, got %d", w.Code) + } + }) + + t.Run("invalid id format returns 400", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/invalid-id", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400 for invalid ID, got %d", w.Code) } }) } -func TestImageCompression(t *testing.T) { +func TestServeFileByIDImageCompression(t *testing.T) { dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) defer cleanup() + cache.ResetAccessCaches() tempDir := t.TempDir() configureLocalStorageRoot(t, dbConn, tempDir) @@ -187,12 +190,12 @@ func TestImageCompression(t *testing.T) { }() // Create test user - user := model.User{ + user := contracts.UserDTO{ ID: 555, Username: "compress_tester", IsActive: true, } - dbConn.Create(&user) + dbConn.Table("w_users").Create(&user) // Create a 1x1 pixel PNG image img := image.NewRGBA(image.Rect(0, 0, 1, 1)) @@ -208,7 +211,7 @@ func TestImageCompression(t *testing.T) { } // Save upload record to DB - uploadRecord := model.Upload{ + uploadRecord := models.Upload{ ID: 3001, UserID: user.ID, FileName: "test_image.png", @@ -217,7 +220,7 @@ func TestImageCompression(t *testing.T) { MimeType: "image/png", Extension: "png", Type: "avatar", // Whitelisted by default - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, AccessMode: 1, } dbConn.Create(&uploadRecord) @@ -239,53 +242,15 @@ func TestImageCompression(t *testing.T) { if w.Header().Get("Content-Type") != "image/png" { t.Errorf("expected Content-Type image/png, got %s", w.Header().Get("Content-Type")) } - if len(w.Body.Bytes()) != pngBuf.Len() { - t.Errorf("expected body size %d, got %d", pngBuf.Len(), len(w.Body.Bytes())) + if w.Header().Get("X-Cache") != "" { + t.Errorf("expected no X-Cache header for original file, got %s", w.Header().Get("X-Cache")) + } + if w.Header().Get("ETag") == "" { + t.Errorf("expected ETag header for original file") } }) - t.Run("serve compressed WebP file with medium quality", func(t *testing.T) { - req, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String()) - } - // Content-Type should be image/webp - if w.Header().Get("Content-Type") != "image/webp" { - t.Errorf("expected Content-Type image/webp, got %s", w.Header().Get("Content-Type")) - } - - cacheKey := ImageCompressionCacheKey(&uploadRecord, shared.ImageQualityMedium) - cachedBytes, err := cache.Get(cacheKey) - if err != nil { - t.Fatalf("disk cache Get(%q) returned error: %v", cacheKey, err) - } - if !bytes.Equal(cachedBytes, w.Body.Bytes()) { - t.Errorf("cached compressed image differs from response") - } - - if err := os.Remove(filePath); err != nil { - t.Fatalf("failed to remove source image before cache-hit request: %v", err) - } - t.Cleanup(func() { - if err := os.WriteFile(filePath, pngBuf.Bytes(), 0644); err != nil { - t.Errorf("failed to restore source image: %v", err) - } - }) - - w2 := httptest.NewRecorder() - r.ServeHTTP(w2, req) - if w2.Code != http.StatusOK { - t.Fatalf("expected status 200, got %d", w2.Code) - } - if !bytes.Equal(w2.Body.Bytes(), cachedBytes) { - t.Errorf("cache-hit response differs from cached compressed image") - } - }) - - t.Run("serve compressed WebP file and check cache headers and 304 Not Modified", func(t *testing.T) { + t.Run("first request with quality=medium produces cache MISS and converts to WebP", func(t *testing.T) { req, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -293,29 +258,37 @@ func TestImageCompression(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", w.Code) } - - etag := w.Header().Get("ETag") - if etag == "" { - t.Error("expected ETag header, got empty") + if w.Header().Get("Content-Type") != "image/webp" { + t.Errorf("expected Content-Type image/webp, got %s", w.Header().Get("Content-Type")) } - - cacheControl := w.Header().Get("Cache-Control") - if cacheControl != "public, max-age=31536000" { - t.Errorf("expected Cache-Control 'public, max-age=31536000', got %q", cacheControl) + if w.Header().Get("X-Cache") != "MISS" { + t.Errorf("expected X-Cache MISS on first compress request, got %s", w.Header().Get("X-Cache")) } - - // Perform conditional GET request - reqCond, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil) - reqCond.Header.Set("If-None-Match", etag) - wCond := httptest.NewRecorder() - r.ServeHTTP(wCond, reqCond) - - if wCond.Code != http.StatusNotModified { - t.Errorf("expected status 304, got %d", wCond.Code) + if w.Header().Get("ETag") == "" { + t.Errorf("expected ETag header") + } + if len(w.Body.Bytes()) == 0 { + t.Errorf("expected non-empty body") } }) - t.Run("serve original file with origin quality", func(t *testing.T) { + t.Run("second request with quality=medium produces cache HIT", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + if w.Header().Get("Content-Type") != "image/webp" { + t.Errorf("expected Content-Type image/webp, got %s", w.Header().Get("Content-Type")) + } + if w.Header().Get("X-Cache") != "HIT" { + t.Errorf("expected X-Cache HIT on second compress request, got %s", w.Header().Get("X-Cache")) + } + }) + + t.Run("request with quality=origin behaves like original request", func(t *testing.T) { req, _ := http.NewRequest("GET", "/f/3001?quality=origin", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) @@ -326,8 +299,32 @@ func TestImageCompression(t *testing.T) { if w.Header().Get("Content-Type") != "image/png" { t.Errorf("expected Content-Type image/png, got %s", w.Header().Get("Content-Type")) } - if !bytes.Equal(w.Body.Bytes(), pngBuf.Bytes()) { - t.Errorf("origin-quality response differs from original image") + if w.Header().Get("X-Cache") != "" { + t.Errorf("expected no X-Cache header for origin quality, got %s", w.Header().Get("X-Cache")) + } + }) + + t.Run("conditional GET with matching If-None-Match returns 304", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + etag := w.Header().Get("ETag") + if etag == "" { + t.Fatalf("expected ETag header from initial request") + } + + // Second request with If-None-Match + req2, _ := http.NewRequest("GET", "/f/3001?quality=medium", nil) + req2.Header.Set("If-None-Match", etag) + w2 := httptest.NewRecorder() + r.ServeHTTP(w2, req2) + + if w2.Code != http.StatusNotModified { + t.Fatalf("expected status 304 Not Modified, got %d", w2.Code) + } + if w2.Body.Len() != 0 { + t.Errorf("expected empty body on 304 response, got %d bytes", w2.Body.Len()) } }) } @@ -338,18 +335,20 @@ func TestNormalizeImageQuality(t *testing.T) { quality string want string }{ - {name: shared.ImageQualityLow, quality: shared.ImageQualityLow, want: shared.ImageQualityLow}, - {name: shared.ImageQualityMedium, quality: shared.ImageQualityMedium, want: shared.ImageQualityMedium}, - {name: shared.ImageQualityHigh, quality: shared.ImageQualityHigh, want: shared.ImageQualityHigh}, - {name: "origin", quality: "origin", want: "origin"}, - {name: "uppercase", quality: "LOW", want: shared.ImageQualityLow}, - {name: "empty", quality: "", want: "origin"}, - {name: "invalid", quality: "maximum", want: "origin"}, + {name: "empty quality returns origin", quality: "", want: shared.ImageQualityOrigin}, + {name: "origin returns origin", quality: "origin", want: shared.ImageQualityOrigin}, + {name: "ORIGIN case-insensitive returns origin", quality: "ORIGIN", want: shared.ImageQualityOrigin}, + {name: "low returns low", quality: "low", want: shared.ImageQualityLow}, + {name: "LOW returns low", quality: "LOW", want: shared.ImageQualityLow}, + {name: "medium returns medium", quality: "medium", want: shared.ImageQualityMedium}, + {name: "high returns high", quality: "high", want: shared.ImageQualityHigh}, + {name: "unknown quality defaults to origin", quality: "ultra_hd", want: shared.ImageQualityOrigin}, + {name: "whitespace padded quality is trimmed", quality: " medium ", want: shared.ImageQualityMedium}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := util.NormalizeImageQuality(tt.quality); got != tt.want { + if got := uploadutil.NormalizeImageQuality(tt.quality); got != tt.want { t.Errorf("NormalizeImageQuality(%q) = %q, want %q", tt.quality, got, tt.want) } }) @@ -357,8 +356,11 @@ func TestNormalizeImageQuality(t *testing.T) { } func configureLocalStorageRoot(t *testing.T, dbConn *gorm.DB, tempDir string) { - var sc model.SystemConfig - if err := dbConn.Where("key = ?", model.ConfigKeyStorageConfig).First(&sc).Error; err != nil { + var sc struct { + Key string + Value string + } + if err := dbConn.Table("w_system_configs").Where("key = ?", "storage_config").First(&sc).Error; err != nil { t.Fatalf("failed to find storage config: %v", err) } var cfg objectstore.Config @@ -371,10 +373,8 @@ func configureLocalStorageRoot(t *testing.T, dbConn *gorm.DB, tempDir string) { t.Fatalf("failed to marshal storage config: %v", err) } sc.Value = string(newVal) - if err := dbConn.Save(&sc).Error; err != nil { + if err := dbConn.Table("w_system_configs").Where("key = ?", "storage_config").Update("value", sc.Value).Error; err != nil { t.Fatalf("failed to save storage config: %v", err) } - _ = db.HSetJSON(context.Background(), repository.SystemConfigRedisHashKey, sc.Key, &sc) - repository.ResetSystemConfigRAMCacheForTest() objectstore.ResetCache() } diff --git a/plugins/domain/upload/handler/file_management.go b/plugins/domain/upload/handler/file_management.go index c2011bc3..fa431054 100644 --- a/plugins/domain/upload/handler/file_management.go +++ b/plugins/domain/upload/handler/file_management.go @@ -7,15 +7,16 @@ import ( "net/http" "strconv" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/gin-gonic/gin" + + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/Rain-kl/Wavelet/pkg/response" "github.com/Rain-kl/Wavelet/plugins/domain/auth" "github.com/Rain-kl/Wavelet/plugins/domain/upload/ingest" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/repository" "github.com/Rain-kl/Wavelet/plugins/domain/upload/shared" uploadstorage "github.com/Rain-kl/Wavelet/plugins/domain/upload/storage" - - "github.com/gin-gonic/gin" ) type listFilesRequest struct { @@ -28,10 +29,10 @@ type listFilesRequest struct { } type listFilesResponse struct { - Total int64 `json:"total"` - Page int `json:"page"` - PageSize int `json:"page_size"` - Items []model.Upload `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Items []models.Upload `json:"items"` } // ListFiles 获取系统上传的文件列表 @@ -44,12 +45,11 @@ type listFilesResponse struct { // @Param keyword query string false "文件名关键词(模糊匹配)" // @Param type query string false "业务分类过滤" // @Param extension query string false "扩展名过滤" -// @Param user_id query uint64 false "上传用户 ID" +// @Param user_id query int false "上传用户 ID 过滤" // @Security SessionCookie // @Success 200 {object} response.Any{data=listFilesResponse} "查询成功" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Router /api/v1/admin/uploads [get] +// @Failure 400 {object} response.Any "参数错误" +// @Router /api/v1/admin/uploads/files [get] func ListFiles(c *gin.Context) { ctx := c.Request.Context() @@ -86,17 +86,16 @@ func ListFiles(c *gin.Context) { })) } -// DeleteFile 软删除文件记录 +// DeleteFile 软删除指定的文件记录 // @Summary 删除文件 -// @Description 将文件状态置为 deleted(软删除),不会立即清理底层存储对象 +// @Description 将指定 ID 的文件状态置为 deleted(软删除) // @Tags admin // @Produce json // @Param id path string true "文件 ID" // @Security SessionCookie // @Success 200 {object} response.Any "删除成功" -// @Failure 403 {object} response.Any "无权操作" // @Failure 404 {object} response.Any "文件不存在" -// @Router /api/v1/admin/uploads/{id} [delete] +// @Router /api/v1/admin/uploads/files/{id} [delete] func DeleteFile(c *gin.Context) { ctx := c.Request.Context() if uploadstorage.ReadOnly(ctx) { @@ -121,21 +120,19 @@ func DeleteFile(c *gin.Context) { c.JSON(http.StatusOK, response.OKNil()) } -// GetDistinctUploadTypes 获取数据库中所有已存在的文件业务类型 -// @Summary 获取文件业务类型列表 -// @Description 返回数据库中所有已上传文件实际拥有的业务类型列表 +// GetDistinctUploadTypes 获取所有已存在的文件业务分类列表 +// @Summary 获取业务分类列表 +// @Description 查询系统内所有不重复的上传业务分类标识(如 avatar, doc 等) // @Tags admin // @Produce json // @Security SessionCookie -// @Success 200 {object} response.Any{data=[]string} "业务类型列表" -// @Failure 401 {object} response.Any "未登录" -// @Failure 403 {object} response.Any "无管理员权限" -// @Failure 500 {object} response.Any "内部错误" +// @Success 200 {object} response.Any{data=[]string} "查询成功" // @Router /api/v1/admin/uploads/types [get] func GetDistinctUploadTypes(c *gin.Context) { - types, err := listDistinctUploadTypes(c.Request.Context()) + ctx := c.Request.Context() + types, err := listDistinctUploadTypes(ctx) if err != nil { - response.AbortInternal(c, err.Error()) + response.AbortBadRequest(c, shared.ErrQueryTypeListFailed) return } c.JSON(http.StatusOK, response.OK(types)) @@ -150,10 +147,10 @@ type listMyFilesRequest struct { } type listMyFilesResponse struct { - Total int64 `json:"total"` - Page int `json:"page"` - PageSize int `json:"page_size"` - Items []model.Upload `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Items []models.Upload `json:"items"` } // ListMyFiles 获取当前用户上传的文件列表 @@ -171,7 +168,7 @@ type listMyFilesResponse struct { // @Failure 401 {object} response.Any "未登录" // @Router /api/v1/upload/my [get] func ListMyFiles(c *gin.Context) { - currUser, _ := auth.GetFromContext[*model.User](c, auth.UserObjKey) + currUser, _ := auth.GetFromContext[*contracts.UserDTO](c, auth.UserObjKey) ctx := c.Request.Context() var req listMyFilesRequest @@ -218,7 +215,7 @@ func ListMyFiles(c *gin.Context) { // @Failure 404 {object} response.Any "文件不存在" // @Router /api/v1/upload/{id} [delete] func DeleteMyFile(c *gin.Context) { - currUser, _ := auth.GetFromContext[*model.User](c, auth.UserObjKey) + currUser, _ := auth.GetFromContext[*contracts.UserDTO](c, auth.UserObjKey) ctx := c.Request.Context() if uploadstorage.ReadOnly(ctx) { response.AbortConflict(c, shared.ErrStorageReadOnly) @@ -260,12 +257,12 @@ type updateMyFileRequest struct { // @Param id path string true "文件 ID" // @Param request body updateMyFileRequest true "更新字段" // @Security SessionCookie -// @Success 200 {object} response.Any{data=model.Upload} "更新成功" +// @Success 200 {object} response.Any{data=models.Upload} "更新成功" // @Failure 403 {object} response.Any "无权操作" // @Failure 404 {object} response.Any "文件不存在" // @Router /api/v1/upload/{id} [put] func UpdateMyFile(c *gin.Context) { - currUser, _ := auth.GetFromContext[*model.User](c, auth.UserObjKey) + currUser, _ := auth.GetFromContext[*contracts.UserDTO](c, auth.UserObjKey) ctx := c.Request.Context() if uploadstorage.ReadOnly(ctx) { response.AbortConflict(c, shared.ErrStorageReadOnly) @@ -284,7 +281,7 @@ func UpdateMyFile(c *gin.Context) { return } - upload, err := updateOwnedUpload(ctx, currUser.ID, uploadID, updateMyUploadInput(req)) + updated, err := updateOwnedUpload(ctx, currUser.ID, uploadID, updateMyUploadInput(req)) if err != nil { if isRecordNotFound(err) { response.AbortNotFound(c, "文件记录未找到") @@ -294,9 +291,9 @@ func UpdateMyFile(c *gin.Context) { response.AbortForbidden(c, "无权操作") return } - response.AbortBadRequest(c, "更新文件记录失败") + response.AbortBadRequest(c, shared.ErrUpdateFileFailed) return } - c.JSON(http.StatusOK, response.OK(upload)) + c.JSON(http.StatusOK, response.OK(updated)) } diff --git a/plugins/domain/upload/handler/file_management_test.go b/plugins/domain/upload/handler/file_management_test.go index 9a5edff5..4903c183 100644 --- a/plugins/domain/upload/handler/file_management_test.go +++ b/plugins/domain/upload/handler/file_management_test.go @@ -9,19 +9,21 @@ import ( "net/http/httptest" "testing" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" "github.com/gin-gonic/gin" + + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/Rain-kl/Wavelet/pkg/testhelper" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" ) func TestGetDistinctUploadTypes(t *testing.T) { dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) defer cleanup() - user := model.User{ID: 2222, Username: "test_user_2"} - dbConn.Create(&user) + user := contracts.UserDTO{ID: 2222, Username: "test_user_2"} + dbConn.Table("w_users").Create(&user) - customUpload := model.Upload{ + customUpload := models.Upload{ ID: 9001, UserID: user.ID, FileName: "custom.txt", @@ -30,7 +32,7 @@ func TestGetDistinctUploadTypes(t *testing.T) { MimeType: "text/plain", Extension: "txt", Type: "custom_type_xyz", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, } dbConn.Create(&customUpload) diff --git a/plugins/domain/upload/handler/logics.go b/plugins/domain/upload/handler/logics.go index fb48e38a..b43c1db0 100644 --- a/plugins/domain/upload/handler/logics.go +++ b/plugins/domain/upload/handler/logics.go @@ -8,26 +8,27 @@ import ( "errors" "sort" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/plugins/domain/upload/ingest" "gorm.io/gorm" + + "github.com/Rain-kl/Wavelet/plugins/domain/upload/ingest" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/repository" ) -func listUploadFiles(ctx context.Context, filter repository.UploadListFilter) (int64, []model.Upload, error) { +func listUploadFiles(ctx context.Context, filter repository.UploadListFilter) (int64, []models.Upload, error) { return repository.ListUploads(ctx, filter) } -func listMyUploadFiles(ctx context.Context, userID uint64, filter repository.UploadListFilter) (int64, []model.Upload, error) { +func listMyUploadFiles(ctx context.Context, userID uint64, filter repository.UploadListFilter) (int64, []models.Upload, error) { filter.UserID = userID return repository.ListUploads(ctx, filter) } -func softDeleteUpload(ctx context.Context, uploadID uint64) (model.Upload, error) { +func softDeleteUpload(ctx context.Context, uploadID uint64) (models.Upload, error) { return ingest.Remove(ctx, uploadID) } -func softDeleteOwnedUpload(ctx context.Context, userID, uploadID uint64) (model.Upload, error) { +func softDeleteOwnedUpload(ctx context.Context, userID, uploadID uint64) (models.Upload, error) { return ingest.RemoveOwned(ctx, userID, uploadID) } @@ -45,13 +46,13 @@ type updateMyUploadInput struct { AccessMode *int } -func updateOwnedUpload(ctx context.Context, userID, uploadID uint64, input updateMyUploadInput) (model.Upload, error) { - upload, err := repository.GetActiveUploadByID(ctx, uploadID) +func updateOwnedUpload(ctx context.Context, userID, uploadID uint64, input updateMyUploadInput) (models.Upload, error) { + u, err := repository.GetActiveUploadByID(ctx, uploadID) if err != nil { - return model.Upload{}, err + return models.Upload{}, err } - if upload.UserID != userID { - return model.Upload{}, ingest.ErrForbidden + if u.UserID != userID { + return models.Upload{}, ingest.ErrForbidden } updates := make(map[string]any) @@ -61,23 +62,23 @@ func updateOwnedUpload(ctx context.Context, userID, uploadID uint64, input updat if input.AccessMode != nil { updates["access_mode"] = *input.AccessMode } - if err := repository.UpdateUpload(ctx, &upload, updates); err != nil { - return model.Upload{}, err + if err := repository.UpdateUpload(ctx, &u, updates); err != nil { + return models.Upload{}, err } if name, ok := updates["file_name"].(string); ok { - upload.FileName = name + u.FileName = name } if mode, ok := updates["access_mode"].(int); ok { - upload.AccessMode = mode + u.AccessMode = mode } - return upload, nil + return u, nil } -func listUploadsForBatchDownload(ctx context.Context, ids []uint64) ([]model.Upload, error) { +func listUploadsForBatchDownload(ctx context.Context, ids []uint64) ([]models.Upload, error) { return repository.ListUploadsByIDs(ctx, ids) } -func loadUploadStats(ctx context.Context) ([]model.UploadStat, error) { +func loadUploadStats(ctx context.Context) ([]models.UploadStat, error) { return repository.ListUploadStats(ctx) } diff --git a/plugins/domain/upload/handler/routers.go b/plugins/domain/upload/handler/routers.go index 4cfc8021..ca1a4833 100644 --- a/plugins/domain/upload/handler/routers.go +++ b/plugins/domain/upload/handler/routers.go @@ -22,13 +22,14 @@ import ( "strconv" "strings" - "github.com/Rain-kl/Wavelet/internal/model" - appshared "github.com/Rain-kl/Wavelet/internal/shared" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/core/contracts" "github.com/Rain-kl/Wavelet/pkg/logger" + "github.com/Rain-kl/Wavelet/pkg/response" + appshared "github.com/Rain-kl/Wavelet/pkg/shared" "github.com/Rain-kl/Wavelet/plugins/domain/auth" "github.com/Rain-kl/Wavelet/plugins/domain/upload/filesrv" "github.com/Rain-kl/Wavelet/plugins/domain/upload/ingest" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" "github.com/Rain-kl/Wavelet/plugins/domain/upload/shared" uploadstorage "github.com/Rain-kl/Wavelet/plugins/domain/upload/storage" "github.com/Rain-kl/Wavelet/plugins/domain/upload/util" @@ -50,7 +51,7 @@ type batchDownloadRequest struct { // @Param type formData string false "业务分类 (例如: avatar, attachment, doc,默认为 generic)" // @Param metadata formData string false "额外的 JSON 格式元数据" // @Security SessionCookie -// @Success 200 {object} response.Any{data=model.Upload} "上传成功" +// @Success 200 {object} response.Any{data=models.Upload} "上传成功" // @Failure 400 {object} response.Any "请求参数错误或文件受限" // @Failure 401 {object} response.Any "未登录" // @Failure 500 {object} response.Any "内部错误" @@ -63,7 +64,7 @@ func UploadFile(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, shared.MaxUploadSize) - currUser, _ := auth.GetFromContext[*model.User](c, auth.UserObjKey) + currUser, _ := auth.GetFromContext[*contracts.UserDTO](c, auth.UserObjKey) ctx := c.Request.Context() header, err := c.FormFile("file") @@ -310,8 +311,8 @@ func resolveUploadAccessMode(c *gin.Context, uploadType string) (int, string) { return accessMode, "" } -func parseUploadMetadata(c *gin.Context, mimeType string) (model.UploadMetadata, string) { - var meta model.UploadMetadata +func parseUploadMetadata(c *gin.Context, mimeType string) (models.UploadMetadata, string) { + var meta models.UploadMetadata metadataStr := c.DefaultPostForm("metadata", "") if metadataStr != "" { if err := json.Unmarshal([]byte(metadataStr), &meta); err != nil { diff --git a/plugins/domain/upload/handler/routers_test.go b/plugins/domain/upload/handler/routers_test.go index 5a3d49d5..9aa252b2 100644 --- a/plugins/domain/upload/handler/routers_test.go +++ b/plugins/domain/upload/handler/routers_test.go @@ -19,15 +19,14 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/Rain-kl/Wavelet/pkg/response" + "github.com/Rain-kl/Wavelet/pkg/testhelper" "github.com/Rain-kl/Wavelet/plugins/domain/auth" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" "github.com/Rain-kl/Wavelet/plugins/domain/upload/shared" uploadstats "github.com/Rain-kl/Wavelet/plugins/domain/upload/stats" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore" "github.com/gin-gonic/gin" ) @@ -36,7 +35,7 @@ type testResponse struct { Data json.RawMessage `json:"data"` } -func setupTestRouter(authUser *model.User) *gin.Engine { +func setupTestRouter(authUser *contracts.UserDTO) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() r.Use(response.ErrorHandlerMiddleware()) @@ -106,7 +105,7 @@ func TestUploadFile(t *testing.T) { defer cleanup() defer func() { _ = os.RemoveAll("uploads") }() // Clean up local files created during tests - authUser := &model.User{ID: 1001, Username: "test_user"} + authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"} router := setupTestRouter(authUser) // Mock Storage Client @@ -175,12 +174,12 @@ func TestUploadFile(t *testing.T) { } // Verify database record - var uploadRecord model.Upload + var uploadRecord models.Upload if err := json.Unmarshal(resp.Data, &uploadRecord); err != nil { t.Fatalf("failed to unmarshal upload record: %v", err) } - var dbRecord model.Upload + var dbRecord models.Upload if err := dbConn.First(&dbRecord, uploadRecord.ID).Error; err != nil { t.Fatalf("failed to retrieve database record: %v", err) } @@ -258,7 +257,7 @@ func TestUploadFile(t *testing.T) { t.Fatalf("second upload was unsuccessful: %s", resp2.ErrorMsg) } - var uploadRecord2 model.Upload + var uploadRecord2 models.Upload if err := json.Unmarshal(resp2.Data, &uploadRecord2); err != nil { t.Fatalf("failed to unmarshal second upload record: %v", err) } @@ -269,7 +268,7 @@ func TestUploadFile(t *testing.T) { } // Check if database contains both records sharing the same FilePath - var records []model.Upload + var records []models.Upload dbConn.Where("hash = ?", uploadRecord2.Hash).Find(&records) if len(records) != 2 { t.Errorf("expected 2 database records sharing the same hash, got %d", len(records)) @@ -289,12 +288,7 @@ func TestUploadFile(t *testing.T) { objectstore.IsEnabledFunc = func() bool { return false } // Seed allowed extensions configuration to allow txt files - var sc model.SystemConfig - dbConn.Where("key = ?", model.ConfigKeyUploadAllowedExtensions).First(&sc) - sc.Value = "jpg,png,webp,txt" - dbConn.Save(&sc) - _ = db.HSetJSON(context.Background(), repository.SystemConfigRedisHashKey, sc.Key, &sc) - repository.ResetSystemConfigRAMCacheForTest() + dbConn.Table("w_system_configs").Where("key = ?", "upload_allowed_extensions").Update("value", "jpg,png,webp,txt") contentType, body := createMultipartRequest(t, "file", "doc.txt", []byte("hello world generic document file"), map[string]string{ "type": "document", @@ -316,7 +310,7 @@ func TestUploadFile(t *testing.T) { t.Fatalf("local upload failed: %s", resp.ErrorMsg) } - var localRecord model.Upload + var localRecord models.Upload if err := json.Unmarshal(resp.Data, &localRecord); err != nil { t.Fatalf("failed to unmarshal local upload record: %v", err) } @@ -338,11 +332,11 @@ func TestDownloadFile(t *testing.T) { defer cleanup() defer func() { _ = os.RemoveAll("uploads") }() - authUser := &model.User{ID: 1001, Username: "test_user"} + authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"} router := setupTestRouter(authUser) // Seed upload records in DB - localUpload := model.Upload{ + localUpload := models.Upload{ ID: 2001, UserID: 1001, FileName: "中文文件名.txt", @@ -350,7 +344,7 @@ func TestDownloadFile(t *testing.T) { FileSize: 12, MimeType: "text/plain", Extension: "txt", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, } // Create local file @@ -405,10 +399,10 @@ func TestListFiles(t *testing.T) { dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) defer cleanup() - authUser := &model.User{ID: 1001, Username: "test_user"} + authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"} router := setupTestRouter(authUser) - uploads := []model.Upload{ + uploads := []models.Upload{ { ID: 2101, UserID: authUser.ID, @@ -417,7 +411,7 @@ func TestListFiles(t *testing.T) { FileSize: 10, MimeType: "text/plain", Extension: "txt", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, }, { ID: 2102, @@ -427,7 +421,7 @@ func TestListFiles(t *testing.T) { FileSize: 20, MimeType: "image/png", Extension: "png", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, }, { ID: 2103, @@ -437,7 +431,7 @@ func TestListFiles(t *testing.T) { FileSize: 30, MimeType: "text/markdown", Extension: "md", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, }, { ID: 2104, @@ -447,7 +441,7 @@ func TestListFiles(t *testing.T) { FileSize: 40, MimeType: "text/plain", Extension: "txt", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, }, } for i := range uploads { @@ -546,7 +540,7 @@ func TestBatchDownloadFiles(t *testing.T) { defer cleanup() defer func() { _ = os.RemoveAll("uploads") }() - authUser := &model.User{ID: 1001, Username: "test_user"} + authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"} router := setupTestRouter(authUser) // Create and write files locally @@ -560,7 +554,7 @@ func TestBatchDownloadFiles(t *testing.T) { _ = os.WriteFile("uploads/f3.txt", []byte("duplicate name file content"), 0644) // Seed upload records. Note f2 and f3 have the same FileName "file_a.txt" to trigger name collision resolution. - uploads := []model.Upload{ + uploads := []models.Upload{ { ID: 3001, UserID: 1001, @@ -569,7 +563,7 @@ func TestBatchDownloadFiles(t *testing.T) { FileSize: 13, MimeType: "text/plain", Extension: "txt", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, }, { ID: 3002, @@ -579,7 +573,7 @@ func TestBatchDownloadFiles(t *testing.T) { FileSize: 13, MimeType: "text/plain", Extension: "txt", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, }, { ID: 3003, @@ -589,7 +583,7 @@ func TestBatchDownloadFiles(t *testing.T) { FileSize: 28, MimeType: "text/plain", Extension: "txt", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, }, } @@ -658,8 +652,8 @@ func TestUploadAccessModeAccessControl(t *testing.T) { defer cleanup() defer func() { _ = os.RemoveAll("uploads") }() - user1 := &model.User{ID: 1001, Username: "user1"} - user2 := &model.User{ID: 1002, Username: "user2"} + user1 := &contracts.UserDTO{ID: 1001, Username: "user1"} + user2 := &contracts.UserDTO{ID: 1002, Username: "user2"} // Seed user1 if err := dbConn.Create(user1).Error; err != nil { @@ -689,7 +683,7 @@ func TestUploadAccessModeAccessControl(t *testing.T) { t.Logf("Raw upload response: %s", w.Body.String()) var resp1 testResponse _ = json.Unmarshal(w.Body.Bytes(), &resp1) - var upload1 model.Upload + var upload1 models.Upload _ = json.Unmarshal(resp1.Data, &upload1) if upload1.AccessMode != 0 { @@ -707,7 +701,7 @@ func TestUploadAccessModeAccessControl(t *testing.T) { router.ServeHTTP(w2, req2) var resp2 testResponse _ = json.Unmarshal(w2.Body.Bytes(), &resp2) - var upload2 model.Upload + var upload2 models.Upload _ = json.Unmarshal(resp2.Data, &upload2) if upload2.AccessMode != 1 { @@ -744,11 +738,11 @@ func TestGetFileStats(t *testing.T) { dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) defer cleanup() - authUser := &model.User{ID: 1001, Username: "test_user"} + authUser := &contracts.UserDTO{ID: 1001, Username: "test_user"} router := setupTestRouter(authUser) // Insert some dummy uploads - uploads := []model.Upload{ + uploads := []models.Upload{ { ID: 3101, UserID: authUser.ID, @@ -758,7 +752,7 @@ func TestGetFileStats(t *testing.T) { MimeType: "image/png", Extension: "png", Type: "generic", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, CreatedAt: time.Now(), }, { @@ -770,7 +764,7 @@ func TestGetFileStats(t *testing.T) { MimeType: "video/mp4", Extension: "mp4", Type: "generic", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, CreatedAt: time.Now().AddDate(0, 0, -2), // 2 days ago }, { @@ -782,7 +776,7 @@ func TestGetFileStats(t *testing.T) { MimeType: "application/pdf", Extension: "pdf", Type: "avatar", // different type - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, CreatedAt: time.Now().AddDate(0, 0, -10), // older than 7 days }, } @@ -854,8 +848,8 @@ func TestUserUploadManagement(t *testing.T) { dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) defer cleanup() - user1 := &model.User{ID: 1001, Username: "user1"} - user2 := &model.User{ID: 1002, Username: "user2"} + user1 := &contracts.UserDTO{ID: 1001, Username: "user1"} + user2 := &contracts.UserDTO{ID: 1002, Username: "user2"} _ = dbConn.Create(user1) _ = dbConn.Create(user2) @@ -864,7 +858,7 @@ func TestUserUploadManagement(t *testing.T) { router2 := setupTestRouter(user2) // Seed upload records - upload1 := model.Upload{ + upload1 := models.Upload{ ID: 4001, UserID: 1001, FileName: "user1-file.txt", @@ -872,10 +866,10 @@ func TestUserUploadManagement(t *testing.T) { FileSize: 100, MimeType: "text/plain", Extension: "txt", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, CreatedAt: time.Now(), } - upload2 := model.Upload{ + upload2 := models.Upload{ ID: 4002, UserID: 1002, FileName: "user2-file.png", @@ -883,7 +877,7 @@ func TestUserUploadManagement(t *testing.T) { FileSize: 200, MimeType: "image/png", Extension: "png", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, CreatedAt: time.Now(), } @@ -927,7 +921,7 @@ func TestUserUploadManagement(t *testing.T) { t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String()) } - var updated model.Upload + var updated models.Upload dbConn.First(&updated, 4001) if updated.FileName != "renamed.txt" { t.Errorf("expected file name renamed.txt, got %s", updated.FileName) @@ -970,9 +964,9 @@ func TestUserUploadManagement(t *testing.T) { t.Fatalf("expected status 200, got %d", w.Code) } - var deleted model.Upload + var deleted models.Upload dbConn.First(&deleted, 4001) - if deleted.Status != model.UploadStatusDeleted { + if deleted.Status != models.UploadStatusDeleted { t.Errorf("expected status deleted, got %s", deleted.Status) } }) diff --git a/plugins/domain/upload/handler/stats.go b/plugins/domain/upload/handler/stats.go index 7c23bfac..1f3da760 100644 --- a/plugins/domain/upload/handler/stats.go +++ b/plugins/domain/upload/handler/stats.go @@ -7,10 +7,10 @@ import ( "net/http" "time" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/shared/response" - "github.com/Rain-kl/Wavelet/plugins/domain/upload/shared" "github.com/gin-gonic/gin" + + "github.com/Rain-kl/Wavelet/pkg/response" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/shared" ) type trendItem struct { @@ -79,22 +79,22 @@ func GetFileStats(c *gin.Context) { for _, stat := range stats { switch stat.Dimension { - case model.UploadStatDimensionTotal: + case shared.UploadStatDimensionTotal: totalCount = stat.FileCount totalSize = stat.FileSize - case model.UploadStatDimensionType: + case shared.UploadStatDimensionType: types = append(types, distributionItem{ Name: stat.StatKey, Count: stat.FileCount, Size: stat.FileSize, }) - case model.UploadStatDimensionCategory: + case shared.UploadStatDimensionCategory: if item, ok := categoryMap[stat.StatKey]; ok { item.Count = stat.FileCount item.Size = stat.FileSize categoryMap[stat.StatKey] = item } - case model.UploadStatDimensionTrend: + case shared.UploadStatDimensionTrend: if _, ok := trendCountMap[stat.StatKey]; ok { trendCountMap[stat.StatKey] = stat.FileCount trendSizeMap[stat.StatKey] = stat.FileSize diff --git a/plugins/domain/upload/ingest/helpers.go b/plugins/domain/upload/ingest/helpers.go index b54b8173..a9e1239d 100644 --- a/plugins/domain/upload/ingest/helpers.go +++ b/plugins/domain/upload/ingest/helpers.go @@ -5,22 +5,23 @@ package ingest import ( "context" + "encoding/json" "errors" "fmt" "io" "strings" "time" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" "github.com/Rain-kl/Wavelet/pkg/logger" + "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/persistence/idgen" uploadcache "github.com/Rain-kl/Wavelet/plugins/domain/upload/cache" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/repository" "github.com/Rain-kl/Wavelet/plugins/domain/upload/shared" uploadstats "github.com/Rain-kl/Wavelet/plugins/domain/upload/stats" uploadstorage "github.com/Rain-kl/Wavelet/plugins/domain/upload/storage" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore" "gorm.io/gorm" ) @@ -33,7 +34,7 @@ func normalizeRequest(req *Request) { req.Type = "generic" } if req.Status == "" { - req.Status = model.UploadStatusUsed + req.Status = models.UploadStatusUsed } } @@ -48,18 +49,30 @@ func resolveAccessMode(uploadType string, explicit *int) int { } func validateAllowedExtension(ctx context.Context, ext string) error { - sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyUploadAllowedExtensions) + var val string + err := db.DB(ctx).Table("w_system_configs").Where("key = ?", "upload_allowed_extensions").Pluck("value", &val).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil } - return err + logger.WarnF(ctx, "failed to query upload_allowed_extensions: %v", err) + return nil } - if sc.Value == "" { + if val == "" { return nil } - allowedExts := strings.Split(strings.ToLower(sc.Value), ",") + var list []string + if err := json.Unmarshal([]byte(val), &list); err == nil { + for _, allowedExt := range list { + if strings.EqualFold(strings.TrimSpace(allowedExt), ext) { + return nil + } + } + return errors.New(shared.ErrUnsupportedFormat) + } + + allowedExts := strings.Split(strings.ToLower(val), ",") for _, allowedExt := range allowedExts { if strings.TrimSpace(allowedExt) == ext { return nil @@ -79,7 +92,7 @@ func buildObjectKey(req Request, id uint64) string { return defaultObjectKey(id, req.Extension) } -func storeObject(ctx context.Context, objectKey string, reader io.Reader, size int64, mimeType string, meta *model.UploadMetadata) (string, error) { +func storeObject(ctx context.Context, objectKey string, reader io.Reader, size int64, mimeType string, meta *models.UploadMetadata) (string, error) { if uploadstorage.ReadOnly(ctx) { return "", ErrStorageReadOnly } @@ -100,7 +113,7 @@ func storeObject(ctx context.Context, objectKey string, reader io.Reader, size i return result.Key, nil } -func persistUploadRecord(ctx context.Context, upload *model.Upload, objectKey string) error { +func persistUploadRecord(ctx context.Context, upload *models.Upload, objectKey string) error { if err := createUploadWithStats(ctx, upload); err != nil { _, backend, backendErr := objectstore.Active(ctx) if backendErr == nil { @@ -114,7 +127,7 @@ func persistUploadRecord(ctx context.Context, upload *model.Upload, objectKey st return nil } -func createUploadWithStats(ctx context.Context, upload *model.Upload) error { +func createUploadWithStats(ctx context.Context, upload *models.Upload) error { return db.DB(ctx).Transaction(func(tx *gorm.DB) error { if err := repository.CreateUploadTx(tx, upload); err != nil { return err @@ -123,9 +136,9 @@ func createUploadWithStats(ctx context.Context, upload *model.Upload) error { }) } -func createDedupRecord(ctx context.Context, existing model.Upload, req Request) (Result, error) { +func createDedupRecord(ctx context.Context, existing models.Upload, req Request) (Result, error) { accessMode := resolveAccessMode(req.Type, req.AccessMode) - newUpload := model.Upload{ + newUpload := models.Upload{ ID: idgen.NextUint64ID(), UserID: req.UserID, FileName: req.FileName, @@ -172,7 +185,7 @@ func createNewUpload(ctx context.Context, req Request) (Result, error) { } accessMode := resolveAccessMode(req.Type, req.AccessMode) - upload := model.Upload{ + upload := models.Upload{ ID: id, UserID: req.UserID, FileName: req.FileName, diff --git a/plugins/domain/upload/ingest/ingest.go b/plugins/domain/upload/ingest/ingest.go index 8aa8e1f2..60b3fdc3 100644 --- a/plugins/domain/upload/ingest/ingest.go +++ b/plugins/domain/upload/ingest/ingest.go @@ -7,8 +7,8 @@ import ( "context" "errors" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/repository" "gorm.io/gorm" ) @@ -36,7 +36,7 @@ func Ingest(ctx context.Context, req Request) (Result, error) { } // FindByHash returns a reusable active upload with the same hash and size. -func FindByHash(ctx context.Context, hash string, size int64) (model.Upload, error) { +func FindByHash(ctx context.Context, hash string, size int64) (models.Upload, error) { return repository.FindReusableUploadByHash(ctx, hash, size) } diff --git a/plugins/domain/upload/ingest/ingest_test.go b/plugins/domain/upload/ingest/ingest_test.go index fd8276dc..382c5bd2 100644 --- a/plugins/domain/upload/ingest/ingest_test.go +++ b/plugins/domain/upload/ingest/ingest_test.go @@ -13,10 +13,10 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/testhelper" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore" ) func TestIngestPolicyCreateIncrementsStats(t *testing.T) { @@ -67,7 +67,7 @@ func TestIngestPolicyResolveExistingSkipsStatsOnHit(t *testing.T) { hash := sha256.Sum256(content) hashStr := hex.EncodeToString(hash[:]) - existing := model.Upload{ + existing := models.Upload{ ID: 88001, UserID: 42, FileName: "existing.png", @@ -77,7 +77,7 @@ func TestIngestPolicyResolveExistingSkipsStatsOnHit(t *testing.T) { Extension: "png", Hash: hashStr, Type: "pixez_mirror", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, CreatedAt: time.Now(), } if err := dbConn.Create(&existing).Error; err != nil { @@ -175,7 +175,7 @@ func TestIngestPolicyDedupNewRecordCreatesSecondRecord(t *testing.T) { } var count int64 - if err := dbConn.Model(&model.Upload{}).Where("hash = ?", hashStr).Count(&count).Error; err != nil { + if err := dbConn.Model(&models.Upload{}).Where("hash = ?", hashStr).Count(&count).Error; err != nil { t.Fatalf("count uploads failed: %v", err) } if count != 2 { @@ -188,7 +188,7 @@ func TestCreateUploadWithStatsRollsBackOnCreateFailure(t *testing.T) { defer cleanup() ctx := context.Background() - existing := model.Upload{ + existing := models.Upload{ ID: 99001, UserID: 1001, FileName: "existing.png", @@ -197,14 +197,14 @@ func TestCreateUploadWithStatsRollsBackOnCreateFailure(t *testing.T) { MimeType: "image/png", Extension: "png", Type: "generic", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, CreatedAt: time.Now(), } if err := dbConn.Create(&existing).Error; err != nil { t.Fatalf("seed upload failed: %v", err) } - duplicate := &model.Upload{ + duplicate := &models.Upload{ ID: existing.ID, UserID: 1002, FileName: "duplicate.png", @@ -213,7 +213,7 @@ func TestCreateUploadWithStatsRollsBackOnCreateFailure(t *testing.T) { MimeType: "image/png", Extension: "png", Type: "generic", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, CreatedAt: time.Now(), } if err := createUploadWithStats(ctx, duplicate); err == nil { @@ -275,8 +275,8 @@ type totalStatsSnapshot struct { } func loadTotalStats(ctx context.Context) (totalStatsSnapshot, error) { - var rows []model.UploadStat - if err := db.DB(ctx).Where("dimension = ?", model.UploadStatDimensionTotal).Find(&rows).Error; err != nil { + var rows []models.UploadStat + if err := db.DB(ctx).Where("dimension = ?", models.UploadStatDimensionTotal).Find(&rows).Error; err != nil { return totalStatsSnapshot{}, err } if len(rows) == 0 { diff --git a/plugins/domain/upload/ingest/remove.go b/plugins/domain/upload/ingest/remove.go index eb298127..cbbfd703 100644 --- a/plugins/domain/upload/ingest/remove.go +++ b/plugins/domain/upload/ingest/remove.go @@ -6,44 +6,44 @@ package ingest import ( "context" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/Rain-kl/Wavelet/pkg/persistence" uploadcache "github.com/Rain-kl/Wavelet/plugins/domain/upload/cache" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/repository" uploadstats "github.com/Rain-kl/Wavelet/plugins/domain/upload/stats" "gorm.io/gorm" ) // Remove soft-deletes an upload and decrements incremental stats. -func Remove(ctx context.Context, uploadID uint64) (model.Upload, error) { +func Remove(ctx context.Context, uploadID uint64) (models.Upload, error) { upload, err := repository.GetActiveUploadByID(ctx, uploadID) if err != nil { - return model.Upload{}, err + return models.Upload{}, err } if err := softDeleteUploadWithStats(ctx, &upload); err != nil { - return model.Upload{}, err + return models.Upload{}, err } - upload.Status = model.UploadStatusDeleted + upload.Status = models.UploadStatusDeleted return upload, nil } // RemoveOwned soft-deletes an upload owned by userID and decrements incremental stats. -func RemoveOwned(ctx context.Context, userID, uploadID uint64) (model.Upload, error) { +func RemoveOwned(ctx context.Context, userID, uploadID uint64) (models.Upload, error) { upload, err := repository.GetActiveUploadByID(ctx, uploadID) if err != nil { - return model.Upload{}, err + return models.Upload{}, err } if upload.UserID != userID { - return model.Upload{}, ErrForbidden + return models.Upload{}, ErrForbidden } if err := softDeleteUploadWithStats(ctx, &upload); err != nil { - return model.Upload{}, err + return models.Upload{}, err } - upload.Status = model.UploadStatusDeleted + upload.Status = models.UploadStatusDeleted return upload, nil } -func softDeleteUploadWithStats(ctx context.Context, upload *model.Upload) error { +func softDeleteUploadWithStats(ctx context.Context, upload *models.Upload) error { statsSnapshot := *upload if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error { if err := repository.SoftDeleteUploadTx(tx, upload); err != nil { diff --git a/plugins/domain/upload/ingest/types.go b/plugins/domain/upload/ingest/types.go index 00bc22b3..8575852b 100644 --- a/plugins/domain/upload/ingest/types.go +++ b/plugins/domain/upload/ingest/types.go @@ -7,7 +7,7 @@ package ingest import ( "io" - "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" ) // Policy controls how ingest handles hash collisions and record creation. @@ -33,7 +33,7 @@ type Request struct { Type string AccessMode *int - Status model.UploadStatus + Status models.UploadStatus Reader io.Reader Size int64 @@ -42,7 +42,7 @@ type Request struct { Extension string Hash string - Metadata model.UploadMetadata + Metadata models.UploadMetadata Policy Policy ObjectKeyFn ObjectKeyFn @@ -53,7 +53,7 @@ type Request struct { // Result reports the outcome of an ingest operation. type Result struct { - Upload model.Upload + Upload models.Upload Created bool Stored bool Resolved bool diff --git a/plugins/domain/upload/models.go b/plugins/domain/upload/models.go new file mode 100644 index 00000000..c64ee407 --- /dev/null +++ b/plugins/domain/upload/models.go @@ -0,0 +1,34 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package upload 提供上传域的门面与类型重导出。 +package upload + +import ( + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" +) + +// UploadStatus 上传状态类型别名 +// +//nolint:revive +type UploadStatus = models.UploadStatus + +// UploadMetadata 上传元数据类型别名 +// +//nolint:revive +type UploadMetadata = models.UploadMetadata + +// Upload 上传实体类型别名 +type Upload = models.Upload + +// UploadStat 上传统计实体类型别名 +// +//nolint:revive +type UploadStat = models.UploadStat + +// 上传状态常量别名 +const ( + UploadStatusPending = models.UploadStatusPending + UploadStatusUsed = models.UploadStatusUsed + UploadStatusDeleted = models.UploadStatusDeleted +) diff --git a/plugins/domain/upload/models/models.go b/plugins/domain/upload/models/models.go new file mode 100644 index 00000000..d164f8e7 --- /dev/null +++ b/plugins/domain/upload/models/models.go @@ -0,0 +1,77 @@ +// Copyright 2025 linux.do +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package models 提供上传域核心数据模型。 +package models + +import ( + "time" +) + +// UploadStatus 上传状态 +type UploadStatus string + +// 上传状态 +const ( + UploadStatusPending UploadStatus = "pending" // 待使用 + UploadStatusUsed UploadStatus = "used" // 已使用 + UploadStatusDeleted UploadStatus = "deleted" // 已删除 +) + +// UploadMetadata 自定义可扩展的 JSON 字段存储非核心或可选的文件元数据 +type UploadMetadata struct { + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Duration float64 `json:"duration,omitempty"` + OriginalMime string `json:"original_mime,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + ClientIP string `json:"client_ip,omitempty"` + Bucket string `json:"bucket,omitempty"` + Extra map[string]any `json:"extra,omitempty"` +} + +// Upload 上传文件记录 +type Upload struct { + ID uint64 `json:"id,string" gorm:"primaryKey"` + UserID uint64 `json:"user_id,string" gorm:"index;not null"` + FileName string `json:"file_name" gorm:"size:255;not null"` + FilePath string `json:"file_path" gorm:"size:500;not null;index"` + FileSize int64 `json:"file_size" gorm:"not null"` + MimeType string `json:"mime_type" gorm:"size:100;not null"` + Extension string `json:"extension" gorm:"size:50;not null"` + Hash string `json:"hash" gorm:"size:64;index"` + Type string `json:"type" gorm:"column:type;size:50;not null;index"` + Status UploadStatus `json:"status" gorm:"type:varchar(20);not null"` + AccessMode int `json:"access_mode" gorm:"column:access_mode;not null;default:0"` + Metadata UploadMetadata `json:"metadata" gorm:"serializer:json;type:jsonb"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// TableName 表名 +func (Upload) TableName() string { + return "w_uploads" +} + +// Upload stats dimension keys stored in w_upload_stats.dimension. +const ( + UploadStatDimensionTotal = "total" + UploadStatDimensionType = "type" + UploadStatDimensionCategory = "category" + UploadStatDimensionTrend = "trend" +) + +// UploadStat 聚合统计记录 +type UploadStat struct { + Dimension string `json:"dimension" gorm:"primaryKey;size:32;not null"` + StatKey string `json:"stat_key" gorm:"primaryKey;size:64;not null;default:''"` + FileCount int64 `json:"file_count" gorm:"not null;default:0"` + FileSize int64 `json:"file_size" gorm:"not null;default:0"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// TableName 表名 +func (UploadStat) TableName() string { + return "w_upload_stats" +} diff --git a/internal/repository/upload.go b/plugins/domain/upload/repository.go similarity index 64% rename from internal/repository/upload.go rename to plugins/domain/upload/repository.go index 9e311cdd..b4fbca1c 100644 --- a/internal/repository/upload.go +++ b/plugins/domain/upload/repository.go @@ -1,19 +1,22 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -package repository +package upload import ( "context" "strings" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/pkg/util" "gorm.io/gorm" + + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/persistence/idgen" + "github.com/Rain-kl/Wavelet/pkg/util" ) // UploadListFilter filters paginated upload queries. +// +//nolint:revive type UploadListFilter struct { UserID uint64 Keyword string @@ -24,9 +27,9 @@ type UploadListFilter struct { } // ListUploads returns paginated upload records matching the filter. -func ListUploads(ctx context.Context, filter UploadListFilter) (int64, []model.Upload, error) { - query := db.DB(ctx).Model(&model.Upload{}). - Where("status != ?", model.UploadStatusDeleted) +func ListUploads(ctx context.Context, filter UploadListFilter) (int64, []Upload, error) { + query := db.DB(ctx).Model(&Upload{}). + Where("status != ?", UploadStatusDeleted) if filter.UserID != 0 { query = query.Where("user_id = ?", filter.UserID) @@ -46,7 +49,7 @@ func ListUploads(ctx context.Context, filter UploadListFilter) (int64, []model.U return 0, nil, err } - var items []model.Upload + var items []Upload offset := (filter.Page - 1) * filter.PageSize if err := query.Order("created_at DESC").Offset(offset).Limit(filter.PageSize).Find(&items).Error; err != nil { return 0, nil, err @@ -55,27 +58,27 @@ func ListUploads(ctx context.Context, filter UploadListFilter) (int64, []model.U } // GetActiveUploadByID loads a non-deleted upload by ID. -func GetActiveUploadByID(ctx context.Context, id uint64) (model.Upload, error) { - var upload model.Upload - if err := db.DB(ctx).Where("id = ? AND status != ?", id, model.UploadStatusDeleted).First(&upload).Error; err != nil { - return model.Upload{}, err +func GetActiveUploadByID(ctx context.Context, id uint64) (Upload, error) { + var upload Upload + if err := db.DB(ctx).Where("id = ? AND status != ?", id, UploadStatusDeleted).First(&upload).Error; err != nil { + return Upload{}, err } return upload, nil } // SoftDeleteUpload marks an upload as deleted. // External modules must use upload.Remove or upload.RemoveOwned; only internal/apps/upload may call this. -func SoftDeleteUpload(ctx context.Context, upload *model.Upload) error { +func SoftDeleteUpload(ctx context.Context, upload *Upload) error { return SoftDeleteUploadTx(db.DB(ctx), upload) } // SoftDeleteUploadTx marks an upload as deleted within an existing transaction. -func SoftDeleteUploadTx(tx *gorm.DB, upload *model.Upload) error { - return tx.Model(upload).Update("status", model.UploadStatusDeleted).Error +func SoftDeleteUploadTx(tx *gorm.DB, upload *Upload) error { + return tx.Model(upload).Update("status", UploadStatusDeleted).Error } // UpdateUpload applies partial field updates to an upload record. -func UpdateUpload(ctx context.Context, upload *model.Upload, updates map[string]any) error { +func UpdateUpload(ctx context.Context, upload *Upload, updates map[string]any) error { if len(updates) == 0 { return nil } @@ -85,7 +88,7 @@ func UpdateUpload(ctx context.Context, upload *model.Upload, updates map[string] // ListDistinctUploadTypes returns all distinct non-empty upload business types. func ListDistinctUploadTypes(ctx context.Context) ([]string, error) { var types []string - if err := db.DB(ctx).Model(&model.Upload{}). + if err := db.DB(ctx).Model(&Upload{}). Where("type IS NOT NULL AND type != ''"). Distinct(). Pluck("type", &types).Error; err != nil { @@ -95,30 +98,32 @@ func ListDistinctUploadTypes(ctx context.Context) ([]string, error) { } // FindReusableUploadByHash finds an existing upload with the same hash and size. -func FindReusableUploadByHash(ctx context.Context, hash string, size int64) (model.Upload, error) { - var existing model.Upload +func FindReusableUploadByHash(ctx context.Context, hash string, size int64) (Upload, error) { + var existing Upload err := db.DB(ctx). - Where("hash = ? AND file_size = ? AND status IN (?, ?)", hash, size, model.UploadStatusPending, model.UploadStatusUsed). + Where("hash = ? AND file_size = ? AND status IN (?, ?)", hash, size, UploadStatusPending, UploadStatusUsed). First(&existing).Error return existing, err } // CreateUpload persists a new upload record. -// External modules must use upload.Ingest; only internal/apps/upload may call this. -func CreateUpload(ctx context.Context, upload *model.Upload) error { +func CreateUpload(ctx context.Context, upload *Upload) error { return CreateUploadTx(db.DB(ctx), upload) } // CreateUploadTx persists a new upload record within an existing transaction. -func CreateUploadTx(tx *gorm.DB, upload *model.Upload) error { +func CreateUploadTx(tx *gorm.DB, upload *Upload) error { + if upload.ID == 0 { + upload.ID = idgen.NextUint64ID() + } return tx.Create(upload).Error } // ListUploadsByIDs returns active uploads matching the given IDs. -func ListUploadsByIDs(ctx context.Context, ids []uint64) ([]model.Upload, error) { - var uploads []model.Upload +func ListUploadsByIDs(ctx context.Context, ids []uint64) ([]Upload, error) { + var uploads []Upload if err := db.DB(ctx). - Where("id IN ? AND status IN (?, ?)", ids, model.UploadStatusPending, model.UploadStatusUsed). + Where("id IN ? AND status IN (?, ?)", ids, UploadStatusPending, UploadStatusUsed). Find(&uploads).Error; err != nil { return nil, err } @@ -126,6 +131,17 @@ func ListUploadsByIDs(ctx context.Context, ids []uint64) ([]model.Upload, error) } // UploadQuery returns a scoped GORM query for uploads. +// +//nolint:revive func UploadQuery(ctx context.Context) *gorm.DB { - return db.DB(ctx).Model(&model.Upload{}) + return db.DB(ctx).Model(&Upload{}) +} + +// ListUploadStats returns all upload statistics rows. +func ListUploadStats(ctx context.Context) ([]UploadStat, error) { + var stats []UploadStat + if err := db.DB(ctx).Find(&stats).Error; err != nil { + return nil, err + } + return stats, nil } diff --git a/plugins/domain/upload/repository/repository.go b/plugins/domain/upload/repository/repository.go new file mode 100644 index 00000000..2f322204 --- /dev/null +++ b/plugins/domain/upload/repository/repository.go @@ -0,0 +1,140 @@ +// Copyright 2025 linux.do +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package repository 提供上传域数据库仓储层操作。 +package repository + +import ( + "context" + "strings" + + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/util" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" + "gorm.io/gorm" +) + +// UploadListFilter filters paginated upload queries. +type UploadListFilter struct { + UserID uint64 + Keyword string + Type string + Extension string + Page int + PageSize int +} + +// ListUploads returns paginated upload records matching the filter. +func ListUploads(ctx context.Context, filter UploadListFilter) (int64, []models.Upload, error) { + query := db.DB(ctx).Model(&models.Upload{}). + Where("status != ?", models.UploadStatusDeleted) + + if filter.UserID != 0 { + query = query.Where("user_id = ?", filter.UserID) + } + if filter.Keyword != "" { + query = query.Where("LOWER(file_name) LIKE ? ESCAPE '\\'", "%"+util.EscapeLike(strings.ToLower(filter.Keyword))+"%") + } + if filter.Type != "" { + query = query.Where("type = ?", filter.Type) + } + if filter.Extension != "" { + query = query.Where("extension = ?", strings.ToLower(filter.Extension)) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return 0, nil, err + } + + var items []models.Upload + offset := (filter.Page - 1) * filter.PageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(filter.PageSize).Find(&items).Error; err != nil { + return 0, nil, err + } + return total, items, nil +} + +// GetActiveUploadByID loads a non-deleted upload by ID. +func GetActiveUploadByID(ctx context.Context, id uint64) (models.Upload, error) { + var upload models.Upload + if err := db.DB(ctx).Where("id = ? AND status != ?", id, models.UploadStatusDeleted).First(&upload).Error; err != nil { + return models.Upload{}, err + } + return upload, nil +} + +// SoftDeleteUpload marks an upload as deleted. +func SoftDeleteUpload(ctx context.Context, upload *models.Upload) error { + return SoftDeleteUploadTx(db.DB(ctx), upload) +} + +// SoftDeleteUploadTx marks an upload as deleted within an existing transaction. +func SoftDeleteUploadTx(tx *gorm.DB, upload *models.Upload) error { + return tx.Model(upload).Update("status", models.UploadStatusDeleted).Error +} + +// UpdateUpload applies partial field updates to an upload record. +func UpdateUpload(ctx context.Context, upload *models.Upload, updates map[string]any) error { + if len(updates) == 0 { + return nil + } + return db.DB(ctx).Model(upload).Updates(updates).Error +} + +// ListDistinctUploadTypes returns all distinct non-empty upload business types. +func ListDistinctUploadTypes(ctx context.Context) ([]string, error) { + var types []string + if err := db.DB(ctx).Model(&models.Upload{}). + Where("type IS NOT NULL AND type != ''"). + Distinct(). + Pluck("type", &types).Error; err != nil { + return nil, err + } + return types, nil +} + +// FindReusableUploadByHash finds an existing upload with the same hash and size. +func FindReusableUploadByHash(ctx context.Context, hash string, size int64) (models.Upload, error) { + var existing models.Upload + err := db.DB(ctx). + Where("hash = ? AND file_size = ? AND status IN (?, ?)", hash, size, models.UploadStatusPending, models.UploadStatusUsed). + First(&existing).Error + return existing, err +} + +// CreateUpload persists a new upload record. +func CreateUpload(ctx context.Context, upload *models.Upload) error { + return CreateUploadTx(db.DB(ctx), upload) +} + +// CreateUploadTx persists a new upload record within an existing transaction. +func CreateUploadTx(tx *gorm.DB, upload *models.Upload) error { + return tx.Create(upload).Error +} + +// ListUploadsByIDs returns active uploads matching the given IDs. +func ListUploadsByIDs(ctx context.Context, ids []uint64) ([]models.Upload, error) { + var uploads []models.Upload + if err := db.DB(ctx). + Where("id IN ? AND status IN (?, ?)", ids, models.UploadStatusPending, models.UploadStatusUsed). + Find(&uploads).Error; err != nil { + return nil, err + } + return uploads, nil +} + +// UploadQuery returns a scoped GORM query for uploads. +func UploadQuery(ctx context.Context) *gorm.DB { + return db.DB(ctx).Model(&models.Upload{}) +} + +// ListUploadStats returns all upload statistics rows. +func ListUploadStats(ctx context.Context) ([]models.UploadStat, error) { + var stats []models.UploadStat + if err := db.DB(ctx).Find(&stats).Error; err != nil { + return nil, err + } + return stats, nil +} diff --git a/plugins/domain/upload/shared/constants.go b/plugins/domain/upload/shared/constants.go index 14117af3..2c58bcec 100644 --- a/plugins/domain/upload/shared/constants.go +++ b/plugins/domain/upload/shared/constants.go @@ -17,4 +17,9 @@ const ( FileStatsTrendDays = 7 MaxS3KeyLength = 1024 AccessCacheTTL = 5 // seconds; multiplied by time.Second at use site + + UploadStatDimensionTotal = "total" + UploadStatDimensionType = "type" + UploadStatDimensionCategory = "category" + UploadStatDimensionTrend = "trend" ) diff --git a/plugins/domain/upload/shared/errs.go b/plugins/domain/upload/shared/errs.go index c37c864c..f552aa2c 100644 --- a/plugins/domain/upload/shared/errs.go +++ b/plugins/domain/upload/shared/errs.go @@ -38,4 +38,6 @@ const ( ErrInvalidImageCacheWarmupQuality = "图片质量仅支持 low、medium、high" ErrParseImageCacheWarmupPayload = "解析图片缓存预热参数失败: %w" ErrQueryImagesForCacheWarmup = "查询待预热图片失败: %w" + ErrQueryTypeListFailed = "查询文件类型列表失败" + ErrUpdateFileFailed = "更新文件失败" ) diff --git a/plugins/domain/upload/stats/stats_counter.go b/plugins/domain/upload/stats/stats_counter.go index c3fad02e..b4fd41b2 100644 --- a/plugins/domain/upload/stats/stats_counter.go +++ b/plugins/domain/upload/stats/stats_counter.go @@ -7,32 +7,32 @@ import ( "context" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/pkg/logger" + "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" "gorm.io/gorm" "gorm.io/gorm/clause" ) // ApplyUploadStatsAdd increments incremental stats for a newly active upload record. -func ApplyUploadStatsAdd(ctx context.Context, upload *model.Upload) error { +func ApplyUploadStatsAdd(ctx context.Context, upload *models.Upload) error { return applyUploadStatsDelta(ctx, upload, 1) } // ApplyUploadStatsRemove decrements incremental stats for a removed active upload record. -func ApplyUploadStatsRemove(ctx context.Context, upload *model.Upload) error { +func ApplyUploadStatsRemove(ctx context.Context, upload *models.Upload) error { return applyUploadStatsDelta(ctx, upload, -1) } // RebuildUploadStats rebuilds all incremental stats from current upload records. func RebuildUploadStats(ctx context.Context) error { return db.DB(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("1 = 1").Delete(&model.UploadStat{}).Error; err != nil { + if err := tx.Where("1 = 1").Delete(&models.UploadStat{}).Error; err != nil { return err } - var uploads []model.Upload - if err := tx.Where("status != ?", model.UploadStatusDeleted).Find(&uploads).Error; err != nil { + var uploads []models.Upload + if err := tx.Where("status != ?", models.UploadStatusDeleted).Find(&uploads).Error; err != nil { return err } @@ -45,7 +45,7 @@ func RebuildUploadStats(ctx context.Context) error { }) } -func applyUploadStatsDelta(ctx context.Context, upload *model.Upload, sign int64) error { +func applyUploadStatsDelta(ctx context.Context, upload *models.Upload, sign int64) error { if upload == nil || !isActiveUploadStatus(upload.Status) { return nil } @@ -55,7 +55,7 @@ func applyUploadStatsDelta(ctx context.Context, upload *model.Upload, sign int64 } // ApplyUploadStatsDeltaTx applies incremental upload stats within an existing transaction. -func ApplyUploadStatsDeltaTx(tx *gorm.DB, upload *model.Upload, sign int64) error { +func ApplyUploadStatsDeltaTx(tx *gorm.DB, upload *models.Upload, sign int64) error { if upload == nil || !isActiveUploadStatus(upload.Status) || sign == 0 { return nil } @@ -71,10 +71,10 @@ func ApplyUploadStatsDeltaTx(tx *gorm.DB, upload *model.Upload, sign int64) erro dimension string key string }{ - {model.UploadStatDimensionTotal, ""}, - {model.UploadStatDimensionType, typeKey}, - {model.UploadStatDimensionCategory, GetFileCategory(upload.MimeType, upload.Extension)}, - {model.UploadStatDimensionTrend, upload.CreatedAt.Format("2006-01-02")}, + {models.UploadStatDimensionTotal, ""}, + {models.UploadStatDimensionType, typeKey}, + {models.UploadStatDimensionCategory, GetFileCategory(upload.MimeType, upload.Extension)}, + {models.UploadStatDimensionTrend, upload.CreatedAt.Format("2006-01-02")}, } for _, entry := range entries { @@ -104,7 +104,7 @@ func upsertUploadStatDelta(tx *gorm.DB, dimension, key string, countDelta, sizeD ), "updated_at": time.Now(), }), - }).Create(&model.UploadStat{ + }).Create(&models.UploadStat{ Dimension: dimension, StatKey: key, FileCount: countDelta, @@ -113,19 +113,19 @@ func upsertUploadStatDelta(tx *gorm.DB, dimension, key string, countDelta, sizeD } // RecordUploadStatsAdd logs and applies upload stats increment. -func RecordUploadStatsAdd(ctx context.Context, upload *model.Upload) { +func RecordUploadStatsAdd(ctx context.Context, upload *models.Upload) { if err := ApplyUploadStatsAdd(ctx, upload); err != nil { logger.WarnF(ctx, "increment upload stats failed: %v", err) } } // RecordUploadStatsRemove logs and applies upload stats decrement. -func RecordUploadStatsRemove(ctx context.Context, upload *model.Upload) { +func RecordUploadStatsRemove(ctx context.Context, upload *models.Upload) { if err := ApplyUploadStatsRemove(ctx, upload); err != nil { logger.WarnF(ctx, "decrement upload stats failed: %v", err) } } -func isActiveUploadStatus(status model.UploadStatus) bool { - return status == model.UploadStatusPending || status == model.UploadStatusUsed +func isActiveUploadStatus(status models.UploadStatus) bool { + return status == models.UploadStatusPending || status == models.UploadStatusUsed } diff --git a/plugins/domain/upload/stats/stats_counter_test.go b/plugins/domain/upload/stats/stats_counter_test.go index 156eacf6..baf22efd 100644 --- a/plugins/domain/upload/stats/stats_counter_test.go +++ b/plugins/domain/upload/stats/stats_counter_test.go @@ -8,9 +8,9 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/testhelper" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" "gorm.io/gorm" ) @@ -19,13 +19,13 @@ func TestApplyUploadStatsDeltaTxWithinTransaction(t *testing.T) { defer cleanup() ctx := context.Background() - upload := &model.Upload{ + upload := &models.Upload{ ID: 42002, FileSize: 256, MimeType: "image/jpeg", Extension: "jpg", Type: "avatar", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, CreatedAt: time.Now(), } @@ -49,13 +49,13 @@ func TestApplyUploadStatsAddAndRemove(t *testing.T) { defer cleanup() ctx := context.Background() - upload := &model.Upload{ + upload := &models.Upload{ ID: 42001, FileSize: 128, MimeType: "image/png", Extension: "png", Type: "avatar", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, CreatedAt: time.Now(), } if err := ApplyUploadStatsAdd(ctx, upload); err != nil { @@ -89,8 +89,8 @@ type uploadStatsSnapshot struct { } func loadUploadStats(ctx context.Context) (uploadStatsSnapshot, error) { - var rows []model.UploadStat - if err := db.DB(ctx).Where("dimension = ?", model.UploadStatDimensionTotal).Find(&rows).Error; err != nil { + var rows []models.UploadStat + if err := db.DB(ctx).Where("dimension = ?", models.UploadStatDimensionTotal).Find(&rows).Error; err != nil { return uploadStatsSnapshot{}, err } if len(rows) == 0 { diff --git a/plugins/domain/upload/storage/access_state.go b/plugins/domain/upload/storage/access_state.go index ad45c22b..0011f82e 100644 --- a/plugins/domain/upload/storage/access_state.go +++ b/plugins/domain/upload/storage/access_state.go @@ -9,9 +9,10 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/pkg/task" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/shared" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore" ) // MigrationAccessState captures cached migration maintenance state. @@ -70,9 +71,9 @@ func buildMigrationAccessState(ctx context.Context) MigrationAccessState { } state := MigrationAccessState{ - ReadOnly: execution.Status != model.TaskExecutionStatusSucceeded, + ReadOnly: execution.Status != task.TaskExecutionStatusSucceeded, } - if execution.Status == model.TaskExecutionStatusSucceeded { + if execution.Status == task.TaskExecutionStatusSucceeded { return state } diff --git a/plugins/domain/upload/storage/migration.go b/plugins/domain/upload/storage/migration.go index 3fa6015b..df610ef3 100644 --- a/plugins/domain/upload/storage/migration.go +++ b/plugins/domain/upload/storage/migration.go @@ -10,17 +10,17 @@ import ( "fmt" "strings" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/Rain-kl/Wavelet/pkg/task" + + "github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore" ) // StorageMigrationTask is the Asynq task name for storage migration. const StorageMigrationTask = "storage:migrate" // LatestMigrationExecution returns the most recent storage migration task execution. -func LatestMigrationExecution(ctx context.Context) (*model.TaskExecution, bool, error) { - return repository.GetLatestTaskExecutionByTaskType(ctx, StorageMigrationTask) +func LatestMigrationExecution(ctx context.Context) (*task.TaskExecution, bool, error) { + return task.GetLatestTaskExecutionByTaskType(ctx, StorageMigrationTask) } // ParseMigrationTargetConfig parses and validates a storage migration target payload. diff --git a/plugins/domain/upload/storage/storage_ops.go b/plugins/domain/upload/storage/storage_ops.go index 892691ef..f6f978ee 100644 --- a/plugins/domain/upload/storage/storage_ops.go +++ b/plugins/domain/upload/storage/storage_ops.go @@ -6,9 +6,9 @@ package storage import ( "context" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/pkg/logger" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore" ) // ReadOnly checks if the storage system is in read-only maintenance mode. @@ -22,7 +22,7 @@ func ReadOnly(ctx context.Context) bool { } // OpenStoredObject opens a stored upload object from the active storage backend. -func OpenStoredObject(ctx context.Context, upload *model.Upload) (*objectstore.Object, error) { +func OpenStoredObject(ctx context.Context, upload *models.Upload) (*objectstore.Object, error) { _, backend, err := objectstore.Active(ctx) if err != nil { return nil, err diff --git a/plugins/domain/upload/task/cleanup.go b/plugins/domain/upload/task/cleanup.go index 76881a50..71148b15 100644 --- a/plugins/domain/upload/task/cleanup.go +++ b/plugins/domain/upload/task/cleanup.go @@ -10,17 +10,17 @@ import ( "fmt" "time" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/repository/logstore" "github.com/Rain-kl/Wavelet/pkg/logger" + "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" + + "github.com/Rain-kl/Wavelet/pkg/persistence/logstore" + "github.com/Rain-kl/Wavelet/pkg/task" uploadcache "github.com/Rain-kl/Wavelet/plugins/domain/upload/cache" "github.com/Rain-kl/Wavelet/plugins/domain/upload/shared" uploadstats "github.com/Rain-kl/Wavelet/plugins/domain/upload/stats" uploadstorage "github.com/Rain-kl/Wavelet/plugins/domain/upload/storage" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore" "gorm.io/gorm" ) @@ -61,9 +61,9 @@ func (h *SystemCleanupHandler) Execute(ctx context.Context, _ []byte) (*task.Tas task.AppendLog(ctx, "开始扫描未使用上传文件,阈值: %s", oneHourAgo.Format(time.RFC3339)) for { - var unusedUploads []model.Upload + var unusedUploads []models.Upload if err := db.DB(ctx). - Where("id > ? AND status = ? AND created_at < ?", lastID, model.UploadStatusPending, oneHourAgo). + Where("id > ? AND status = ? AND created_at < ?", lastID, models.UploadStatusPending, oneHourAgo). Order("id ASC"). Limit(batchSize). Find(&unusedUploads).Error; err != nil { @@ -81,9 +81,9 @@ func (h *SystemCleanupHandler) Execute(ctx context.Context, _ []byte) (*task.Tas totalProcessed++ if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.Upload{}). - Where("id = ? AND status = ?", u.ID, model.UploadStatusPending). - Update("status", model.UploadStatusDeleted).Error; err != nil { + if err := tx.Model(&models.Upload{}). + Where("id = ? AND status = ?", u.ID, models.UploadStatusPending). + Update("status", models.UploadStatusDeleted).Error; err != nil { return err } @@ -112,10 +112,10 @@ func (h *SystemCleanupHandler) Execute(ctx context.Context, _ []byte) (*task.Tas task.AppendLog(ctx, "开始清理历史推送审计日志,只保留最近7天数据...") cutoff := time.Now().AddDate(0, 0, -7) var pushHistoryCount int64 - if err := db.DB(ctx).Model(&model.PushHistory{}).Where("created_at < ?", cutoff).Count(&pushHistoryCount).Error; err != nil { + if err := db.DB(ctx).Table("w_push_histories").Where("created_at < ?", cutoff).Count(&pushHistoryCount).Error; err != nil { task.AppendLog(ctx, "统计待清理的历史推送记录失败: %v", err) } else if pushHistoryCount > 0 { - if err := db.DB(ctx).Where("created_at < ?", cutoff).Delete(&model.PushHistory{}).Error; err != nil { + if err := db.DB(ctx).Table("w_push_histories").Where("created_at < ?", cutoff).Delete(map[string]any{}).Error; err != nil { task.AppendLog(ctx, "删除历史推送记录失败: %v", err) } else { task.AppendLog(ctx, "成功删除 %d 条历史推送记录 (截止时间: %s)", pushHistoryCount, cutoff.Format("2006-01-02 15:04:05")) @@ -125,7 +125,7 @@ func (h *SystemCleanupHandler) Execute(ctx context.Context, _ []byte) (*task.Tas } task.AppendLog(ctx, "开始清理任务执行日志:高频任务保留最近3天,低频任务保留最近30天...") - taskLogStats, err := repository.CleanupTaskExecutionLogs(ctx, time.Now()) + taskLogStats, err := task.CleanupTaskExecutionLogs(ctx, time.Now()) if err != nil { task.AppendLog(ctx, "清理任务执行日志失败: %v", err) logger.ErrorF(ctx, "清理任务执行日志失败: %v", err) diff --git a/plugins/domain/upload/task/rebuild_stats.go b/plugins/domain/upload/task/rebuild_stats.go index 94229eb4..9624c2d2 100644 --- a/plugins/domain/upload/task/rebuild_stats.go +++ b/plugins/domain/upload/task/rebuild_stats.go @@ -7,9 +7,9 @@ import ( "context" "fmt" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/task" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" uploadstats "github.com/Rain-kl/Wavelet/plugins/domain/upload/stats" ) @@ -39,8 +39,8 @@ type RebuildUploadStatsHandler struct{} func (h *RebuildUploadStatsHandler) Execute(ctx context.Context, _ []byte) (*task.TaskResult, error) { var activeCount int64 if err := db.DB(ctx). - Model(&model.Upload{}). - Where("status != ?", model.UploadStatusDeleted). + Model(&models.Upload{}). + Where("status != ?", models.UploadStatusDeleted). Count(&activeCount).Error; err != nil { task.AppendLog(ctx, "统计活跃上传记录失败: %v", err) return nil, fmt.Errorf("count active uploads: %w", err) @@ -53,9 +53,9 @@ func (h *RebuildUploadStatsHandler) Execute(ctx context.Context, _ []byte) (*tas return nil, fmt.Errorf("rebuild upload stats: %w", err) } - var totalStat model.UploadStat + var totalStat models.UploadStat if err := db.DB(ctx). - Where("dimension = ? AND stat_key = ?", model.UploadStatDimensionTotal, ""). + Where("dimension = ? AND stat_key = ?", models.UploadStatDimensionTotal, ""). First(&totalStat).Error; err != nil { task.AppendLog(ctx, "读取总量统计失败: %v", err) return nil, fmt.Errorf("load total upload stats: %w", err) diff --git a/plugins/domain/upload/task/rebuild_stats_test.go b/plugins/domain/upload/task/rebuild_stats_test.go index e73e4d58..688274c5 100644 --- a/plugins/domain/upload/task/rebuild_stats_test.go +++ b/plugins/domain/upload/task/rebuild_stats_test.go @@ -8,9 +8,9 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/testhelper" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" ) func TestRebuildUploadStatsHandler_Execute(t *testing.T) { @@ -20,16 +20,16 @@ func TestRebuildUploadStatsHandler_Execute(t *testing.T) { ctx := context.Background() now := time.Now() - uploads := []model.Upload{ + uploads := []models.Upload{ { UserID: 1001, FileName: "a.jpg", FilePath: "uploads/a.jpg", FileSize: 100, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash-a", - Type: "pixez_mirror", Status: model.UploadStatusUsed, CreatedAt: now, + Type: "pixez_mirror", Status: models.UploadStatusUsed, CreatedAt: now, }, { UserID: 1001, FileName: "b.png", FilePath: "uploads/b.png", FileSize: 200, MimeType: "image/png", Extension: "png", Hash: "hash-b", - Type: "attachment", Status: model.UploadStatusUsed, CreatedAt: now, + Type: "attachment", Status: models.UploadStatusUsed, CreatedAt: now, }, } for i := range uploads { @@ -39,8 +39,8 @@ func TestRebuildUploadStatsHandler_Execute(t *testing.T) { } // Corrupt stats to ensure rebuild recalculates from uploads. - if err := db.DB(ctx).Create(&model.UploadStat{ - Dimension: model.UploadStatDimensionTotal, + if err := db.DB(ctx).Create(&models.UploadStat{ + Dimension: models.UploadStatDimensionTotal, StatKey: "", FileCount: 0, FileSize: 0, @@ -57,9 +57,9 @@ func TestRebuildUploadStatsHandler_Execute(t *testing.T) { t.Fatalf("Execute() returned empty result: %+v", result) } - var totalStat model.UploadStat + var totalStat models.UploadStat if err := db.DB(ctx). - Where("dimension = ? AND stat_key = ?", model.UploadStatDimensionTotal, ""). + Where("dimension = ? AND stat_key = ?", models.UploadStatDimensionTotal, ""). First(&totalStat).Error; err != nil { t.Fatalf("load total stat failed: %v", err) } diff --git a/plugins/domain/upload/task/storage_migration.go b/plugins/domain/upload/task/storage_migration.go index 513dd347..cb041902 100644 --- a/plugins/domain/upload/task/storage_migration.go +++ b/plugins/domain/upload/task/storage_migration.go @@ -15,13 +15,13 @@ import ( "sync/atomic" "time" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/task" "github.com/Rain-kl/Wavelet/pkg/util" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" uploadstats "github.com/Rain-kl/Wavelet/plugins/domain/upload/stats" uploadstorage "github.com/Rain-kl/Wavelet/plugins/domain/upload/storage" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore" "golang.org/x/sync/errgroup" ) @@ -173,8 +173,8 @@ func (h *MigrationHandler) Execute(ctx context.Context, payload []byte) (*task.T func countStorageObjects(ctx context.Context) (int64, error) { var count int64 - err := db.DB(ctx).Model(&model.Upload{}). - Where("status != ?", model.UploadStatusDeleted). + err := db.DB(ctx).Model(&models.Upload{}). + Where("status != ?", models.UploadStatusDeleted). Distinct("file_path"). Count(&count).Error return count, err @@ -185,7 +185,7 @@ func hasUnresolvedMigrationTask(ctx context.Context) (bool, error) { if err != nil || !ok { return false, err } - return execution.Status == model.TaskExecutionStatusPending || execution.Status == model.TaskExecutionStatusRunning, nil + return execution.Status == task.TaskExecutionStatusPending || execution.Status == task.TaskExecutionStatusRunning, nil } type migrationObject struct { @@ -214,9 +214,9 @@ func migrateObjects( task.AppendLog(ctx, "正在查询待迁移对象批次,当前已完成迁移: %d/%d", atomic.LoadInt64(&migrated), total) var objects []migrationObject - query := db.DB(ctx).Model(&model.Upload{}). + query := db.DB(ctx).Model(&models.Upload{}). Select("file_path, MAX(file_size) AS file_size, MAX(mime_type) AS mime_type, MAX(hash) AS hash"). - Where("status != ?", model.UploadStatusDeleted) + Where("status != ?", models.UploadStatusDeleted) if lastFilePath != "" { query = query.Where("file_path > ?", lastFilePath) } @@ -311,8 +311,8 @@ func migrateSingleObject( if targetResult.Key != obj.FilePath { task.AppendLog(ctx, "[更新数据库] 正在更新文件路径: %s -> %s", obj.FilePath, targetResult.Key) - if err := db.DB(ctx).Model(&model.Upload{}). - Where("file_path = ? AND status != ?", obj.FilePath, model.UploadStatusDeleted). + if err := db.DB(ctx).Model(&models.Upload{}). + Where("file_path = ? AND status != ?", obj.FilePath, models.UploadStatusDeleted). Update("file_path", targetResult.Key).Error; err != nil { return fmt.Errorf("update migrated object %q: %w", obj.FilePath, err) } @@ -344,15 +344,15 @@ func markMissingMigrationObjectDeleted( ) error { task.AppendLog(ctx, "警告: 源存储中物理文件不存在,标记为已删除并跳过: %s (错误: %v)", filePath, sourceErr) - var affectedUploads []model.Upload + var affectedUploads []models.Upload if err := db.DB(ctx). - Where("file_path = ? AND status != ?", filePath, model.UploadStatusDeleted). + Where("file_path = ? AND status != ?", filePath, models.UploadStatusDeleted). Find(&affectedUploads).Error; err != nil { return fmt.Errorf("load missing object uploads %q: %w", filePath, err) } - if err := db.DB(ctx).Model(&model.Upload{}). + if err := db.DB(ctx).Model(&models.Upload{}). Where("file_path = ?", filePath). - Update("status", model.UploadStatusDeleted).Error; err != nil { + Update("status", models.UploadStatusDeleted).Error; err != nil { return fmt.Errorf("update missing object %q: %w", filePath, err) } for i := range affectedUploads { diff --git a/plugins/domain/upload/task/storage_migration_task_test.go b/plugins/domain/upload/task/storage_migration_task_test.go index a531c3f3..ae2be51f 100644 --- a/plugins/domain/upload/task/storage_migration_task_test.go +++ b/plugins/domain/upload/task/storage_migration_task_test.go @@ -16,10 +16,10 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/testhelper" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" ) @@ -59,7 +59,7 @@ func TestMigrationHandlerExecute(t *testing.T) { t.Fatalf("Marshal(storageMigrationPayload) returned error: %v", err) } - upload := model.Upload{ + upload := models.Upload{ ID: 99101, UserID: 1, FileName: "test.txt", @@ -69,7 +69,7 @@ func TestMigrationHandlerExecute(t *testing.T) { Extension: "txt", Hash: "hash", Type: "attachment", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, } if err := dbConn.Create(&upload).Error; err != nil { t.Fatalf("Create(upload) returned error: %v", err) @@ -101,7 +101,7 @@ func TestMigrationHandlerExecute(t *testing.T) { t.Errorf("migrated content = %q, want %q", copied.String(), content) } - var migrated model.Upload + var migrated models.Upload if err := dbConn.First(&migrated, upload.ID).Error; err != nil { t.Fatalf("First(upload) returned error: %v", err) } @@ -156,7 +156,7 @@ func TestMigrationHandlerExecuteWithHashValidation(t *testing.T) { } // Case 1: Incorrect Hash (should fail validation) - uploadIncorrect := model.Upload{ + uploadIncorrect := models.Upload{ ID: 99102, UserID: 1, FileName: "test-hash.txt", @@ -166,7 +166,7 @@ func TestMigrationHandlerExecuteWithHashValidation(t *testing.T) { Extension: "txt", Hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // Invalid hash Type: "attachment", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, } if err := dbConn.Create(&uploadIncorrect).Error; err != nil { t.Fatalf("Create(uploadIncorrect) returned error: %v", err) @@ -202,7 +202,7 @@ func TestMigrationHandlerExecuteWithHashValidation(t *testing.T) { } // Case 2: Correct Hash (should succeed) - if err := dbConn.Model(&model.Upload{}).Where("id = ?", uploadIncorrect.ID).Update("hash", correctHash).Error; err != nil { + if err := dbConn.Model(&models.Upload{}).Where("id = ?", uploadIncorrect.ID).Update("hash", correctHash).Error; err != nil { t.Fatalf("Update hash to correct value returned error: %v", err) } @@ -215,7 +215,7 @@ func TestMigrationHandlerExecuteWithHashValidation(t *testing.T) { t.Fatal("Execute() result = nil, want non-nil") } - var migrated model.Upload + var migrated models.Upload if err := dbConn.First(&migrated, uploadIncorrect.ID).Error; err != nil { t.Fatalf("First(upload) returned error: %v", err) } diff --git a/plugins/domain/upload/task/tasks.go b/plugins/domain/upload/task/tasks.go index 8c36ea6c..c28bff2b 100644 --- a/plugins/domain/upload/task/tasks.go +++ b/plugins/domain/upload/task/tasks.go @@ -12,10 +12,10 @@ import ( "strings" "sync" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/task" "github.com/Rain-kl/Wavelet/plugins/domain/upload/filesrv" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" "github.com/Rain-kl/Wavelet/plugins/domain/upload/shared" ) @@ -113,11 +113,11 @@ func (h *WarmImageCacheHandler) Execute(ctx context.Context, payload []byte) (*t return nil, fmt.Errorf("image cache warmup canceled: %w", err) } - var uploads []model.Upload + var uploads []models.Upload if err := db.DB(ctx). Where("id > ? AND status != ? AND (LOWER(mime_type) LIKE ? OR LOWER(extension) IN ?)", lastID, - model.UploadStatusDeleted, + models.UploadStatusDeleted, "image/%", []string{"jpg", "jpeg", "png", "webp", "gif"}, ). diff --git a/plugins/domain/upload/task/tasks_test.go b/plugins/domain/upload/task/tasks_test.go index ded2de0e..64b5c6a4 100644 --- a/plugins/domain/upload/task/tasks_test.go +++ b/plugins/domain/upload/task/tasks_test.go @@ -17,15 +17,15 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/infra/diskcache" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/task" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/task" + "github.com/Rain-kl/Wavelet/pkg/testhelper" + msg "github.com/Rain-kl/Wavelet/plugins/domain/message_gateway" "github.com/Rain-kl/Wavelet/plugins/domain/upload/filesrv" + "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" "github.com/Rain-kl/Wavelet/plugins/domain/upload/shared" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/diskcache" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -48,39 +48,39 @@ func TestSystemCleanupHandler_Execute(t *testing.T) { objectstore.ResetCache() ctx := context.Background() - err := db.DB(ctx).AutoMigrate(&model.PushHistory{}) + err := db.DB(ctx).AutoMigrate(&msg.PushHistory{}) require.NoError(t, err) // 准备测试数据:创建一些上传记录 now := time.Now() twoHoursAgo := now.Add(-2 * time.Hour) - records := []*model.Upload{ + records := []*models.Upload{ // 超过1小时且状态为 pending 的记录 —— 应被清理 { UserID: 1001, FileName: "old_file_1.jpg", FilePath: "uploads/old_1.jpg", FileSize: 1024, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash1", - Type: "attachment", Status: model.UploadStatusPending, + Type: "attachment", Status: models.UploadStatusPending, CreatedAt: twoHoursAgo, }, { UserID: 1001, FileName: "old_file_2.png", FilePath: "uploads/old_2.png", FileSize: 2048, MimeType: "image/png", Extension: "png", Hash: "hash2", - Type: "attachment", Status: model.UploadStatusPending, + Type: "attachment", Status: models.UploadStatusPending, CreatedAt: twoHoursAgo, }, // 状态为 used 的记录 —— 不应被清理 { UserID: 1001, FileName: "used_file.jpg", FilePath: "uploads/used.jpg", FileSize: 512, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash3", - Type: "attachment", Status: model.UploadStatusUsed, + Type: "attachment", Status: models.UploadStatusUsed, CreatedAt: twoHoursAgo, }, // 不到1小时的 pending 记录 —— 不应被清理 { UserID: 1001, FileName: "recent_file.jpg", FilePath: "uploads/recent.jpg", FileSize: 256, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash4", - Type: "attachment", Status: model.UploadStatusPending, + Type: "attachment", Status: models.UploadStatusPending, CreatedAt: now.Add(-10 * time.Minute), }, } @@ -90,7 +90,7 @@ func TestSystemCleanupHandler_Execute(t *testing.T) { } // 准备推送历史测试数据:1个旧的(应删除),1个新的(应保留) - oldPush := &model.PushHistory{ + oldPush := &msg.PushHistory{ EventKey: "admin_login", Channel: "email", Target: "admin@test.com", @@ -100,7 +100,7 @@ func TestSystemCleanupHandler_Execute(t *testing.T) { Status: "success", CreatedAt: now.AddDate(0, 0, -10), } - newPush := &model.PushHistory{ + newPush := &msg.PushHistory{ EventKey: "admin_login", Channel: "lark", Target: "http://webhook.com", @@ -115,16 +115,16 @@ func TestSystemCleanupHandler_Execute(t *testing.T) { err = db.DB(ctx).Create(newPush).Error require.NoError(t, err) - oldTaskLog := &model.TaskExecution{ + oldTaskLog := &task.TaskExecution{ TaskID: "old_low_frequency_task_log", TaskType: "low:frequency", TaskName: "低频任务", - Status: model.TaskExecutionStatusSucceeded, + Status: task.TaskExecutionStatusSucceeded, CreatedAt: now.AddDate(0, 0, -31), UpdatedAt: now.AddDate(0, 0, -31), TriggeredBy: "system", } - err = repository.CreateTaskExecution(ctx, oldTaskLog) + err = task.CreateTaskExecution(ctx, oldTaskLog) require.NoError(t, err) // 执行 handler @@ -138,29 +138,29 @@ func TestSystemCleanupHandler_Execute(t *testing.T) { // 验证数据库状态:pending 且超过1小时的应被标记为 deleted var pendingCount int64 - db.DB(ctx).Model(&model.Upload{}).Where("status = ?", model.UploadStatusPending).Count(&pendingCount) + db.DB(ctx).Model(&models.Upload{}).Where("status = ?", models.UploadStatusPending).Count(&pendingCount) assert.Equal(t, int64(1), pendingCount, "应只剩1条 pending 记录(最近的文件)") var deletedCount int64 - db.DB(ctx).Model(&model.Upload{}).Where("status = ?", model.UploadStatusDeleted).Count(&deletedCount) + db.DB(ctx).Model(&models.Upload{}).Where("status = ?", models.UploadStatusDeleted).Count(&deletedCount) assert.Equal(t, int64(2), deletedCount, "应有2条被标记为 deleted") var usedCount int64 - db.DB(ctx).Model(&model.Upload{}).Where("status = ?", model.UploadStatusUsed).Count(&usedCount) + db.DB(ctx).Model(&models.Upload{}).Where("status = ?", models.UploadStatusUsed).Count(&usedCount) assert.Equal(t, int64(1), usedCount, "used 状态的文件不应受影响") // 验证推送历史数据状态:10天前的应被删除,今天的应保留 var pushCount int64 - db.DB(ctx).Model(&model.PushHistory{}).Count(&pushCount) + db.DB(ctx).Model(&msg.PushHistory{}).Count(&pushCount) assert.Equal(t, int64(1), pushCount, "应只剩1条推送历史记录") - var remainingPush model.PushHistory + var remainingPush msg.PushHistory err = db.DB(ctx).First(&remainingPush).Error require.NoError(t, err) assert.Equal(t, "New Login", remainingPush.Title) var taskLogCount int64 - err = db.DB(ctx).Model(&model.TaskExecution{}).Where("task_id = ?", "old_low_frequency_task_log").Count(&taskLogCount).Error + err = db.DB(ctx).Model(&task.TaskExecution{}).Where("task_id = ?", "old_low_frequency_task_log").Count(&taskLogCount).Error require.NoError(t, err) assert.Equal(t, int64(0), taskLogCount, "过期低频任务日志应被清理") } @@ -180,7 +180,7 @@ func TestSystemCleanupHandler_ExecuteNoFiles(t *testing.T) { defer storageMock() ctx := context.Background() - err := db.DB(ctx).AutoMigrate(&model.PushHistory{}) + err := db.DB(ctx).AutoMigrate(&msg.PushHistory{}) require.NoError(t, err) // 没有任何上传记录 @@ -279,7 +279,7 @@ func TestWarmImageCacheHandlerExecute(t *testing.T) { writeTaskTestPNG(t, firstPath, color.RGBA{R: 255, A: 255}) writeTaskTestPNG(t, secondPath, color.RGBA{G: 255, A: 255}) - records := []model.Upload{ + records := []models.Upload{ { ID: 4101, UserID: 1001, @@ -287,7 +287,7 @@ func TestWarmImageCacheHandlerExecute(t *testing.T) { FilePath: firstPath, MimeType: "image/png", Extension: "png", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, }, { ID: 4102, @@ -296,7 +296,7 @@ func TestWarmImageCacheHandlerExecute(t *testing.T) { FilePath: secondPath, MimeType: "application/octet-stream", Extension: "jpg", - Status: model.UploadStatusPending, + Status: models.UploadStatusPending, }, { ID: 4103, @@ -305,7 +305,7 @@ func TestWarmImageCacheHandlerExecute(t *testing.T) { FilePath: filepath.Join(testDir, "notes.txt"), MimeType: "text/plain", Extension: "txt", - Status: model.UploadStatusUsed, + Status: models.UploadStatusUsed, }, { ID: 4104, @@ -314,7 +314,7 @@ func TestWarmImageCacheHandlerExecute(t *testing.T) { FilePath: firstPath, MimeType: "image/png", Extension: "png", - Status: model.UploadStatusDeleted, + Status: models.UploadStatusDeleted, }, } for i := range records { diff --git a/plugins/domain/upload/util/media.go b/plugins/domain/upload/util/media.go index 121b1d0e..dd725cd5 100644 --- a/plugins/domain/upload/util/media.go +++ b/plugins/domain/upload/util/media.go @@ -41,9 +41,10 @@ func IsDocumentExtension(ext string) bool { // NormalizeImageQuality normalizes the requested image quality query parameter. func NormalizeImageQuality(quality string) string { - switch strings.ToLower(quality) { + q := strings.TrimSpace(strings.ToLower(quality)) + switch q { case shared.ImageQualityLow, shared.ImageQualityMedium, shared.ImageQualityHigh: - return strings.ToLower(quality) + return q default: return shared.ImageQualityOrigin } diff --git a/plugins/domain/user/handlers.go b/plugins/domain/user/handlers.go index f0ab70c2..15fcf27b 100644 --- a/plugins/domain/user/handlers.go +++ b/plugins/domain/user/handlers.go @@ -11,10 +11,9 @@ import ( "strconv" "time" - persistence "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" + persistence "github.com/Rain-kl/Wavelet/pkg/persistence" + + "github.com/Rain-kl/Wavelet/pkg/response" "github.com/Rain-kl/Wavelet/plugins/domain/auth" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" @@ -60,7 +59,7 @@ func Login(c *gin.Context) { return } - user, err := repository.GetUserByUsername(c.Request.Context(), req.Username) + user, err := GetUserByUsername(c.Request.Context(), req.Username) if err != nil { response.AbortUnauthorized(c, errPasswordMismatch) return @@ -87,7 +86,7 @@ func Register(c *gin.Context) { return } - newUser := &model.User{ + newUser := &User{ Username: req.Username, Email: req.Email, IsActive: true, @@ -128,7 +127,7 @@ func ChangePassword(c *gin.Context) { } userID := auth.GetUserIDFromContext(c) - user, err := repository.GetUserByID(c.Request.Context(), userID) + user, err := GetUserByID(c.Request.Context(), userID) if err != nil { response.AbortNotFound(c, errUserNotFound) return @@ -160,7 +159,7 @@ func UpdateProfile(c *gin.Context) { } userID := auth.GetUserIDFromContext(c) - user, err := repository.GetUserByID(c.Request.Context(), userID) + user, err := GetUserByID(c.Request.Context(), userID) if err != nil { response.AbortNotFound(c, errUserNotFound) return @@ -184,7 +183,7 @@ func UpdateProfile(c *gin.Context) { // ListAccessTokens lists access tokens for the current user. func ListAccessTokens(c *gin.Context) { userID := auth.GetUserIDFromContext(c) - var tokens []model.AccessToken + var tokens []AccessToken gormDB := persistence.DB(c.Request.Context()) _ = gormDB.Where("user_id = ?", userID).Find(&tokens).Error c.JSON(http.StatusOK, response.OK(tokens)) @@ -215,7 +214,7 @@ func CreateAccessToken(c *gin.Context) { masked = rawToken[:4] + "..." + rawToken[len(rawToken)-4:] } - token := model.AccessToken{ + token := AccessToken{ UserID: userID, Name: req.Name, TokenHash: tokenHash, @@ -245,7 +244,7 @@ func DeleteAccessToken(c *gin.Context) { } userID := auth.GetUserIDFromContext(c) - var token model.AccessToken + var token AccessToken gormDB := persistence.DB(c.Request.Context()) if err := gormDB.Where("id = ? AND user_id = ?", id, userID).First(&token).Error; err != nil { response.AbortNotFound(c, errTokenNotFound) @@ -267,7 +266,7 @@ func RotateAccessToken(c *gin.Context) { } userID := auth.GetUserIDFromContext(c) - var token model.AccessToken + var token AccessToken gormDB := persistence.DB(c.Request.Context()) if err := gormDB.Where("id = ? AND user_id = ?", id, userID).First(&token).Error; err != nil { response.AbortNotFound(c, errTokenNotFound) diff --git a/plugins/domain/user/models.go b/plugins/domain/user/models.go new file mode 100644 index 00000000..e083e65f --- /dev/null +++ b/plugins/domain/user/models.go @@ -0,0 +1,79 @@ +// Copyright 2025 linux.do +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package user + +import ( + "errors" + + "strings" + "time" + + "github.com/Rain-kl/Wavelet/pkg/util" +) + +// AccessToken 个人访问令牌实体 +type AccessToken struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + UserID uint64 `json:"user_id" gorm:"index;not null"` + Name string `json:"name" gorm:"size:128;not null"` + TokenHash string `json:"-" gorm:"size:64;uniqueIndex;not null"` + MaskedToken string `json:"masked_token" gorm:"size:64;not null"` + IsAdmin bool `json:"is_admin" gorm:"default:false"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// TableName 表名 +func (AccessToken) TableName() string { + return "w_access_tokens" +} + +// User 用户表实体 +type User struct { + ID uint64 `json:"id,string" gorm:"primaryKey;not null"` + Username string `json:"username" gorm:"size:64;uniqueIndex"` + Password string `json:"password,omitempty" gorm:"size:255"` + Nickname string `json:"nickname" gorm:"size:255"` + Email string `json:"email" gorm:"size:255;index"` + AvatarURL string `json:"avatar_url" gorm:"size:255"` + IsActive bool `json:"is_active" gorm:"default:true;index"` + IsAdmin bool `json:"is_admin" gorm:"default:false"` + Bio string `json:"bio" gorm:"size:500"` + Phone string `json:"phone" gorm:"size:32"` + Gender string `json:"gender" gorm:"size:16"` + Website string `json:"website" gorm:"size:255"` + Location string `json:"location" gorm:"size:255"` + LastLoginAt time.Time `json:"last_login_at" gorm:"index"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` +} + +// TableName 表名 +func (User) TableName() string { + return "w_users" +} + +// SetEncryptedPassword 设置加密密码 +func (u *User) SetEncryptedPassword(password string) error { + trimmed := strings.TrimSpace(password) + if trimmed == "" { + return errors.New("password cannot be empty") + } + hash, err := util.HashPassword(trimmed) + if err != nil { + return err + } + u.Password = hash + return nil +} + +// CheckPassword 校验密码 +func (u *User) CheckPassword(password string) bool { + if u.Password == "" { + util.DummyCheckPassword(password) + return false + } + return util.CheckPasswordHash(u.Password, password) +} diff --git a/plugins/domain/user/plugin.go b/plugins/domain/user/plugin.go index 8dcf7e25..775a17c6 100644 --- a/plugins/domain/user/plugin.go +++ b/plugins/domain/user/plugin.go @@ -44,15 +44,18 @@ func New(opts ...Option) *Plugin { return p } +// PluginName 用户插件唯一名称标识 +const PluginName = "user" + // Name returns the unique identifier for the user domain plugin. func (p *Plugin) Name() string { - return "user" + return PluginName } // Manifest returns the plugin metadata. func (p *Plugin) Manifest() core.Manifest { return core.Manifest{ - Name: "user", + Name: PluginName, Version: "1.0.0", Description: "User profiles, credentials, role management, and access token domain plugin", Author: "Wavelet Team", diff --git a/plugins/domain/user/plugin_test.go b/plugins/domain/user/plugin_test.go index 3c13639b..55ae3ec6 100644 --- a/plugins/domain/user/plugin_test.go +++ b/plugins/domain/user/plugin_test.go @@ -15,8 +15,7 @@ import ( "github.com/Rain-kl/Wavelet/core" "github.com/Rain-kl/Wavelet/core/contracts" - db "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" + db "github.com/Rain-kl/Wavelet/pkg/persistence" "github.com/Rain-kl/Wavelet/plugins/domain/user" ) @@ -27,8 +26,8 @@ func setupTestDB(t *testing.T) *gorm.DB { require.NoError(t, err) require.NoError(t, testDB.AutoMigrate( - &model.User{}, - &model.AccessToken{}, + &user.User{}, + &user.AccessToken{}, )) db.SetDB(testDB) diff --git a/plugins/domain/user/repository.go b/plugins/domain/user/repository.go new file mode 100644 index 00000000..106137b6 --- /dev/null +++ b/plugins/domain/user/repository.go @@ -0,0 +1,161 @@ +// Copyright 2025 linux.do +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package user + +import ( + "context" + + "strings" + + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/util" + "gorm.io/gorm" +) + +// GetUserByID 通过 ID 获取用户 +func GetUserByID(ctx context.Context, id uint64) (*User, error) { + var u User + if err := db.DB(ctx).First(&u, id).Error; err != nil { + return nil, err + } + return &u, nil +} + +// GetUserByUsername 通过用户名获取用户 +func GetUserByUsername(ctx context.Context, username string) (*User, error) { + var u User + if err := db.DB(ctx).Where("username = ?", username).First(&u).Error; err != nil { + return nil, err + } + return &u, nil +} + +// GetUserByEmail 通过邮箱获取用户 +func GetUserByEmail(ctx context.Context, email string) (*User, error) { + var u User + if err := db.DB(ctx).Where("email = ?", email).First(&u).Error; err != nil { + return nil, err + } + return &u, nil +} + +// CreateUser 创建用户 +func CreateUser(ctx context.Context, u *User) error { + return db.DB(ctx).Create(u).Error +} + +// UpdateUser 更新用户 +func UpdateUser(ctx context.Context, u *User) error { + return db.DB(ctx).Save(u).Error +} + +// ListUsers 分页查询用户 +func ListUsers(ctx context.Context, page, pageSize int, keyword string) ([]*User, int64, error) { + db := db.DB(ctx).Model(&User{}) + if keyword != "" { + escaped := util.EscapeLike(keyword) + db = db.Where("username LIKE ? ESCAPE '\\' OR nickname LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\'", "%"+escaped+"%", "%"+escaped+"%", "%"+escaped+"%") + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + var users []*User + offset := (page - 1) * pageSize + if err := db.Offset(offset).Limit(pageSize).Order("id DESC").Find(&users).Error; err != nil { + return nil, 0, err + } + return users, total, nil +} + +// GetAccessTokenByHash 通过 Hash 查询访问令牌 +func GetAccessTokenByHash(ctx context.Context, tokenHash string) (*AccessToken, error) { + var token AccessToken + if err := db.DB(ctx).Where("token_hash = ?", tokenHash).First(&token).Error; err != nil { + return nil, err + } + return &token, nil +} + +// AdminUserListFilter 包含后台用户列表过滤条件 +type AdminUserListFilter struct { + Username string + Keyword string + Page int + PageSize int +} + +// ListAdminUsers 获取后台管理用户列表 +func ListAdminUsers(ctx context.Context, filter AdminUserListFilter) (int64, []User, error) { + query := db.DB(ctx).Model(&User{}) + if filter.Username != "" { + escaped := util.EscapeLike(strings.ToLower(filter.Username)) + query = query.Where("LOWER(username) LIKE ? ESCAPE '\\'", "%"+escaped+"%") + } + if filter.Keyword != "" { + escaped := util.EscapeLike(strings.ToLower(filter.Keyword)) + query = query.Where("LOWER(username) LIKE ? ESCAPE '\\' OR LOWER(nickname) LIKE ? ESCAPE '\\' OR LOWER(email) LIKE ? ESCAPE '\\'", + "%"+escaped+"%", "%"+escaped+"%", "%"+escaped+"%") + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return 0, nil, err + } + + var users []User + offset := (filter.Page - 1) * filter.PageSize + if err := query.Order("id DESC").Offset(offset).Limit(filter.PageSize).Find(&users).Error; err != nil { + return 0, nil, err + } + return total, users, nil +} + +// UpdateUserActive 更新用户激活状态 +func UpdateUserActive(ctx context.Context, id uint64, active bool) error { + return db.DB(ctx).Model(&User{}).Where("id = ?", id).Update("is_active", active).Error +} + +// GetActiveUserByID 获取处于激活状态的用户 +func GetActiveUserByID(ctx context.Context, id uint64) (*User, error) { + var u User + if err := db.DB(ctx).Where("id = ? AND is_active = ?", id, true).First(&u).Error; err != nil { + return nil, err + } + return &u, nil +} + +// DeleteUserWithRelations 删除用户及其级联关系 +func DeleteUserWithRelations(ctx context.Context, id uint64) error { + return db.DB(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("user_id = ?", id).Delete(&AccessToken{}).Error; err != nil { + return err + } + return tx.Where("id = ?", id).Delete(&User{}).Error + }) +} + +// GetFirstAdminUser 获取第一个管理员用户 +func GetFirstAdminUser(ctx context.Context) (*User, error) { + var u User + if err := db.DB(ctx).Where("is_admin = ?", true).Order("id ASC").First(&u).Error; err != nil { + return nil, err + } + return &u, nil +} + +// ListUsernamesMatchingBase 列出匹配基础用户名的所有用户名 +func ListUsernamesMatchingBase(ctx context.Context, base string) ([]string, error) { + var usernames []string + escaped := util.EscapeLike(strings.ToLower(base)) + if err := db.DB(ctx).Model(&User{}). + Where("LOWER(username) LIKE ? ESCAPE '\\'", escaped+"%"). + Pluck("username", &usernames).Error; err != nil { + return nil, err + } + return usernames, nil +} diff --git a/plugins/domain/user/service.go b/plugins/domain/user/service.go index 9c50c12b..89c62ef2 100644 --- a/plugins/domain/user/service.go +++ b/plugins/domain/user/service.go @@ -1,20 +1,24 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + // Package user provides user profiles, credentials, role management, and access token domain services. package user import ( "context" "errors" + "fmt" + "strings" "time" "github.com/Rain-kl/Wavelet/core/contracts" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/persistence/idgen" + pkgu "github.com/Rain-kl/Wavelet/pkg/util" ) -func toUserDTO(u *model.User) *contracts.UserDTO { +func toUserDTO(u *User) *contracts.UserDTO { if u == nil { return nil } @@ -44,23 +48,23 @@ func newUserService() contracts.UserService { } func (s *userServiceImpl) GetUserByID(ctx context.Context, id uint64) (*contracts.UserDTO, error) { - u, err := repository.GetUserByID(ctx, id) + u, err := GetUserByID(ctx, id) if err != nil { return nil, err } - return toUserDTO(&u), nil + return toUserDTO(u), nil } func (s *userServiceImpl) GetUserByUsername(ctx context.Context, username string) (*contracts.UserDTO, error) { - u, err := repository.GetUserByUsername(ctx, username) + u, err := GetUserByUsername(ctx, username) if err != nil { return nil, err } - return toUserDTO(&u), nil + return toUserDTO(u), nil } func (s *userServiceImpl) GetUserByEmail(ctx context.Context, email string) (*contracts.UserDTO, error) { - var u model.User + var u User if err := db.DB(ctx).Where("email = ?", email).First(&u).Error; err != nil { return nil, err } @@ -72,7 +76,7 @@ func (s *userServiceImpl) CreateUser(ctx context.Context, req contracts.CreateUs return nil, errors.New("user: username cannot be empty") } - user := model.User{ + user := User{ ID: idgen.NextUint64ID(), Username: req.Username, Nickname: req.Nickname, @@ -94,7 +98,7 @@ func (s *userServiceImpl) CreateUser(ctx context.Context, req contracts.CreateUs } } - if err := repository.CreateUser(ctx, &user); err != nil { + if err := CreateUser(ctx, &user); err != nil { return nil, err } @@ -129,7 +133,7 @@ func (s *userServiceImpl) UpdateProfile(ctx context.Context, id uint64, req cont } updates["updated_at"] = time.Now() - if err := db.DB(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates).Error; err != nil { + if err := db.DB(ctx).Model(&User{}).Where("id = ?", id).Updates(updates).Error; err != nil { return nil, err } @@ -137,7 +141,7 @@ func (s *userServiceImpl) UpdateProfile(ctx context.Context, id uint64, req cont } func (s *userServiceImpl) UpdatePassword(ctx context.Context, id uint64, oldPassword, newPassword string) error { - var user model.User + var user User if err := db.DB(ctx).Where("id = ?", id).First(&user).Error; err != nil { return err } @@ -150,7 +154,7 @@ func (s *userServiceImpl) UpdatePassword(ctx context.Context, id uint64, oldPass return err } - return db.DB(ctx).Model(&model.User{}).Where("id = ?", id). + return db.DB(ctx).Model(&User{}).Where("id = ?", id). Updates(map[string]any{ "password": user.Password, "updated_at": time.Now(), @@ -158,7 +162,7 @@ func (s *userServiceImpl) UpdatePassword(ctx context.Context, id uint64, oldPass } func (s *userServiceImpl) VerifyPassword(ctx context.Context, id uint64, password string) bool { - var user model.User + var user User if err := db.DB(ctx).Where("id = ?", id).First(&user).Error; err != nil { pkgu.DummyCheckPassword(password) return false @@ -167,7 +171,7 @@ func (s *userServiceImpl) VerifyPassword(ctx context.Context, id uint64, passwor } func (s *userServiceImpl) UpdateLastLogin(ctx context.Context, id uint64, _ string) error { - return db.DB(ctx).Model(&model.User{}).Where("id = ?", id). + return db.DB(ctx).Model(&User{}).Where("id = ?", id). Updates(map[string]any{ "last_login_at": time.Now(), "updated_at": time.Now(), @@ -182,13 +186,13 @@ func (s *userServiceImpl) ListUsers(ctx context.Context, page, pageSize int, key pageSize = 20 } - filter := repository.AdminUserListFilter{ + filter := AdminUserListFilter{ Username: keyword, Page: page, PageSize: pageSize, } - total, users, err := repository.ListAdminUsers(ctx, filter) + total, users, err := ListAdminUsers(ctx, filter) if err != nil { return nil, 0, err } @@ -202,9 +206,77 @@ func (s *userServiceImpl) ListUsers(ctx context.Context, page, pageSize int, key } func (s *userServiceImpl) SetUserActive(ctx context.Context, id uint64, active bool) error { - return repository.UpdateUserActive(ctx, id, active) + return UpdateUserActive(ctx, id, active) } func (s *userServiceImpl) SetUserAdmin(ctx context.Context, id uint64, admin bool) error { - return db.DB(ctx).Model(&model.User{}).Where("id = ?", id).Update("is_admin", admin).Error + return db.DB(ctx).Model(&User{}).Where("id = ?", id).Update("is_admin", admin).Error +} + +func (s *userServiceImpl) VerifyAccessToken(ctx context.Context, tokenHash string) (*contracts.UserDTO, bool, error) { + tokenRecord, err := GetAccessTokenByHash(ctx, tokenHash) + if err != nil { + return nil, false, err + } + + user, err := GetActiveUserByID(ctx, tokenRecord.UserID) + if err != nil { + return nil, false, err + } + + return toUserDTO(user), tokenRecord.IsAdmin, nil +} + +func (s *userServiceImpl) DeleteUser(ctx context.Context, id uint64) error { + return DeleteUserWithRelations(ctx, id) +} + +func (s *userServiceImpl) CountUsers(ctx context.Context) (int64, error) { + var count int64 + err := db.DB(ctx).Model(&User{}).Count(&count).Error + return count, err +} + +func (s *userServiceImpl) CountActiveUsers(ctx context.Context) (int64, error) { + var count int64 + err := db.DB(ctx).Model(&User{}).Where("is_active = ?", true).Count(&count).Error + return count, err +} + +func (s *userServiceImpl) GetFirstAdminUser(ctx context.Context) (*contracts.UserDTO, error) { + u, err := GetFirstAdminUser(ctx) + if err != nil { + return nil, err + } + return toUserDTO(u), nil +} + +func (s *userServiceImpl) UniqueUsername(ctx context.Context, base string) (string, error) { + base = strings.TrimSpace(base) + if base == "" { + base = PluginName + } + + existingUsernames, err := ListUsernamesMatchingBase(ctx, base) + if err != nil { + return "", err + } + + exists := make(map[string]bool, len(existingUsernames)) + for _, u := range existingUsernames { + exists[strings.ToLower(u)] = true + } + + if !exists[strings.ToLower(base)] { + return base, nil + } + + for i := 1; i <= 1000; i++ { + candidate := fmt.Sprintf("%s-%d", base, i) + if !exists[strings.ToLower(candidate)] { + return candidate, nil + } + } + + return "", errors.New("failed to generate unique username") } diff --git a/plugins/drivers/driver_asynq_cron/plugin.go b/plugins/drivers/driver_asynq_cron/plugin.go index aa0c8dd1..bc532516 100644 --- a/plugins/drivers/driver_asynq_cron/plugin.go +++ b/plugins/drivers/driver_asynq_cron/plugin.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + // Package driver_asynq_cron provides the Asynq cron schedule driver plugin for Cordis. package driver_asynq_cron diff --git a/plugins/drivers/driver_asynq_worker/plugin.go b/plugins/drivers/driver_asynq_worker/plugin.go index ad6d1265..36a9aa37 100644 --- a/plugins/drivers/driver_asynq_worker/plugin.go +++ b/plugins/drivers/driver_asynq_worker/plugin.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + // Package driver_asynq_worker provides the Asynq worker driver plugin for Cordis. package driver_asynq_worker diff --git a/internal/router/router.go b/plugins/drivers/driver_http/engine.go similarity index 97% rename from internal/router/router.go rename to plugins/drivers/driver_http/engine.go index 205d789c..cba4be84 100644 --- a/internal/router/router.go +++ b/plugins/drivers/driver_http/engine.go @@ -2,7 +2,7 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -package router +package driver_http import ( "context" @@ -16,7 +16,7 @@ import ( "syscall" "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" + "github.com/Rain-kl/Wavelet/pkg/config" "github.com/Rain-kl/Wavelet/pkg/trace" "github.com/Rain-kl/Wavelet/pkg/util" "github.com/Rain-kl/Wavelet/plugins/domain/auth" diff --git a/internal/router/middlewares.go b/plugins/drivers/driver_http/middlewares.go similarity index 86% rename from internal/router/middlewares.go rename to plugins/drivers/driver_http/middlewares.go index b6c42f21..ce3e8896 100644 --- a/internal/router/middlewares.go +++ b/plugins/drivers/driver_http/middlewares.go @@ -2,8 +2,8 @@ // Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 -// Package router 提供 HTTP 路由注册与服务启动 -package router +// Package driver_http 提供 HTTP 路由中间件与服务启动 +package driver_http import ( "context" @@ -12,11 +12,10 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/infra/config" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/shared/response" + "github.com/Rain-kl/Wavelet/pkg/config" "github.com/Rain-kl/Wavelet/pkg/logger" + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/response" otel_trace "github.com/Rain-kl/Wavelet/pkg/trace" "github.com/gin-gonic/gin" "go.opentelemetry.io/otel/codes" @@ -73,11 +72,11 @@ func loggerMiddleware() gin.HandlerFunc { } func isOriginAllowed(ctx context.Context, origin string) bool { - sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress) - if err != nil || sc.Value == "" { + var val string + if err := db.DB(ctx).Table("w_system_configs").Where("key = ?", "server_address").Pluck("value", &val).Error; err != nil || val == "" { return false } - allowedOrigins := strings.Split(sc.Value, ",") + allowedOrigins := strings.Split(val, ",") for _, allowed := range allowedOrigins { allowed = strings.TrimRight(strings.TrimSpace(allowed), "/") if allowed != "" && strings.EqualFold(allowed, origin) { diff --git a/plugins/drivers/driver_http/middlewares_test.go b/plugins/drivers/driver_http/middlewares_test.go new file mode 100644 index 00000000..12fc578b --- /dev/null +++ b/plugins/drivers/driver_http/middlewares_test.go @@ -0,0 +1,100 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package driver_http + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/Rain-kl/Wavelet/pkg/testhelper" + "github.com/gin-gonic/gin" +) + +func TestCORSMiddleware(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + gin.SetMode(gin.TestMode) + + clearConfigCache := func() {} + + t.Run("missing server_address configuration returns no CORS headers", func(t *testing.T) { + clearConfigCache() + if err := dbConn.Table("w_system_configs").Where("key = ?", "server_address").Update("value", "").Error; err != nil { + t.Fatalf("failed to update config: %v", err) + } + + r := gin.New() + r.Use(corsMiddleware()) + r.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Origin", "http://attacker.com") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Header().Get("Access-Control-Allow-Origin") != "" { + t.Errorf("expected no Access-Control-Allow-Origin, got %s", w.Header().Get("Access-Control-Allow-Origin")) + } + }) + + t.Run("configured server_address allows exact origin match", func(t *testing.T) { + if err := dbConn.Table("w_system_configs").Where("key = ?", "server_address").Update("value", "http://trusted.com").Error; err != nil { + t.Fatalf("failed to update config: %v", err) + } + + r := gin.New() + r.Use(corsMiddleware()) + r.GET("/test", func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + // Trusted origin + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Origin", "http://trusted.com") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Header().Get("Access-Control-Allow-Origin") != "http://trusted.com" { + t.Errorf("expected Access-Control-Allow-Origin http://trusted.com, got %s", w.Header().Get("Access-Control-Allow-Origin")) + } + if w.Header().Get("Access-Control-Allow-Credentials") != "true" { + t.Errorf("expected Access-Control-Allow-Credentials true, got %s", w.Header().Get("Access-Control-Allow-Credentials")) + } + + // Untrusted origin + req, _ = http.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Origin", "http://attacker.com") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Header().Get("Access-Control-Allow-Origin") != "" { + t.Errorf("expected no Access-Control-Allow-Origin for attacker, got %s", w.Header().Get("Access-Control-Allow-Origin")) + } + }) + + t.Run("preflight OPTIONS request responds with 204", func(t *testing.T) { + if err := dbConn.Table("w_system_configs").Where("key = ?", "server_address").Update("value", "http://trusted.com").Error; err != nil { + t.Fatalf("failed to update config: %v", err) + } + + r := gin.New() + r.Use(corsMiddleware()) + + req, _ := http.NewRequest(http.MethodOptions, "/test", nil) + req.Header.Set("Origin", "http://trusted.com") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("expected status 204, got %d", w.Code) + } + if w.Header().Get("Access-Control-Allow-Methods") == "" { + t.Error("expected Access-Control-Allow-Methods header") + } + }) +} diff --git a/plugins/drivers/driver_http/plugin.go b/plugins/drivers/driver_http/plugin.go index e15fef49..5af542a5 100644 --- a/plugins/drivers/driver_http/plugin.go +++ b/plugins/drivers/driver_http/plugin.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + // Package driver_http provides the Gin HTTP web server driver plugin for Cordis. package driver_http diff --git a/plugins/drivers/drivers_test.go b/plugins/drivers/drivers_test.go index 55c8e6a6..07d44c3b 100644 --- a/plugins/drivers/drivers_test.go +++ b/plugins/drivers/drivers_test.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + package drivers_test import ( diff --git a/plugins/infra/cache/plugin.go b/plugins/infra/cache/plugin.go index 408f3d72..b7dc40c3 100644 --- a/plugins/infra/cache/plugin.go +++ b/plugins/infra/cache/plugin.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + // Package cache provides the multi-tier caching infrastructure plugin for Cordis. package cache @@ -10,8 +13,8 @@ import ( "github.com/Rain-kl/Wavelet/core" "github.com/Rain-kl/Wavelet/core/contracts" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" "github.com/Rain-kl/Wavelet/pkg/cache/ram" + "github.com/Rain-kl/Wavelet/pkg/persistence" "github.com/Rain-kl/Wavelet/pkg/util" "github.com/redis/go-redis/v9" ) diff --git a/plugins/infra/database/plugin.go b/plugins/infra/database/plugin.go index 7b6012aa..c3ee26ca 100644 --- a/plugins/infra/database/plugin.go +++ b/plugins/infra/database/plugin.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + // Package database provides the relational database infrastructure plugin for Cordis. package database @@ -6,7 +9,7 @@ import ( "github.com/Rain-kl/Wavelet/core" "github.com/Rain-kl/Wavelet/core/contracts" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" + "github.com/Rain-kl/Wavelet/pkg/persistence" "gorm.io/gorm" ) diff --git a/plugins/infra/infra_test.go b/plugins/infra/infra_test.go index 650b2d32..c6827b54 100644 --- a/plugins/infra/infra_test.go +++ b/plugins/infra/infra_test.go @@ -21,11 +21,11 @@ import ( "github.com/Rain-kl/Wavelet/core" "github.com/Rain-kl/Wavelet/core/contracts" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" "github.com/Rain-kl/Wavelet/plugins/infra/cache" "github.com/Rain-kl/Wavelet/plugins/infra/database" "github.com/Rain-kl/Wavelet/plugins/infra/logger" "github.com/Rain-kl/Wavelet/plugins/infra/storage" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore" ) type TestUser struct { diff --git a/plugins/infra/logger/plugin.go b/plugins/infra/logger/plugin.go index a8abdbc2..e3ab711c 100644 --- a/plugins/infra/logger/plugin.go +++ b/plugins/infra/logger/plugin.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + // Package logger provides the structured logging infrastructure plugin for Cordis. package logger diff --git a/internal/infra/diskcache/cache.go b/plugins/infra/storage/diskcache/cache.go similarity index 73% rename from internal/infra/diskcache/cache.go rename to plugins/infra/storage/diskcache/cache.go index e387e7c9..15d95b8f 100644 --- a/internal/infra/diskcache/cache.go +++ b/plugins/infra/storage/diskcache/cache.go @@ -10,10 +10,8 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" pkgcache "github.com/Rain-kl/Wavelet/pkg/cache/disk" + db "github.com/Rain-kl/Wavelet/pkg/persistence" ) // Status represents the runtime cache statistics. @@ -71,24 +69,27 @@ func (c *DiskCache) ReloadConfig(ctx context.Context) { // 1. Max Size maxSizeMB := int64(defaultMaxSizeMB) - if scMaxSize, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyDiskCacheMaxSizeMB); err == nil && scMaxSize.Value != "" { - if val, err := strconv.ParseInt(scMaxSize.Value, 10, 64); err == nil && val > 0 { + var maxVal string + if err := db.DB(ctx).Table("w_system_configs").Where("key = ?", "disk_cache_max_size_mb").Pluck("value", &maxVal).Error; err == nil && maxVal != "" { + if val, err := strconv.ParseInt(maxVal, 10, 64); err == nil && val > 0 { maxSizeMB = val } } // 2. Default TTL ttlMinutes := int64(defaultTTLMinutes) - if scTTL, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyDiskCacheTTLMinutes); err == nil && scTTL.Value != "" { - if val, err := strconv.ParseInt(scTTL.Value, 10, 64); err == nil && val >= 0 { + var ttlVal string + if err := db.DB(ctx).Table("w_system_configs").Where("key = ?", "disk_cache_ttl_minutes").Pluck("value", &ttlVal).Error; err == nil && ttlVal != "" { + if val, err := strconv.ParseInt(ttlVal, 10, 64); err == nil && val >= 0 { ttlMinutes = val } } // 3. LRU Enabled lruEnabled := true - if scLRU, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyDiskCacheLRUEnabled); err == nil && scLRU.Value != "" { - if val, err := strconv.ParseBool(scLRU.Value); err == nil { + var lruVal string + if err := db.DB(ctx).Table("w_system_configs").Where("key = ?", "disk_cache_lru_enabled").Pluck("value", &lruVal).Error; err == nil && lruVal != "" { + if val, err := strconv.ParseBool(lruVal); err == nil { lruEnabled = val } } diff --git a/internal/infra/diskcache/cache_test.go b/plugins/infra/storage/diskcache/cache_test.go similarity index 55% rename from internal/infra/diskcache/cache_test.go rename to plugins/infra/storage/diskcache/cache_test.go index 68221d75..1155a395 100644 --- a/internal/infra/diskcache/cache_test.go +++ b/plugins/infra/storage/diskcache/cache_test.go @@ -8,10 +8,8 @@ import ( "os" "testing" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" - "github.com/Rain-kl/Wavelet/internal/testhelper" + db "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/testhelper" ) func TestDiskCacheReloadConfig(t *testing.T) { @@ -26,13 +24,13 @@ func TestDiskCacheReloadConfig(t *testing.T) { defer func() { _ = c.Clear() }() // Update DB config values - dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyDiskCacheMaxSizeMB).Update("value", "250") - dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyDiskCacheTTLMinutes).Update("value", "120") - dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyDiskCacheLRUEnabled).Update("value", "false") + dbConn.Table("w_system_configs").Where("key = ?", "disk_cache_max_size_mb").Update("value", "250") + dbConn.Table("w_system_configs").Where("key = ?", "disk_cache_ttl_minutes").Update("value", "120") + dbConn.Table("w_system_configs").Where("key = ?", "disk_cache_lru_enabled").Update("value", "false") // Invalidate Redis config cache to force DB reload if db.Redis != nil { - db.Redis.Del(context.Background(), db.PrefixedKey(repository.SystemConfigRedisHashKey)) + db.Redis.Del(context.Background(), db.PrefixedKey("system_configs")) } // Reload config @@ -46,6 +44,6 @@ func TestDiskCacheReloadConfig(t *testing.T) { t.Errorf("expected TTLMinutes to be 120, got %d", status.TTLMinutes) } if status.LRUEnabled != false { - t.Errorf("expected LRUEnabled to be false, got %t", status.LRUEnabled) + t.Errorf("expected LRUEnabled to be false, got %v", status.LRUEnabled) } } diff --git a/internal/infra/objectstore/config.go b/plugins/infra/storage/objectstore/config.go similarity index 87% rename from internal/infra/objectstore/config.go rename to plugins/infra/storage/objectstore/config.go index 5cd1dc8d..a7e38c53 100644 --- a/internal/infra/objectstore/config.go +++ b/plugins/infra/storage/objectstore/config.go @@ -12,9 +12,8 @@ import ( "strings" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/Rain-kl/Wavelet/pkg/persistence" + "gorm.io/gorm" ) @@ -106,21 +105,22 @@ func LoadConfig(ctx context.Context) (Config, error) { return cfg, nil } - return loadConfigByKey(ctx, model.ConfigKeyStorageConfig, DefaultConfig()) + return loadConfigByKey(ctx, "storage_config", DefaultConfig()) } func loadConfigByKey(ctx context.Context, key string, fallback Config) (Config, error) { - sc, err := repository.GetSystemConfigByKey(ctx, key) + var val string + err := db.DB(ctx).Table("w_system_configs").Where("key = ?", key).Pluck("value", &val).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fallback, nil } return Config{}, err } - if strings.TrimSpace(sc.Value) == "" { + if strings.TrimSpace(val) == "" { return fallback, nil } - if err := json.Unmarshal([]byte(sc.Value), &fallback); err != nil { + if err := json.Unmarshal([]byte(val), &fallback); err != nil { return Config{}, fmt.Errorf("parse %s: %w", key, err) } return fallback, nil @@ -178,38 +178,42 @@ func validateObjectConfig(cfg ObjectConfig, endpointRequired bool) error { // SaveActiveConfig persists the active storage configuration. func SaveActiveConfig(ctx context.Context, cfg Config) error { - return saveSystemConfig(ctx, model.ConfigKeyStorageConfig, cfg, "文件存储驱动及连接配置(JSON)") + return saveSystemConfig(ctx, "storage_config", cfg, "文件存储驱动及连接配置(JSON)") } func saveSystemConfig(ctx context.Context, key string, value any, description string) error { err := db.DB(ctx).Transaction(func(tx *gorm.DB) error { return upsertSystemConfig(ctx, tx, key, value, description) }) - if err == nil && key == model.ConfigKeyStorageConfig { + if err == nil && key == "storage_config" { ResetCache() PublishCacheInvalidation(ctx) } return err } -func upsertSystemConfig(ctx context.Context, tx *gorm.DB, key string, value any, description string) error { +func upsertSystemConfig(_ context.Context, tx *gorm.DB, key string, value any, description string) error { data, err := json.Marshal(value) if err != nil { return fmt.Errorf("marshal %s: %w", key, err) } - sc := model.SystemConfig{ + type sysConfig struct { + Key string `gorm:"primaryKey"` + Value string + Type string + Visibility string + Description string + } + sc := sysConfig{ Key: key, Value: string(data), Type: "system", - Visibility: model.ConfigVisibilityHidden, + Visibility: "hidden", Description: description, } - if err := tx.Where("key = ?", key). - Assign(map[string]any{"value": sc.Value, "description": description, "visibility": model.ConfigVisibilityHidden}). - FirstOrCreate(&sc).Error; err != nil { - return err - } - return repository.InvalidateSystemConfigCache(ctx, key) + return tx.Table("w_system_configs").Where("key = ?", key). + Assign(map[string]any{"value": sc.Value, "description": description, "visibility": "hidden"}). + FirstOrCreate(&sc).Error } // MergeMaskedSecrets restores unchanged secrets from the current configuration. diff --git a/internal/infra/objectstore/http.go b/plugins/infra/storage/objectstore/http.go similarity index 100% rename from internal/infra/objectstore/http.go rename to plugins/infra/storage/objectstore/http.go diff --git a/internal/infra/objectstore/local.go b/plugins/infra/storage/objectstore/local.go similarity index 100% rename from internal/infra/objectstore/local.go rename to plugins/infra/storage/objectstore/local.go diff --git a/internal/infra/objectstore/local_test.go b/plugins/infra/storage/objectstore/local_test.go similarity index 100% rename from internal/infra/objectstore/local_test.go rename to plugins/infra/storage/objectstore/local_test.go diff --git a/internal/infra/objectstore/oss.go b/plugins/infra/storage/objectstore/oss.go similarity index 100% rename from internal/infra/objectstore/oss.go rename to plugins/infra/storage/objectstore/oss.go diff --git a/internal/infra/objectstore/s3.go b/plugins/infra/storage/objectstore/s3.go similarity index 100% rename from internal/infra/objectstore/s3.go rename to plugins/infra/storage/objectstore/s3.go diff --git a/internal/infra/objectstore/storage.go b/plugins/infra/storage/objectstore/storage.go similarity index 94% rename from internal/infra/objectstore/storage.go rename to plugins/infra/storage/objectstore/storage.go index c678aba6..5c83ccd8 100644 --- a/internal/infra/objectstore/storage.go +++ b/plugins/infra/storage/objectstore/storage.go @@ -13,9 +13,8 @@ import ( "sync" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" - "github.com/Rain-kl/Wavelet/internal/model" - "github.com/Rain-kl/Wavelet/internal/repository" + "github.com/Rain-kl/Wavelet/pkg/persistence" + "github.com/Rain-kl/Wavelet/pkg/util" "gorm.io/gorm" ) @@ -84,11 +83,12 @@ func PublishCacheInvalidation(ctx context.Context) { // startPubSubListener starts the background subscriber for cache invalidations. func startPubSubListener() { - if db.Redis == nil { + rdb := db.Redis + if rdb == nil { return } util.Go(func() { - pubsub := db.Redis.Subscribe(context.Background(), ConfigInvalidationChannel) + pubsub := rdb.Subscribe(context.Background(), ConfigInvalidationChannel) defer func() { _ = pubsub.Close() }() @@ -125,10 +125,12 @@ func Active(ctx context.Context) (Driver, Backend, error) { return activeDriver, activeBackend, nil } - sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyStorageConfig) + var val string + err := db.DB(ctx).Table("w_system_configs").Where("key = ?", "storage_config").Pluck("value", &val).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return "", nil, err } + sc := struct{ Value string }{Value: val} lastChecked = time.Now() diff --git a/internal/infra/objectstore/storage_test.go b/plugins/infra/storage/objectstore/storage_test.go similarity index 98% rename from internal/infra/objectstore/storage_test.go rename to plugins/infra/storage/objectstore/storage_test.go index 1ea43ec0..ad38b3c6 100644 --- a/internal/infra/objectstore/storage_test.go +++ b/plugins/infra/storage/objectstore/storage_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/Rain-kl/Wavelet/internal/infra/persistence" + "github.com/Rain-kl/Wavelet/pkg/persistence" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" ) diff --git a/internal/infra/objectstore/webdav.go b/plugins/infra/storage/objectstore/webdav.go similarity index 100% rename from internal/infra/objectstore/webdav.go rename to plugins/infra/storage/objectstore/webdav.go diff --git a/plugins/infra/storage/plugin.go b/plugins/infra/storage/plugin.go index 469d7e46..d03d229a 100644 --- a/plugins/infra/storage/plugin.go +++ b/plugins/infra/storage/plugin.go @@ -1,3 +1,6 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + // Package storage provides the object storage and ingestion infrastructure plugin for Cordis. package storage @@ -8,9 +11,9 @@ import ( "github.com/Rain-kl/Wavelet/core" "github.com/Rain-kl/Wavelet/core/contracts" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" - "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/plugins/domain/upload/ingest" + uploadmodels "github.com/Rain-kl/Wavelet/plugins/domain/upload/models" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore" ) // Option configures the storage plugin. @@ -114,7 +117,7 @@ func (s *storageServiceImpl) Delete(ctx context.Context, key string) error { } func (s *storageServiceImpl) Ingest(ctx context.Context, reader io.Reader, opts contracts.IngestOptions) (*contracts.IngestResult, error) { - meta := model.UploadMetadata{ + meta := uploadmodels.UploadMetadata{ Extra: opts.Metadata, } diff --git a/plugins/infra/storage/plugin_test.go b/plugins/infra/storage/plugin_test.go index 630862ee..86d0eadc 100644 --- a/plugins/infra/storage/plugin_test.go +++ b/plugins/infra/storage/plugin_test.go @@ -16,8 +16,8 @@ import ( "github.com/Rain-kl/Wavelet/core" "github.com/Rain-kl/Wavelet/core/contracts" - "github.com/Rain-kl/Wavelet/internal/infra/objectstore" "github.com/Rain-kl/Wavelet/plugins/infra/storage" + "github.com/Rain-kl/Wavelet/plugins/infra/storage/objectstore" ) type mockStorageBackend struct { diff --git a/scripts/update_go_license.sh b/scripts/update_go_license.sh index 85be313f..dc8da13f 100755 --- a/scripts/update_go_license.sh +++ b/scripts/update_go_license.sh @@ -55,9 +55,7 @@ find_go_files() { -o -path './docs' \ -o -path './frontend/node_modules' \ -o -path './frontend/.next' \ - -o -path './frontend/out' \ - -o -path './internal/router/dist' \ - -o -path './internal/router/root/dist' \ + -o -path './plugins/drivers/driver_http/dist' \ -o -path './vendor' \) -prune \ -o -type f -name '*.go' -print }