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
}