feat(push): implement system notification and push framework

This commit is contained in:
ryan
2026-06-14 16:52:06 +08:00
parent aee457093d
commit cb018b3b60
60 changed files with 8756 additions and 160 deletions
+3 -3
View File
@@ -115,7 +115,7 @@ func ListFiles(c *gin.Context) {
// @Router /api/v1/admin/uploads/{id} [delete]
func DeleteFile(c *gin.Context) {
ctx := c.Request.Context()
if storageReadOnly(ctx) {
if StorageReadOnly(ctx) {
c.JSON(http.StatusConflict, util.Err(ErrStorageReadOnly))
return
}
@@ -259,7 +259,7 @@ func ListMyFiles(c *gin.Context) {
func DeleteMyFile(c *gin.Context) {
currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
ctx := c.Request.Context()
if storageReadOnly(ctx) {
if StorageReadOnly(ctx) {
c.JSON(http.StatusConflict, util.Err(ErrStorageReadOnly))
return
}
@@ -313,7 +313,7 @@ type updateMyFileRequest struct {
func UpdateMyFile(c *gin.Context) {
currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
ctx := c.Request.Context()
if storageReadOnly(ctx) {
if StorageReadOnly(ctx) {
c.JSON(http.StatusConflict, util.Err(ErrStorageReadOnly))
return
}
+2 -2
View File
@@ -362,7 +362,7 @@ func tryInstantUpload(ctx context.Context, c *gin.Context, currUser *model.User,
if err != nil {
return false, err
}
if storageReadOnly(ctx) {
if StorageReadOnly(ctx) {
c.JSON(http.StatusConflict, util.Err(ErrStorageReadOnly))
return true, nil
}
@@ -396,7 +396,7 @@ func tryInstantUpload(ctx context.Context, c *gin.Context, currUser *model.User,
// storeUploadFile 将文件写入当前活动存储驱动。
func storeUploadFile(ctx context.Context, subPath string, size int64, mimeType string, buf *bytes.Buffer, meta *model.UploadMetadata) (string, string, string) {
if storageReadOnly(ctx) {
if StorageReadOnly(ctx) {
return "", "", ErrStorageReadOnly
}
driver, backend, err := storage.Active(ctx)
+1 -3
View File
@@ -12,7 +12,7 @@ import (
"github.com/Rain-kl/Wavelet/internal/storage"
)
func storageReadOnly(ctx context.Context) bool {
func StorageReadOnly(ctx context.Context) bool {
execution, ok, err := latestStorageMigrationExecution(ctx)
if err != nil {
logger.ErrorF(ctx, "读取存储维护状态失败: %v", err)
@@ -66,5 +66,3 @@ func currentMigrationTargetConfig(ctx context.Context) (storage.Config, bool, er
}
return target, true, nil
}
+2 -95
View File
@@ -11,21 +11,14 @@ import (
"fmt"
"strings"
"sync"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/storage"
"github.com/Rain-kl/Wavelet/internal/task"
"gorm.io/gorm"
)
// 异步任务名称与管理类型定义
const (
// CleanupUnusedUploadsTask 清理未使用上传任务标识
CleanupUnusedUploadsTask = "upload:cleanup_unused"
// TaskTypeCleanupUploads 清理未使用上传管理类型
TaskTypeCleanupUploads = "cleanup_unused_uploads"
// WarmImageCacheTask 图片压缩缓存预热任务标识
WarmImageCacheTask = "upload:warm_image_cache"
// TaskTypeWarmImageCache 图片压缩缓存预热管理类型
@@ -34,18 +27,6 @@ const (
var warmImageCacheMu sync.Mutex
// CleanupUnusedUploadsMeta represents the task metadata.
var CleanupUnusedUploadsMeta = task.TaskMeta{
Type: TaskTypeCleanupUploads,
AsynqTask: CleanupUnusedUploadsTask,
Name: "清理未使用上传",
Description: "清理超过1小时未使用的上传文件",
SupportsTime: false,
MaxRetry: task.DefaultMaxRetry,
Queue: task.QueueDefault,
Retryable: true,
}
// WarmImageCacheMeta represents the image cache warmup task metadata.
var WarmImageCacheMeta = task.TaskMeta{
Type: TaskTypeWarmImageCache,
@@ -73,86 +54,12 @@ type WarmImageCachePayload struct {
Quality string `json:"quality"`
}
// CleanupUnusedUploadsHandler 清理未使用上传文件的异步任务处理器
type CleanupUnusedUploadsHandler struct{}
// SystemCleanupHandler 系统定期垃圾清理异步任务处理器
// WarmImageCacheHandler serially warms compressed image cache entries.
type WarmImageCacheHandler struct{}
// Execute 执行清理未使用上传文件的业务逻辑
func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, _ []byte) (*task.TaskResult, error) {
if storageReadOnly(ctx) {
return nil, errors.New(ErrStorageReadOnly)
}
const batchSize = 100 // 每批处理100个文件
var lastID uint64
var totalProcessed int
var totalDeleted int
// 计算1小时前的时间
oneHourAgo := time.Now().Add(-1 * time.Hour)
task.AppendLog(ctx, "开始扫描未使用上传文件,阈值: %s", oneHourAgo.Format(time.RFC3339))
for {
// 使用游标分页查询未使用且超过1小时的上传记录
var unusedUploads []model.Upload
if err := db.DB(ctx).
Where("id > ? AND status = ? AND created_at < ?", lastID, model.UploadStatusPending, oneHourAgo).
Order("id ASC").
Limit(batchSize).
Find(&unusedUploads).Error; err != nil {
task.AppendLog(ctx, "查询未使用的上传文件失败: %v", err)
return nil, fmt.Errorf(ErrQueryUnusedUploadsFailed, err)
}
// 没有更多数据,退出循环
if len(unusedUploads) == 0 {
break
}
task.AppendLog(ctx, "本批次找到 %d 个需要清理的上传文件", len(unusedUploads))
// 处理每个未使用的上传文件
for _, upload := range unusedUploads {
totalProcessed++
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
// 更新上传记录状态
if err := tx.Model(&model.Upload{}).
Where("id = ? AND status = ?", upload.ID, model.UploadStatusPending).
Update("status", model.UploadStatusDeleted).Error; err != nil {
return err
}
driver := storage.Driver(upload.StorageDriver)
if driver == "" {
driver = storage.DriverLocal
}
backend, err := storage.ForDriver(ctx, driver)
if err != nil {
return err
}
if err := backend.Delete(ctx, upload.FilePath); err != nil {
return err
}
return nil
}); err != nil {
task.AppendLog(ctx, "清理上传文件失败 [ID:%d]: %v", upload.ID, err)
lastID = upload.ID
continue
}
totalDeleted++
lastID = upload.ID
}
}
msg := fmt.Sprintf("共处理 %d 个文件,成功删除 %d 个", totalProcessed, totalDeleted)
task.AppendLog(ctx, "%s", msg)
return &task.TaskResult{Message: msg}, nil
}
// Execute 执行系统清理(包含文件清理和历史消息推送日志清理)
// ValidatePayload validates and normalizes image cache warmup parameters.
func (h *WarmImageCacheHandler) ValidatePayload(payload []byte) ([]byte, error) {
+50 -9
View File
@@ -20,6 +20,7 @@ import (
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/diskcache"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/service"
"github.com/Rain-kl/Wavelet/internal/storage"
"github.com/Rain-kl/Wavelet/internal/task"
"github.com/Rain-kl/Wavelet/internal/testhelper"
@@ -27,7 +28,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestCleanupUnusedUploadsHandler_Execute(t *testing.T) {
func TestSystemCleanupHandler_Execute(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
@@ -42,6 +43,8 @@ func TestCleanupUnusedUploadsHandler_Execute(t *testing.T) {
defer storageMock()
ctx := context.Background()
err := db.DB(ctx).AutoMigrate(&model.PushHistory{})
require.NoError(t, err)
// 准备测试数据:创建一些上传记录
now := time.Now()
@@ -81,14 +84,40 @@ func TestCleanupUnusedUploadsHandler_Execute(t *testing.T) {
require.NoError(t, err)
}
// 准备推送历史测试数据:1个旧的(应删除),1个新的(应保留)
oldPush := &model.PushHistory{
EventKey: "admin_login",
Channel: "email",
Target: "admin@test.com",
Title: "Old Login",
Content: "Old Content",
Level: "INFO",
Status: "success",
CreatedAt: now.AddDate(0, 0, -10),
}
newPush := &model.PushHistory{
EventKey: "admin_login",
Channel: "lark",
Target: "http://webhook.com",
Title: "New Login",
Content: "New Content",
Level: "INFO",
Status: "success",
CreatedAt: now,
}
err = db.DB(ctx).Create(oldPush).Error
require.NoError(t, err)
err = db.DB(ctx).Create(newPush).Error
require.NoError(t, err)
// 执行 handler
handler := &CleanupUnusedUploadsHandler{}
handler := &service.SystemCleanupHandler{}
result, err := handler.Execute(ctx, nil)
// 验证结果
require.NoError(t, err)
require.NotNil(t, result)
assert.Contains(t, result.Message, "共处理 2 个文件,成功删除 2 个")
assert.Contains(t, result.Message, "系统清理完成。成功清理未使用的上传文件 2/2 个;清理历史推送审计日志 1 条。")
// 验证数据库状态:pending 且超过1小时的应被标记为 deleted
var pendingCount int64
@@ -102,9 +131,19 @@ func TestCleanupUnusedUploadsHandler_Execute(t *testing.T) {
var usedCount int64
db.DB(ctx).Model(&model.Upload{}).Where("status = ?", model.UploadStatusUsed).Count(&usedCount)
assert.Equal(t, int64(1), usedCount, "used 状态的文件不应受影响")
// 验证推送历史数据状态:10天前的应被删除,今天的应保留
var pushCount int64
db.DB(ctx).Model(&model.PushHistory{}).Count(&pushCount)
assert.Equal(t, int64(1), pushCount, "应只剩1条推送历史记录")
var remainingPush model.PushHistory
err = db.DB(ctx).First(&remainingPush).Error
require.NoError(t, err)
assert.Equal(t, "New Login", remainingPush.Title)
}
func TestCleanupUnusedUploadsHandler_ExecuteNoFiles(t *testing.T) {
func TestSystemCleanupHandler_ExecuteNoFiles(t *testing.T) {
_, _, cleanup := testhelper.SetupTestEnvironment(t)
defer cleanup()
@@ -119,19 +158,21 @@ func TestCleanupUnusedUploadsHandler_ExecuteNoFiles(t *testing.T) {
defer storageMock()
ctx := context.Background()
err := db.DB(ctx).AutoMigrate(&model.PushHistory{})
require.NoError(t, err)
// 没有任何上传记录
handler := &CleanupUnusedUploadsHandler{}
handler := &service.SystemCleanupHandler{}
result, err := handler.Execute(ctx, nil)
require.NoError(t, err)
require.NotNil(t, result)
assert.Contains(t, result.Message, "共处理 0 个文件,成功删除 0 个")
assert.Contains(t, result.Message, "系统清理完成。成功清理未使用的上传文件 0/0 个;清理历史推送审计日志 0 条。")
}
func TestCleanupUnusedUploadsHandler_ImplementsTaskHandler(t *testing.T) {
// 编译期验证 CleanupUnusedUploadsHandler 实现了 TaskHandler 接口
var _ task.TaskHandler = (*CleanupUnusedUploadsHandler)(nil)
func TestSystemCleanupHandler_ImplementsTaskHandler(t *testing.T) {
// 编译期验证 SystemCleanupHandler 实现了 TaskHandler 接口
var _ task.TaskHandler = (*service.SystemCleanupHandler)(nil)
}
func TestWarmImageCacheHandlerValidatePayload(t *testing.T) {