init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

83
cmd/worker/file_worker.go Normal file
View File

@@ -0,0 +1,83 @@
package main
import (
"context"
"errors"
"os/signal"
"strings"
"syscall"
queueapp "wucher/internal/app/queueing"
appworker "wucher/internal/app/worker"
"wucher/internal/config"
"wucher/internal/queue"
mysqlrepo "wucher/internal/repository/mysql"
s3repo "wucher/internal/repository/s3"
"wucher/internal/resilience"
"wucher/internal/service"
"wucher/internal/shared/pkg/logger"
)
func runFileWorker(appCfg config.AppConfig) error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
appLogger := logger.New(appCfg.Logging).With("component", "worker", "job", "file_processing")
resCfg := appCfg.Resilience
fileQueueCfg := appCfg.FileQueue
fileSQSCfg := appCfg.FileSQS
if err := config.ValidateDistinctQueueURLs(appCfg.SQS, fileSQSCfg); err != nil {
return err
}
if strings.TrimSpace(fileSQSCfg.QueueURL) == "" {
return errors.New("FILE_SQS_QUEUE_URL is required for file worker")
}
db, err := mysqlrepo.Connect(appCfg.MySQL)
if err != nil {
return err
}
fileRepo := mysqlrepo.NewFileManagerFileRepository(db)
sqsBreaker := resilience.NewExecutor("sqs", resCfg.SQS, appLogger, resilience.ClassifySQSError)
consumer, serializer, err := queueapp.NewFileProcessingConsumer(ctx, fileQueueCfg, fileSQSCfg, sqsBreaker)
if err != nil {
return err
}
idempotency := queueapp.NewIdempotencyStoreWithPrefix(db, fileQueueCfg.IdempotencyKeyPrefix)
useCaseDeps := service.FileProcessingUseCaseDependencies{
Logger: appLogger,
FileStatusEvents: fileRepo,
GotenbergURL: appCfg.Gotenberg.URL,
}
fileStorageCfg := appCfg.FileStorage
if fileStorageCfg.Bucket != "" && fileStorageCfg.Region != "" {
storage, err := s3repo.NewFileObjectStorage(ctx, fileStorageCfg)
if err != nil {
return err
}
useCaseDeps.Storage = storage
}
useCase := service.NewFileProcessingUseCase(fileRepo, useCaseDeps)
handler := queue.NewFileProcessingJobQueueHandler(useCase, service.IsFileProcessingPermanentError)
var dispatcher *appworker.FileProcessingOutboxDispatcher
if fileQueueCfg.OutboxEnabled {
rawProducer, err := queueapp.NewQueueProducer(ctx, fileSQSCfg, sqsBreaker, "outbox_dispatcher")
if err != nil {
return err
}
dispatcher = appworker.NewFileProcessingOutboxDispatcher(fileQueueCfg, fileRepo, rawProducer, appLogger)
}
runner := appworker.NewFileProcessingRunner(fileQueueCfg, consumer, serializer, handler, idempotency, dispatcher, appLogger)
appLogger.Info("file worker started",
"backend", "sqs",
"monitor_addr", fileQueueCfg.WorkerMonitorAddr,
"queue_url", fileSQSCfg.QueueURL,
"outbox_enabled", fileQueueCfg.OutboxEnabled,
)
if err := runner.Start(ctx); err != nil {
return err
}
appLogger.Info("file worker stopped")
return nil
}

99
cmd/worker/mail_worker.go Normal file
View File

