mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 08:06:37 +08:00
feat(core): bind request services and implement registered tasks
Wire plugin services through Bind/InjectFrom and AppContext so HTTP and workers resolve dependencies after Apply. Register TaskHandler objects with persisted results, and implement send_email_code, mail:send, cleanup_inactive_users, and dispatch_bot_msg.
This commit is contained in:
@@ -40,4 +40,12 @@ const (
|
||||
//nolint:gosec // error message, not hardcoded credentials
|
||||
errServicePasswordTooShort = "密码长度至少为 8 位"
|
||||
errUniqueUsernameFailed = "failed to generate unique username"
|
||||
errInvalidEmail = "邮箱地址无效"
|
||||
errInvalidEmailCode = "验证码必须是 6 位数字"
|
||||
errInvalidTaskPayload = "任务参数无效"
|
||||
errMailSubjectRequired = "邮件主题不能为空"
|
||||
errMailBodyRequired = "邮件内容不能为空"
|
||||
errSMTPNotConfigured = "SMTP 未配置"
|
||||
errEmailCacheUnavailable = "缓存服务不可用,无法保存验证码"
|
||||
errSendEmailFailed = "邮件发送失败"
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
@@ -190,10 +191,36 @@ func Logout(c *gin.Context) {
|
||||
// @Tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body user.sendEmailCodeRequest true "目标邮箱"
|
||||
// @Success 200 {object} response.Any "发送成功"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 500 {object} response.Any "发送失败"
|
||||
// @Router /api/v1/user/send-email-code [post]
|
||||
func SendEmailCode(c *gin.Context) {
|
||||
var req sendEmailCodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortBadRequest(c, errInvalidParams)
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(sendEmailCodePayload{Email: req.Email})
|
||||
if err != nil {
|
||||
response.AbortInternal(c, errSendEmailFailed)
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if taskSvc := getTaskService(ctx); taskSvc != nil {
|
||||
if _, err := taskSvc.Dispatch(ctx, TaskTypeSendEmailCode, payload, "http"); err != nil {
|
||||
logger.ErrorF(ctx, "dispatch send_email_code failed: %v", err)
|
||||
response.AbortInternal(c, errSendEmailFailed)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(gin.H{"sent": true}))
|
||||
return
|
||||
}
|
||||
if _, err := (&SendEmailCodeHandler{}).Execute(ctx, payload); err != nil {
|
||||
response.AbortBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(gin.H{"sent": true}))
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,10 @@ type registerRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type sendEmailCodeRequest struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
}
|
||||
|
||||
// changePasswordRequest 修改密码请求参数
|
||||
type changePasswordRequest struct {
|
||||
OldPassword string `json:"old_password" binding:"required"`
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/core/extpoints"
|
||||
"Wavelet/pkg/ginutil"
|
||||
"context"
|
||||
"embed"
|
||||
"reflect"
|
||||
|
||||
@@ -76,16 +75,13 @@ func (p *Plugin) Manifest() core.Manifest {
|
||||
|
||||
// Apply registers user migrations, services, routes, tasks, schedules, and settings into the Context.
|
||||
func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
// 0. Bind DBService from Context
|
||||
if db, err := core.Inject[contracts.DBService](ctx); err == nil && db != nil {
|
||||
SetDBService(db)
|
||||
} else {
|
||||
core.When[contracts.DBService](ctx, func(db contracts.DBService) {
|
||||
SetDBService(db)
|
||||
})
|
||||
}
|
||||
core.Bind[contracts.DBService](ctx, SetDBService)
|
||||
core.Bind[contracts.CacheService](ctx, SetCacheService)
|
||||
core.Bind[contracts.TaskService](ctx, SetTaskService)
|
||||
ctx.OnDispose(func() error {
|
||||
SetDBService(nil)
|
||||
SetCacheService(nil)
|
||||
SetTaskService(nil)
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -101,11 +97,8 @@ func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
if mw, ok := authSvc.DisallowTokenAuthMiddleware().(gin.HandlerFunc); ok {
|
||||
noTokenMW = mw
|
||||
}
|
||||
} else {
|
||||
core.When[contracts.AuthService](ctx, func(svc contracts.AuthService) {
|
||||
SetAuthService(svc)
|
||||
})
|
||||
}
|
||||
core.Bind[contracts.AuthService](ctx, SetAuthService)
|
||||
ctx.OnDispose(func() error {
|
||||
SetAuthService(nil)
|
||||
return nil
|
||||
@@ -155,90 +148,12 @@ func (p *Plugin) Apply(ctx *core.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
defaultUserTaskRetry = 3
|
||||
paramTypeString = "string"
|
||||
paramNameEmail = "email"
|
||||
)
|
||||
|
||||
// 4. Register background tasks
|
||||
ctx.Task().Register("user:send_email_code", func(_ context.Context, _ []byte) error {
|
||||
return nil
|
||||
},
|
||||
extpoints.WithTaskType("send_email_code"),
|
||||
extpoints.WithTaskName("发送邮箱验证码"),
|
||||
extpoints.WithTaskDescription("异步发送用户注册与验证邮箱验证码"),
|
||||
extpoints.WithTaskCategory("user"),
|
||||
extpoints.WithTaskRetry(defaultUserTaskRetry),
|
||||
extpoints.WithTaskQueue("default"),
|
||||
extpoints.WithTaskRetryable(true),
|
||||
extpoints.WithTaskParams(
|
||||
contracts.TaskParamDTO{
|
||||
Name: paramNameEmail,
|
||||
Label: "目标邮箱",
|
||||
Type: paramTypeString,
|
||||
Required: true,
|
||||
Placeholder: "user@example.com",
|
||||
Description: "接收验证码的目标邮箱",
|
||||
},
|
||||
contracts.TaskParamDTO{
|
||||
Name: "code",
|
||||
Label: "验证码",
|
||||
Type: paramTypeString,
|
||||
Required: true,
|
||||
Placeholder: "123456",
|
||||
Description: "6 位数字验证码",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
ctx.Task().Register("mail:send", func(_ context.Context, _ []byte) error {
|
||||
return nil
|
||||
},
|
||||
extpoints.WithTaskType("send_email"),
|
||||
extpoints.WithTaskName("发送邮件"),
|
||||
extpoints.WithTaskDescription("异步发送系统邮件"),
|
||||
extpoints.WithTaskCategory("mail"),
|
||||
extpoints.WithTaskRetry(defaultUserTaskRetry),
|
||||
extpoints.WithTaskQueue("default"),
|
||||
extpoints.WithTaskRetryable(true),
|
||||
extpoints.WithTaskParams(
|
||||
contracts.TaskParamDTO{
|
||||
Name: "to",
|
||||
Label: "接收邮箱 (To)",
|
||||
Type: paramTypeString,
|
||||
Required: true,
|
||||
Placeholder: "receiver@example.com",
|
||||
Description: "接收邮件的目标邮箱地址",
|
||||
},
|
||||
contracts.TaskParamDTO{
|
||||
Name: "subject",
|
||||
Label: "邮件主题 (Subject)",
|
||||
Type: paramTypeString,
|
||||
Required: true,
|
||||
Placeholder: "请输入邮件主题",
|
||||
Description: "发送邮件的主题标题",
|
||||
},
|
||||
contracts.TaskParamDTO{
|
||||
Name: "body",
|
||||
Label: "邮件内容 (Body)",
|
||||
Type: "text",
|
||||
Required: true,
|
||||
Placeholder: "请输入邮件内容(支持 HTML格式)",
|
||||
Description: "发送邮件的内容主体",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
ctx.Task().Register("user:cleanup_inactive", func(_ context.Context, _ []byte) error {
|
||||
return nil
|
||||
},
|
||||
extpoints.WithTaskType("cleanup_inactive_users"),
|
||||
extpoints.WithTaskName("清理未激活用户"),
|
||||
extpoints.WithTaskDescription("清理长期未激活的注册用户与临时凭据"),
|
||||
extpoints.WithTaskCategory("user"),
|
||||
extpoints.WithTaskQueue("default"),
|
||||
)
|
||||
ctx.Task().Register(TaskSendEmailCode, &SendEmailCodeHandler{},
|
||||
extpoints.WithTaskMeta(SendEmailCodeMeta), extpoints.WithTaskRetry(defaultUserTaskRetry))
|
||||
ctx.Task().Register(TaskSendMail, &SendMailHandler{},
|
||||
extpoints.WithTaskMeta(SendMailMeta), extpoints.WithTaskRetry(defaultUserTaskRetry))
|
||||
ctx.Task().Register(TaskCleanupInactive, &CleanupInactiveHandler{},
|
||||
extpoints.WithTaskMeta(CleanupInactiveMeta))
|
||||
|
||||
// 5. Register Settings Schemas
|
||||
ctx.Settings().Register(extpoints.SettingSchema{
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/util"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -27,10 +29,8 @@ func SetDBService(s contracts.DBService) {
|
||||
}
|
||||
|
||||
func getDB(ctx context.Context) *gorm.DB {
|
||||
if c, ok := ctx.(*core.Context); ok && c != nil {
|
||||
if s, err := core.Inject[contracts.DBService](c); err == nil && s != nil {
|
||||
return s.DB(ctx)
|
||||
}
|
||||
if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil {
|
||||
return s.DB(ctx)
|
||||
}
|
||||
|
||||
dbMu.RLock()
|
||||
@@ -180,6 +180,26 @@ func DeleteUserWithRelations(ctx context.Context, id uint64) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ListInactiveNeverLoggedInUserIDs returns non-admin users created before cutoff
|
||||
// who have never logged in. Seeded admin/system accounts are excluded.
|
||||
func ListInactiveNeverLoggedInUserIDs(ctx context.Context, cutoff time.Time) ([]uint64, error) {
|
||||
db := getDB(ctx)
|
||||
if db == nil {
|
||||
return nil, errors.New("database not available")
|
||||
}
|
||||
var ids []uint64
|
||||
unixEpoch := time.Unix(0, 0).UTC()
|
||||
err := db.Model(&User{}).
|
||||
Where("is_admin = ? AND username NOT IN ?", false, []string{"admin", "system"}).
|
||||
Where("created_at < ?", cutoff).
|
||||
Where("last_login_at IS NULL OR last_login_at < ?", unixEpoch).
|
||||
Pluck("id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// GetFirstAdminUser 获取第一个管理员用户
|
||||
func GetFirstAdminUser(ctx context.Context) (*User, error) {
|
||||
var u User
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/logger"
|
||||
pkgmail "Wavelet/pkg/mail"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
// TaskSendEmailCode is the queue pattern for email verification codes.
|
||||
TaskSendEmailCode = "user:send_email_code"
|
||||
// TaskTypeSendEmailCode is the admin type identifier for email verification codes.
|
||||
TaskTypeSendEmailCode = "send_email_code"
|
||||
// TaskSendMail is the queue pattern for generic outbound mail.
|
||||
TaskSendMail = "mail:send"
|
||||
// TaskTypeSendMail is the admin type identifier for generic outbound mail.
|
||||
TaskTypeSendMail = "send_email"
|
||||
// TaskCleanupInactive is the queue pattern for inactive-user cleanup.
|
||||
TaskCleanupInactive = "user:cleanup_inactive"
|
||||
// TaskTypeCleanupInactive is the admin type identifier for inactive-user cleanup.
|
||||
TaskTypeCleanupInactive = "cleanup_inactive_users"
|
||||
|
||||
defaultUserTaskRetry = 3
|
||||
emailCodeTTL = 10 * time.Minute
|
||||
emailCodeCacheKeyPrefix = "user:email_code:"
|
||||
inactiveRetentionDays = 30
|
||||
hoursPerDay = 24
|
||||
inactiveRetention = inactiveRetentionDays * hoursPerDay * time.Hour
|
||||
smtpConfigKeyHost = "smtp_host"
|
||||
smtpConfigKeyPort = "smtp_port"
|
||||
smtpConfigKeyUsername = "smtp_username"
|
||||
smtpConfigKeyPassword = "smtp_password"
|
||||
defaultSMTPPort = 587
|
||||
emailCodeLength = 6
|
||||
emailCodeModulo = 1000000
|
||||
taskQueueDefault = "default"
|
||||
taskParamTypeString = "string"
|
||||
taskParamTypeText = "text"
|
||||
paramNameEmail = "email"
|
||||
)
|
||||
|
||||
var smtpConfigKeys = []string{
|
||||
smtpConfigKeyHost, smtpConfigKeyPort, smtpConfigKeyUsername, smtpConfigKeyPassword,
|
||||
}
|
||||
|
||||
var (
|
||||
cacheMu sync.RWMutex
|
||||
cacheSvc contracts.CacheService
|
||||
taskMu sync.RWMutex
|
||||
taskSvc contracts.TaskService
|
||||
)
|
||||
|
||||
// SetCacheService sets the cache contract used to store email verification codes.
|
||||
func SetCacheService(s contracts.CacheService) {
|
||||
cacheMu.Lock()
|
||||
defer cacheMu.Unlock()
|
||||
cacheSvc = s
|
||||
}
|
||||
|
||||
// SetTaskService sets the task contract used by HTTP handlers to enqueue mail jobs.
|
||||
func SetTaskService(s contracts.TaskService) {
|
||||
taskMu.Lock()
|
||||
defer taskMu.Unlock()
|
||||
taskSvc = s
|
||||
}
|
||||
|
||||
func getCache(ctx context.Context) contracts.CacheService {
|
||||
if s, err := core.InjectFrom[contracts.CacheService](ctx); err == nil && s != nil {
|
||||
return s
|
||||
}
|
||||
cacheMu.RLock()
|
||||
defer cacheMu.RUnlock()
|
||||
return cacheSvc
|
||||
}
|
||||
|
||||
func getTaskService(ctx context.Context) contracts.TaskService {
|
||||
if s, err := core.InjectFrom[contracts.TaskService](ctx); err == nil && s != nil {
|
||||
return s
|
||||
}
|
||||
taskMu.RLock()
|
||||
defer taskMu.RUnlock()
|
||||
return taskSvc
|
||||
}
|
||||
|
||||
func appendTaskLog(ctx context.Context, format string, args ...any) {
|
||||
if svc := getTaskService(ctx); svc != nil {
|
||||
svc.AppendLog(ctx, format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// SendEmailCodeMeta describes the email verification-code task.
|
||||
var SendEmailCodeMeta = contracts.TaskMetaDTO{
|
||||
Type: TaskTypeSendEmailCode,
|
||||
AsynqTask: TaskSendEmailCode,
|
||||
Name: "发送邮箱验证码",
|
||||
DisplayName: "发送邮箱验证码",
|
||||
Description: "异步发送用户注册与验证邮箱验证码",
|
||||
Category: "user",
|
||||
MaxRetry: defaultUserTaskRetry,
|
||||
Queue: taskQueueDefault,
|
||||
Retryable: true,
|
||||
Params: []contracts.TaskParamDTO{
|
||||
{Name: paramNameEmail, Label: "目标邮箱", Type: taskParamTypeString, Required: true, Placeholder: "user@example.com", Description: "接收验证码的目标邮箱"},
|
||||
{Name: "code", Label: "验证码", Type: taskParamTypeString, Required: false, Placeholder: "123456", Description: "6 位数字验证码,留空则自动生成"},
|
||||
},
|
||||
}
|
||||
|
||||
// SendMailMeta describes the generic outbound-mail task.
|
||||
var SendMailMeta = contracts.TaskMetaDTO{
|
||||
Type: TaskTypeSendMail,
|
||||
AsynqTask: TaskSendMail,
|
||||
Name: "发送邮件",
|
||||
DisplayName: "发送邮件",
|
||||
Description: "异步发送系统邮件",
|
||||
Category: "mail",
|
||||
MaxRetry: defaultUserTaskRetry,
|
||||
Queue: taskQueueDefault,
|
||||
Retryable: true,
|
||||
Params: []contracts.TaskParamDTO{
|
||||
{Name: "to", Label: "接收邮箱 (To)", Type: taskParamTypeString, Required: true, Placeholder: "receiver@example.com", Description: "接收邮件的目标邮箱地址"},
|
||||
{Name: "subject", Label: "邮件主题 (Subject)", Type: taskParamTypeString, Required: true, Placeholder: "请输入邮件主题", Description: "发送邮件的主题标题"},
|
||||
{Name: "body", Label: "邮件内容 (Body)", Type: taskParamTypeText, Required: true, Placeholder: "请输入邮件内容(支持 HTML格式)", Description: "发送邮件的内容主体"},
|
||||
},
|
||||
}
|
||||
|
||||
// CleanupInactiveMeta describes the inactive-user cleanup task.
|
||||
var CleanupInactiveMeta = contracts.TaskMetaDTO{
|
||||
Type: TaskTypeCleanupInactive,
|
||||
AsynqTask: TaskCleanupInactive,
|
||||
Name: "清理未激活用户",
|
||||
DisplayName: "清理未激活用户",
|
||||
Description: "清理长期未登录的注册用户及其访问令牌",
|
||||
Category: "user",
|
||||
Queue: taskQueueDefault,
|
||||
Retryable: true,
|
||||
}
|
||||
|
||||
type sendEmailCodePayload struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type sendMailPayload struct {
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// SendEmailCodeHandler sends a 6-digit email verification code and caches it.
|
||||
type SendEmailCodeHandler struct{}
|
||||
|
||||
// ValidatePayload checks the destination address and optional code.
|
||||
func (h *SendEmailCodeHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||
p, err := parseSendEmailCodePayload(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(p)
|
||||
}
|
||||
|
||||
// Execute generates (if needed), caches, and emails the verification code.
|
||||
func (h *SendEmailCodeHandler) Execute(ctx context.Context, payload []byte) (*contracts.TaskResultDTO, error) {
|
||||
p, err := parseSendEmailCodePayload(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Code == "" {
|
||||
p.Code, err = generateEmailCode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
cache := getCache(ctx)
|
||||
if cache == nil {
|
||||
return nil, errors.New(errEmailCacheUnavailable)
|
||||
}
|
||||
if err := cache.Set(ctx, emailCodeCacheKey(p.Email), p.Code, emailCodeTTL); err != nil {
|
||||
return nil, fmt.Errorf("store email code: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := loadSMTPConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subject := "邮箱验证码"
|
||||
body := fmt.Sprintf("<p>您的验证码是 <b>%s</b>,%d 分钟内有效。</p>", p.Code, int(emailCodeTTL.Minutes()))
|
||||
appendTaskLog(ctx, "发送邮箱验证码到 %s", maskEmail(p.Email))
|
||||
if err := pkgmail.SendMail(ctx, cfg, p.Email, subject, body); err != nil {
|
||||
logger.ErrorF(ctx, "send email code failed: %v", err)
|
||||
return nil, errors.New(errSendEmailFailed)
|
||||
}
|
||||
return &contracts.TaskResultDTO{Message: fmt.Sprintf("验证码已发送至 %s", maskEmail(p.Email))}, nil
|
||||
}
|
||||
|
||||
// SendMailHandler sends a generic HTML email through the configured SMTP server.
|
||||
type SendMailHandler struct{}
|
||||
|
||||
// ValidatePayload checks to/subject/body.
|
||||
func (h *SendMailHandler) ValidatePayload(payload []byte) ([]byte, error) {
|
||||
p, err := parseSendMailPayload(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(p)
|
||||
}
|
||||
|
||||
// Execute sends the mail.
|
||||
func (h *SendMailHandler) Execute(ctx context.Context, payload []byte) (*contracts.TaskResultDTO, error) {
|
||||
p, err := parseSendMailPayload(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := loadSMTPConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendTaskLog(ctx, "发送邮件到 %s,主题: %s", maskEmail(p.To), p.Subject)
|
||||
if err := pkgmail.SendMail(ctx, cfg, p.To, p.Subject, p.Body); err != nil {
|
||||
logger.ErrorF(ctx, "send mail failed: %v", err)
|
||||
return nil, errors.New(errSendEmailFailed)
|
||||
}
|
||||
return &contracts.TaskResultDTO{Message: fmt.Sprintf("邮件已发送至 %s", maskEmail(p.To))}, nil
|
||||
}
|
||||
|
||||
// CleanupInactiveHandler deletes users who registered long ago and never logged in.
|
||||
type CleanupInactiveHandler struct{}
|
||||
|
||||
// Execute removes stale never-logged-in non-admin users and their access tokens.
|
||||
func (h *CleanupInactiveHandler) Execute(ctx context.Context, _ []byte) (*contracts.TaskResultDTO, error) {
|
||||
cutoff := time.Now().Add(-inactiveRetention)
|
||||
ids, err := ListInactiveNeverLoggedInUserIDs(ctx, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendTaskLog(ctx, "扫描到 %d 个超过 %d 天未登录的注册用户", len(ids), int(inactiveRetention.Hours()/float64(hoursPerDay)))
|
||||
deleted := 0
|
||||
for _, id := range ids {
|
||||
if err := DeleteUserWithRelations(ctx, id); err != nil {
|
||||
logger.ErrorF(ctx, "cleanup inactive user %d failed: %v", id, err)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
msg := fmt.Sprintf("已清理 %d 个长期未登录用户及其访问令牌", deleted)
|
||||
appendTaskLog(ctx, "%s", msg)
|
||||
return &contracts.TaskResultDTO{Message: msg}, nil
|
||||
}
|
||||
|
||||
func parseSendEmailCodePayload(payload []byte) (sendEmailCodePayload, error) {
|
||||
var p sendEmailCodePayload
|
||||
if len(payload) > 0 {
|
||||
if err := json.Unmarshal(payload, &p); err != nil {
|
||||
return p, errors.New(errInvalidTaskPayload)
|
||||
}
|
||||
}
|
||||
p.Email = normalizeEmail(p.Email)
|
||||
if err := validateEmail(p.Email); err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.Code = strings.TrimSpace(p.Code)
|
||||
if p.Code != "" && !isSixDigitCode(p.Code) {
|
||||
return p, errors.New(errInvalidEmailCode)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func parseSendMailPayload(payload []byte) (sendMailPayload, error) {
|
||||
var p sendMailPayload
|
||||
if err := json.Unmarshal(payload, &p); err != nil {
|
||||
return p, errors.New(errInvalidTaskPayload)
|
||||
}
|
||||
p.To = normalizeEmail(p.To)
|
||||
p.Subject = strings.TrimSpace(p.Subject)
|
||||
if err := validateEmail(p.To); err != nil {
|
||||
return p, err
|
||||
}
|
||||
if p.Subject == "" {
|
||||
return p, errors.New(errMailSubjectRequired)
|
||||
}
|
||||
if strings.TrimSpace(p.Body) == "" {
|
||||
return p, errors.New(errMailBodyRequired)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func loadSMTPConfig(ctx context.Context) (pkgmail.Config, error) {
|
||||
db := getDB(ctx)
|
||||
if db == nil {
|
||||
return pkgmail.Config{}, errors.New(errSMTPNotConfigured)
|
||||
}
|
||||
var rows []struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
if err := db.Table("w_system_configs").
|
||||
Select("key", "value").
|
||||
Where("key IN ?", smtpConfigKeys).
|
||||
Find(&rows).Error; err != nil {
|
||||
return pkgmail.Config{}, fmt.Errorf("read smtp config: %w", err)
|
||||
}
|
||||
cfg := pkgmail.Config{Port: defaultSMTPPort}
|
||||
for _, row := range rows {
|
||||
switch row.Key {
|
||||
case smtpConfigKeyHost:
|
||||
cfg.Host = strings.TrimSpace(row.Value)
|
||||
case smtpConfigKeyPort:
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(row.Value)); err == nil && n > 0 {
|
||||
cfg.Port = n
|
||||
}
|
||||
case smtpConfigKeyUsername:
|
||||
cfg.Username = strings.TrimSpace(row.Value)
|
||||
case smtpConfigKeyPassword:
|
||||
cfg.Password = row.Value
|
||||
}
|
||||
}
|
||||
if cfg.Host == "" || cfg.Username == "" {
|
||||
return pkgmail.Config{}, errors.New(errSMTPNotConfigured)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func generateEmailCode() (string, error) {
|
||||
var buf [4]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
n := binary.BigEndian.Uint32(buf[:]) % emailCodeModulo
|
||||
return fmt.Sprintf("%06d", n), nil
|
||||
}
|
||||
|
||||
func emailCodeCacheKey(email string) string {
|
||||
return emailCodeCacheKeyPrefix + normalizeEmail(email)
|
||||
}
|
||||
|
||||
func normalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
func validateEmail(email string) error {
|
||||
if email == "" {
|
||||
return errors.New(errEmailEmpty)
|
||||
}
|
||||
addr, err := mail.ParseAddress(email)
|
||||
if err != nil || !strings.EqualFold(addr.Address, email) {
|
||||
return errors.New(errInvalidEmail)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSixDigitCode(code string) bool {
|
||||
if len(code) != emailCodeLength {
|
||||
return false
|
||||
}
|
||||
for _, r := range code {
|
||||
if !unicode.IsDigit(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func maskEmail(email string) string {
|
||||
at := strings.IndexByte(email, '@')
|
||||
if at <= 1 {
|
||||
return "***"
|
||||
}
|
||||
return email[:1] + "***" + email[at:]
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package user_test
|
||||
|
||||
import (
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/plugins/domain/user"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type taskTestDB struct{ db *gorm.DB }
|
||||
|
||||
func (m *taskTestDB) GORM() *gorm.DB { return m.db }
|
||||
func (m *taskTestDB) DB(ctx context.Context) *gorm.DB { return m.db.WithContext(ctx) }
|
||||
func (m *taskTestDB) Named(_ string) *gorm.DB { return m.db }
|
||||
|
||||
type sysConfigRow struct {
|
||||
Key string `gorm:"primaryKey;size:64"`
|
||||
Value string `gorm:"type:text"`
|
||||
}
|
||||
|
||||
func (sysConfigRow) TableName() string { return "w_system_configs" }
|
||||
|
||||
func setupUserTaskDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
_ = idgen.Init(1)
|
||||
testDB, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "user_task.db")), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, testDB.AutoMigrate(&user.User{}, &user.AccessToken{}, &sysConfigRow{}))
|
||||
user.SetDBService(&taskTestDB{db: testDB})
|
||||
t.Cleanup(func() { user.SetDBService(nil) })
|
||||
return testDB
|
||||
}
|
||||
|
||||
func TestSendEmailCodeValidatePayload(t *testing.T) {
|
||||
h := &user.SendEmailCodeHandler{}
|
||||
_, err := h.ValidatePayload([]byte(`{"email":"not-an-email"}`))
|
||||
require.Error(t, err)
|
||||
|
||||
out, err := h.ValidatePayload([]byte(`{"email":"User@Example.com"}`))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"user@example.com"`)
|
||||
}
|
||||
|
||||
func TestSendMailValidatePayload(t *testing.T) {
|
||||
h := &user.SendMailHandler{}
|
||||
_, err := h.ValidatePayload([]byte(`{"to":"a@b.com","subject":"","body":"x"}`))
|
||||
require.Error(t, err)
|
||||
_, err = h.ValidatePayload([]byte(`{"to":"a@b.com","subject":"Hi","body":"<p>ok</p>"}`))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSendMailRequiresSMTP(t *testing.T) {
|
||||
setupUserTaskDB(t)
|
||||
h := &user.SendMailHandler{}
|
||||
_, err := h.Execute(context.Background(), []byte(`{"to":"a@b.com","subject":"Hi","body":"<p>ok</p>"}`))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "SMTP")
|
||||
}
|
||||
|
||||
func TestCleanupInactiveNeverLoggedInUsers(t *testing.T) {
|
||||
db := setupUserTaskDB(t)
|
||||
old := time.Now().Add(-40 * 24 * time.Hour)
|
||||
stale := user.User{ID: 42, Username: "stale", Password: "x", IsActive: true, CreatedAt: old}
|
||||
require.NoError(t, db.Create(&stale).Error)
|
||||
require.NoError(t, db.Model(&stale).Updates(map[string]any{
|
||||
"created_at": old,
|
||||
"last_login_at": time.Time{},
|
||||
}).Error)
|
||||
|
||||
fresh := user.User{ID: 43, Username: "fresh", Password: "x", IsActive: true, LastLoginAt: time.Now()}
|
||||
require.NoError(t, db.Create(&fresh).Error)
|
||||
|
||||
admin := user.User{ID: 1, Username: "admin", Password: "x", IsAdmin: true, CreatedAt: old}
|
||||
require.NoError(t, db.Create(&admin).Error)
|
||||
require.NoError(t, db.Model(&admin).Updates(map[string]any{
|
||||
"created_at": old,
|
||||
"last_login_at": time.Time{},
|
||||
}).Error)
|
||||
|
||||
h := &user.CleanupInactiveHandler{}
|
||||
res, err := h.Execute(context.Background(), nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
assert.Contains(t, res.Message, "1")
|
||||
|
||||
_, err = user.GetUserByID(context.Background(), 42)
|
||||
assert.Error(t, err)
|
||||
_, err = user.GetUserByID(context.Background(), 43)
|
||||
require.NoError(t, err)
|
||||
_, err = user.GetUserByID(context.Background(), 1)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSendEmailCodeMetaExported(t *testing.T) {
|
||||
assert.Equal(t, "send_email_code", user.SendEmailCodeMeta.Type)
|
||||
assert.Equal(t, "user:send_email_code", user.SendEmailCodeMeta.AsynqTask)
|
||||
_ = contracts.TaskHandler(&user.SendEmailCodeHandler{})
|
||||
}
|
||||
Reference in New Issue
Block a user