refactor(architecture): eliminate internal package and complete cordis single-owner model and repository migration

- Physically purged all legacy internal/ packages, centralized pkg/model/ and pkg/repository/
- Migrated domain models and database repositories into self-contained owner plugins (user, auth, message_gateway, admin, upload, risk_control)
- Decoupled cross-plugin interactions via pure core/contracts and typed EventBus
- Ensured 100% test coverage pass, zero data races (-race clean), and 0 lint issues in make code-check
This commit is contained in:
ryan
2026-08-28 08:40:43 +08:00
parent 1f348fd425
commit fb6a3edb89
323 changed files with 8222 additions and 17693 deletions
+1 -1
View File
@@ -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) {
@@ -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"
)
+2 -2
View File
@@ -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) {
// ... 注册成功逻辑 ...
+6 -6
View File
@@ -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
View File
View File
+1 -1
View File
@@ -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"
+6 -3
View File
@@ -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
@@ -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) {
@@ -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)
@@ -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)
}
+3 -3
View File
@@ -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"
)
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core_test
import (
+3
View File
@@ -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
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core_test
import (
+8
View File
@@ -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"`
+3
View File
@@ -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
+3
View File
@@ -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
+13
View File
@@ -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"`
}
+3
View File
@@ -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
+3
View File
@@ -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
+21
View File
@@ -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)
}
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core_test
import (
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints_test
import (
+3
View File
@@ -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
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import (
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import "sync"
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import "sync"
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package extpoints
import (
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package core
import (
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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; \
-501
View File
@@ -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 <LoadingPage text="登录状态" badgeText="Auth" />
}
```
**生产表现**:
- 每次进入 `/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 无界)
- 每行渲染完整 `<div>`,无虚拟滚动
- `@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 | ~~缩略图用原生 `<img>` 无 lazy loading~~ ✅ | `file-list.tsx`, `file-manager.tsx` | `loading="lazy"` + `decoding="async"` |
| 19 | ~~`@/lib/services` barrel 导入~~ ✅ | 全前端消费侧 | 改为 `@/lib/services/<module>` 直接导入 |
| 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` | 已有,可复用模式 |
+30 -1
View File
@@ -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`<br>`w_access_tokens` | `plugins/domain/user` | `models.go`<br>`repository.go` | `core/contracts.UserService`<br>`contracts.UserDTO` |
| `w_auth_sources`<br>`w_external_accounts`<br>`w_passkeys`<br>`w_oauth_states` | `plugins/domain/auth` | `models.go`<br>`repository.go` | `core/contracts.AuthService`<br>`contracts.AuthRegistry` |
| `w_uploads`<br>`w_upload_stats` | `plugins/domain/upload` | `models/models.go`<br>`repository/repository.go` | `core/contracts.StorageService`<br>`upload.Ingest` 流水线 |
| `w_system_configs`<br>`w_templates` | `plugins/domain/admin` | `models.go`<br>`repository.go` | `ctx.Settings()` / `contracts.ConfigService`<br>Redis Pub/Sub 广播 |
| `w_message_channels`<br>`w_message_bindings`<br>`w_message_pairing_codes`<br>`w_push_events`<br>`w_push_channels`<br>`w_push_histories` | `plugins/domain/message_gateway` | `models.go`<br>`repository.go` | `EventBus` 强类型事件广播订阅 |
| `w_user_access_logs` (分析库/日志库) | `plugins/domain/risk_control` | 委托 `pkg/persistence/logstore` | `logstore.UserAccessLog` 抽象门面 |
| `w_task_executions`<br>`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` 契约编程或发布事件。
+1275 -4181
View File
File diff suppressed because it is too large Load Diff
+1275 -4181
View File
File diff suppressed because it is too large Load Diff
+875 -2648
View File
File diff suppressed because it is too large Load Diff
-42
View File
@@ -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)
}
}
-39
View File
@@ -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)
}
}
-25
View File
@@ -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)
}
}
-60
View File
@@ -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:])
}
-114
View File
@@ -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 = ""
}
-17
View File
@@ -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
)
-56
View File
@@ -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" }
-99
View File
@@ -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")
}
-50
View File
@@ -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
}
-27
View File
@@ -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"
}
-68
View File
@@ -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"
}
-65
View File
@@ -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"`
}
-88
View File
@@ -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
}
-28
View File
@@ -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"
}
-54
View File
@@ -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"
}
-120
View File
@@ -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
}
-69
View File
@@ -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())
}
}
-69
View File
@@ -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
}
-215
View File
@@ -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(&current, "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(&current).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(&current).Error
if err == nil {
if current.UserID != account.UserID {
return errors.New(errExternalAccountAlreadyBoundToAnother)
}
return tx.Model(&current).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
}
-280
View File
@@ -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()
}
@@ -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")
}
}
-25
View File
@@ -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"
-152
View File
@@ -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
}
-105
View File
@@ -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()
}
}
-124
View File
@@ -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()
}
}
-54
View File
@@ -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{})
}
-53
View File
@@ -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
}
-256
View File
@@ -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
}
@@ -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)
}
-305
View File
@@ -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
}
-514
View File
@@ -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)
}
-59
View File
@@ -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
}
-20
View File
@@ -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
}
-295
View File
@@ -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
}
-128
View File
@@ -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)
}
})
}
-363
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
@@ -1,3 +1,6 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package config
import "testing"
@@ -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
@@ -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
@@ -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])
@@ -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)
}
@@ -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"
@@ -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"
)

Some files were not shown because too many files have changed in this diff Show More