@@ -0,0 +1,99 @@
package main
import (
"context"
"errors"
"log/slog"
"os/signal"
"syscall"
queueapp "wucher/internal/app/queueing"
appworker "wucher/internal/app/worker"
"wucher/internal/config"
"wucher/internal/shared/pkg/logger"
mysqlrepo "wucher/internal/repository/mysql"
"wucher/internal/resilience"
"wucher/internal/service"
)
func runMailWorker(appCfg config.AppConfig) error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
appLogger := logger.New(appCfg.Logging).With("component", "worker", "job", "email")
resCfg := appCfg.Resilience
emailCfg := appCfg.Email
queueCfg := appCfg.Queue
sqsCfg := appCfg.SQS
db, err := mysqlrepo.Connect(appCfg.MySQL)
if err != nil {
return err
}
authRepo := mysqlrepo.NewAuthRepository(db)
sqsBreaker := resilience.NewExecutor("sqs", resCfg.SQS, appLogger, resilience.ClassifySQSError)
consumer, serializer, err := queueapp.NewEmailConsumer(ctx, queueCfg, sqsCfg, sqsBreaker)
if err != nil {
return err
}
idempotency := queueapp.NewIdempotencyStore(db, queueCfg)
var dispatcher *appworker.OutboxDispatcher
if queueCfg.OutboxEnabled {
rawProducer, err := queueapp.NewQueueProducer(ctx, sqsCfg, sqsBreaker, "outbox_dispatcher")
if err != nil {
return err
}
dispatcher = appworker.NewOutboxDispatcher(queueCfg, authRepo, rawProducer, appLogger)
}
emailSender, err := buildEmailSender(ctx, emailCfg, appCfg.SES, resCfg, appLogger)
if err != nil {
return err
}
handler := service.NewEmailDeliveryHandler(emailSender, appLogger)
runner := appworker.NewRunner(queueCfg, consumer, serializer, handler, idempotency, dispatcher, appLogger)
appLogger.Info("worker started",
"backend", "sqs",
"email_provider", emailCfg.Provider,
"monitor_addr", queueCfg.WorkerMonitorAddr,
"outbox_enabled", queueCfg.OutboxEnabled,
)
if err := runner.Start(ctx); err != nil {
return err
}
appLogger.Info("worker stopped")
return nil
}
func buildEmailSender(
ctx context.Context,
emailCfg config.EmailDeliveryConfig,
sesCfg config.SESConfig,
resCfg config.ResilienceConfig,
appLogger *slog.Logger,
) (service.EmailSender, error) {
switch emailCfg.Provider {
case "", "ses":
sesBreaker := resilience.NewExecutor("ses", resCfg.SES, appLogger, resilience.ClassifySESError)
sender, err := service.NewSESSender(ctx, sesCfg)
if err != nil {
return nil, err
}
sender = sender.WithExecutor(sesBreaker)
sender = sender.WithMetricsComponent("email_worker")
if !sender.Configured() {
return nil, errors.New("ses is not configured")
}
return sender, nil
case "mock":
sender := service.NewMockEmailSender(emailCfg.MockDir)
if !sender.Configured() {
return nil, errors.New("mock email sender is not configured")
}
return sender, nil
default:
return nil, errors.New("unsupported email provider: " + emailCfg.Provider)
}
}

80
cmd/worker/main.go Normal file
View File

@@ -0,0 +1,80 @@
package main
import (
"errors"
"fmt"
"log"
"os"
"strings"
"wucher/internal/config"
)
const (
workerJobEnvKey = "WORKER_JOB"
workerJobEmail = "email"
workerJobFileProcessing = "file_processing"
workerJobAliasMail = "mail"
workerJobAliasEmailWorker = "email_worker"
workerJobAliasFile = "file"
workerJobAliasFileWorker = "file_worker"
)
var (
workerRunFn = run
workerFatalFn = log.Fatal
runMailWorkerFn func(config.AppConfig) error = runMailWorker
runFileWorkerFn func(config.AppConfig) error = runFileWorker
)
func configFilePath() string {
if v := os.Getenv("CONFIG_FILE"); v != "" {
return v
}
basePath := os.Getenv("CONFIG_BASE_PATH")
if basePath == "" {
basePath = "/tmp"
}
filename := os.Getenv("CONFIG_FILENAME")
if filename == "" {
filename = "config.generated.json"
}
return basePath + "/" + filename
}
func main() {
if err := workerRunFn(); err != nil {
workerFatalFn(err)
}
}
func run() error {
appCfg, err := config.LoadFromFile(configFilePath())
if err != nil {
return fmt.Errorf("load config: %w", err)
}
job := normalizeWorkerJob(os.Getenv(workerJobEnvKey))
switch job {
case workerJobEmail:
return runMailWorkerFn(appCfg)
case workerJobFileProcessing:
return runFileWorkerFn(appCfg)
default:
return errors.New("unsupported worker job: " + job)
}
}
func normalizeWorkerJob(raw string) string {
job := strings.ToLower(strings.TrimSpace(raw))
switch job {
case "", workerJobEmail, workerJobAliasMail, workerJobAliasEmailWorker:
return workerJobEmail
case workerJobFileProcessing, workerJobAliasFile, workerJobAliasFileWorker:
return workerJobFileProcessing
default:
return job
}
}

136
cmd/worker/main_test.go Normal file
View File

@@ -0,0 +1,136 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"wucher/internal/config"
"wucher/internal/shared/pkg/logger"
)
// writeTestConfig writes a minimal valid JSON config to a temp file and sets
// CONFIG_FILE so that run() can load it.
func writeTestConfig(t *testing.T) {
t.Helper()
f := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(f, []byte("{}"), 0o600); err != nil {
t.Fatalf("writeTestConfig: %v", err)
}
t.Setenv("CONFIG_FILE", f)
}
func TestMain_CallsRun(t *testing.T) {
origRun := workerRunFn
t.Cleanup(func() { workerRunFn = origRun })
runCalled := false
workerRunFn = func() error {
runCalled = true
return nil
}
main()
if !runCalled {
t.Fatalf("expected worker run function to be called")
}
}
func TestRun_DefaultsToMailWorker(t *testing.T) {
origMail := runMailWorkerFn
origFile := runFileWorkerFn
t.Cleanup(func() {
runMailWorkerFn = origMail
runFileWorkerFn = origFile
})
writeTestConfig(t)
t.Setenv(workerJobEnvKey, "")
mailCalled := false
fileCalled := false
runMailWorkerFn = func(config.AppConfig) error {
mailCalled = true
return nil
}
runFileWorkerFn = func(config.AppConfig) error {
fileCalled = true
return nil
}
if err := run(); err != nil {
t.Fatalf("run: %v", err)
}
if !mailCalled {
t.Fatalf("expected mail worker to be called")
}
if fileCalled {
t.Fatalf("expected file worker not to be called")
}
}
func TestRun_UsesFileWorkerWhenConfigured(t *testing.T) {
origMail := runMailWorkerFn
origFile := runFileWorkerFn
t.Cleanup(func() {
runMailWorkerFn = origMail
runFileWorkerFn = origFile
})
writeTestConfig(t)
t.Setenv(workerJobEnvKey, workerJobFileProcessing)
mailCalled := false
fileCalled := false
runMailWorkerFn = func(config.AppConfig) error {
mailCalled = true
return nil
}
runFileWorkerFn = func(config.AppConfig) error {
fileCalled = true
return nil
}
if err := run(); err != nil {
t.Fatalf("run: %v", err)
}
if !fileCalled {
t.Fatalf("expected file worker to be called")
}
if mailCalled {
t.Fatalf("expected mail worker not to be called")
}
}
func TestRun_ReturnsErrorOnUnsupportedWorkerJob(t *testing.T) {
writeTestConfig(t)
t.Setenv(workerJobEnvKey, "unknown_worker")
err := run()
if err == nil || !strings.Contains(err.Error(), "unsupported worker job") {
t.Fatalf("expected unsupported worker job error, got %v", err)
}
}
func TestBuildEmailSenderMock(t *testing.T) {
sender, err := buildEmailSender(context.Background(), config.EmailDeliveryConfig{
Provider: "mock",
MockDir: t.TempDir(),
}, config.SESConfig{}, config.ResilienceConfig{}, logger.NewDiscard())
if err != nil {
t.Fatalf("buildEmailSender: %v", err)
}
if sender == nil {
t.Fatalf("expected sender")
}
}
func TestBuildEmailSenderUnsupportedProvider(t *testing.T) {
_, err := buildEmailSender(context.Background(), config.EmailDeliveryConfig{
Provider: "unknown",
}, config.SESConfig{}, config.ResilienceConfig{}, logger.NewDiscard())
if err == nil || !strings.Contains(err.Error(), "unsupported email provider") {
t.Fatalf("expected unsupported provider error, got %v", err)
}
}