Upgrade Go version to 1.23.0 and update dependencies. Introduce new application structure with Clean Architecture principles, including message processing, NATS integration, and PostgreSQL repository. Add configuration management using Koanf and structured logging with Zap. Remove legacy GraphQL integration and related files. Implement dependency injection with Google Wire.

This commit is contained in:
windyboy
2025-11-14 08:42:26 +08:00
parent bafbbf470c
commit a574cfcf27
31 changed files with 1565 additions and 1374 deletions
+169
View File
@@ -0,0 +1,169 @@
package config
import (
"fmt"
"os"
"strings"
"time"
"github.com/knadh/koanf/v2"
"github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/file"
envprovider "github.com/knadh/koanf/providers/env"
)
// Config holds all application configuration
type Config struct {
NATS NATSConfig `koanf:"nats"`
Postgres PostgresConfig `koanf:"postgres"`
App AppConfig `koanf:"app"`
Log LogConfig `koanf:"log"`
Publisher PublisherConfig `koanf:"publisher"`
// Legacy fields for backward compatibility during migration
Subscription SubscriptionConfig `koanf:"subscription"`
Timeouts TimeoutsConfig `koanf:"timeouts"`
}
// NATSConfig holds NATS/JetStream configuration
type NATSConfig struct {
URL string `koanf:"url"`
Stream string `koanf:"stream"`
Consumer string `koanf:"consumer"`
// Legacy fields
Client string `koanf:"client"`
Cluster string `koanf:"cluster"`
}
// PostgresConfig holds PostgreSQL configuration
type PostgresConfig struct {
URL string `koanf:"url"`
MaxConns int32 `koanf:"max_conns"`
MinConns int32 `koanf:"min_conns"`
}
// AppConfig holds application-level configuration
type AppConfig struct {
BatchSize int `koanf:"batch_size"`
BatchTimeout time.Duration `koanf:"batch_timeout"`
}
// LogConfig holds logging configuration
type LogConfig struct {
Level string `koanf:"level"`
Format string `koanf:"format"` // json or console
}
// PublisherConfig holds publisher configuration
type PublisherConfig struct {
Topic string `koanf:"topic"`
}
// SubscriptionConfig holds subscription configuration (legacy)
type SubscriptionConfig struct {
Topic string `koanf:"topic"`
QueueGroup string `koanf:"queue_group"`
}
// TimeoutsConfig holds timeout configuration (legacy)
type TimeoutsConfig struct {
Server time.Duration `koanf:"server"`
ReconnectWait time.Duration `koanf:"reconnect_wait"`
Close time.Duration `koanf:"close"`
AckWait time.Duration `koanf:"ack_wait"`
}
// LoadConfig loads configuration from file and environment variables
func LoadConfig() (*Config, error) {
k := koanf.New(".")
// Determine environment
env := os.Getenv("GO_ENV")
if env == "" {
env = "dev"
}
// Load from TOML file
configFile := fmt.Sprintf("configs/config.%s.toml", env)
if err := k.Load(file.Provider(configFile), toml.Parser()); err != nil {
return nil, fmt.Errorf("error loading config file '%s': %w", configFile, err)
}
// Load from environment variables with CAATSM_ prefix
envProvider := envprovider.Provider("CAATSM_", ".", func(s string) string {
// Convert CAATSM_NATS_URL to nats.url
s = strings.TrimPrefix(s, "CAATSM_")
return strings.ToLower(strings.ReplaceAll(s, "_", "."))
})
if err := k.Load(envProvider, nil); err != nil {
// Environment variables are optional, so we don't fail if they're not present
// This allows the config to work with just the file
}
// Unmarshal into Config struct
var cfg Config
if err := k.Unmarshal("", &cfg); err != nil {
return nil, fmt.Errorf("error unmarshaling config: %w", err)
}
// Set defaults
if cfg.App.BatchSize == 0 {
cfg.App.BatchSize = 50
}
if cfg.App.BatchTimeout == 0 {
cfg.App.BatchTimeout = 2 * time.Second
}
if cfg.Postgres.MaxConns == 0 {
cfg.Postgres.MaxConns = 10
}
if cfg.Postgres.MinConns == 0 {
cfg.Postgres.MinConns = 2
}
if cfg.Log.Level == "" {
cfg.Log.Level = "info"
}
if cfg.Log.Format == "" {
cfg.Log.Format = "json"
}
if cfg.NATS.Stream == "" {
cfg.NATS.Stream = "TELEGRAM"
}
if cfg.NATS.Consumer == "" {
cfg.NATS.Consumer = "telegram-consumer"
}
// Validate configuration
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("config validation failed: %w", err)
}
return &cfg, nil
}
// Validate validates the configuration
func (c *Config) Validate() error {
if c.NATS.URL == "" {
return fmt.Errorf("nats.url is required")
}
if c.Subscription.Topic == "" && c.NATS.Stream == "" {
return fmt.Errorf("subscription.topic or nats.stream is required")
}
if c.Publisher.Topic == "" {
return fmt.Errorf("publisher.topic is required")
}
if c.Postgres.URL == "" {
return fmt.Errorf("postgres.url is required")
}
if c.App.BatchSize <= 0 {
return fmt.Errorf("app.batch_size must be greater than 0")
}
if c.App.BatchTimeout <= 0 {
return fmt.Errorf("app.batch_timeout must be greater than 0")
}
return nil
}
// ProvideConfig is a Wire provider function
func ProvideConfig() (*Config, error) {
return LoadConfig()
}
+45
View File
@@ -0,0 +1,45 @@
package log
import (
"caatsm/internal/infra/config"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// ProvideLogger creates a zap logger based on configuration
func ProvideLogger(cfg *config.Config) (*zap.Logger, error) {
var zapConfig zap.Config
if cfg.Log.Format == "console" {
zapConfig = zap.NewDevelopmentConfig()
} else {
zapConfig = zap.NewProductionConfig()
}
// Set log level
switch cfg.Log.Level {
case "debug":
zapConfig.Level = zap.NewAtomicLevelAt(zapcore.DebugLevel)
case "info":
zapConfig.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
case "warn":
zapConfig.Level = zap.NewAtomicLevelAt(zapcore.WarnLevel)
case "error":
zapConfig.Level = zap.NewAtomicLevelAt(zapcore.ErrorLevel)
default:
zapConfig.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
}
// Build logger
logger, err := zapConfig.Build(zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
if err != nil {
return nil, err
}
// Note: We still replace global logger for backward compatibility with parsers package
// This will be removed once parsers are fully migrated to use dependency injection
zap.ReplaceGlobals(logger)
return logger, nil
}
+184
View File
@@ -0,0 +1,184 @@
package nats
import (
"caatsm/internal/app"
"caatsm/internal/infra/config"
"context"
"errors"
"fmt"
"go.uber.org/zap"
"github.com/nats-io/nats.go"
"time"
)
// Consumer handles NATS JetStream message consumption
type Consumer struct {
js nats.JetStreamContext
processor *app.MessageProcessor
cfg *config.Config
logger *zap.Logger
subject string
consumerName string
}
// ProvideConsumer creates a NATS consumer
func ProvideConsumer(
js nats.JetStreamContext,
processor *app.MessageProcessor,
cfg *config.Config,
logger *zap.Logger,
) (*Consumer, error) {
subject := cfg.Subscription.Topic
if subject == "" {
subject = "telegram.>"
}
consumerName := cfg.NATS.Consumer
if consumerName == "" {
consumerName = "telegram-consumer"
}
consumer := &Consumer{
js: js,
processor: processor,
cfg: cfg,
logger: logger,
subject: subject,
consumerName: consumerName,
}
// Create consumer if it doesn't exist
if err := consumer.ensureConsumer(); err != nil {
return nil, fmt.Errorf("failed to ensure consumer: %w", err)
}
return consumer, nil
}
// ensureConsumer creates the consumer if it doesn't exist
func (c *Consumer) ensureConsumer() error {
streamName := c.cfg.NATS.Stream
if streamName == "" {
streamName = "TELEGRAM"
}
consumerConfig := &nats.ConsumerConfig{
Durable: c.consumerName,
DeliverPolicy: nats.DeliverAllPolicy,
AckPolicy: nats.AckExplicitPolicy,
AckWait: c.cfg.Timeouts.AckWait,
MaxDeliver: 5, // Maximum number of delivery attempts
FilterSubject: c.subject,
}
_, err := c.js.AddConsumer(streamName, consumerConfig)
if err != nil && err != nats.ErrConsumerNameAlreadyInUse {
return fmt.Errorf("failed to create consumer: %w", err)
}
if err == nil {
c.logger.Info("Created JetStream consumer",
zap.String("consumer", c.consumerName),
zap.String("stream", streamName),
zap.String("subject", c.subject),
)
}
return nil
}
// Start starts consuming messages
func (c *Consumer) Start(ctx context.Context) error {
streamName := c.cfg.NATS.Stream
if streamName == "" {
streamName = "TELEGRAM"
}
// Create pull subscription
sub, err := c.js.PullSubscribe(c.subject, c.consumerName, nats.Bind(streamName, c.consumerName))
if err != nil {
return fmt.Errorf("failed to create pull subscription: %w", err)
}
defer sub.Unsubscribe()
c.logger.Info("Started consuming messages",
zap.String("subject", c.subject),
zap.String("consumer", c.consumerName),
zap.String("stream", streamName),
)
batchSize := c.cfg.App.BatchSize
if batchSize == 0 {
batchSize = 50
}
batchTimeout := c.cfg.App.BatchTimeout
if batchTimeout == 0 {
batchTimeout = 2 * time.Second
}
for {
select {
case <-ctx.Done():
c.logger.Info("Stopping consumer", zap.Error(ctx.Err()))
return ctx.Err()
default:
}
// Fetch messages in batch
msgs, err := sub.Fetch(batchSize, nats.MaxWait(batchTimeout))
if err != nil {
if errors.Is(err, nats.ErrTimeout) {
// Timeout is expected when no messages are available
continue
}
c.logger.Error("Failed to fetch messages", zap.Error(err))
time.Sleep(time.Second)
continue
}
// Process each message
for _, msg := range msgs {
if err := c.processMessage(ctx, msg); err != nil {
c.logger.Error("Failed to process message",
zap.String("subject", msg.Subject),
zap.Error(err),
)
// NAK the message to retry
if nakErr := msg.Nak(); nakErr != nil {
c.logger.Error("Failed to NAK message", zap.Error(nakErr))
}
} else {
// ACK the message
if ackErr := msg.Ack(); ackErr != nil {
c.logger.Error("Failed to ACK message", zap.Error(ackErr))
}
}
}
}
}
// processMessage processes a single message
func (c *Consumer) processMessage(ctx context.Context, msg *nats.Msg) error {
msgID := msg.Header.Get("Nats-Msg-Id")
if msgID == "" {
// Use reply subject or generate a simple ID
if msg.Reply != "" {
msgID = msg.Reply
} else {
msgID = fmt.Sprintf("msg-%d", time.Now().UnixNano())
}
}
c.logger.Debug("Processing message",
zap.String("subject", msg.Subject),
zap.String("msg_id", msgID),
zap.Int("data_size", len(msg.Data)),
)
// Call processor
if err := c.processor.Handle(ctx, msg.Data, msgID); err != nil {
return fmt.Errorf("processor error: %w", err)
}
return nil
}
+66
View File
@@ -0,0 +1,66 @@
package nats
import (
"caatsm/internal/infra/config"
"fmt"
"go.uber.org/zap"
"github.com/nats-io/nats.go"
"time"
)
// ProvideJetStream creates a NATS JetStream connection
func ProvideJetStream(cfg *config.Config, logger *zap.Logger) (nats.JetStreamContext, error) {
// Connect to NATS
nc, err := nats.Connect(
cfg.NATS.URL,
nats.RetryOnFailedConnect(true),
nats.Timeout(cfg.Timeouts.Server),
nats.ReconnectWait(cfg.Timeouts.ReconnectWait),
nats.DisconnectErrHandler(func(nc *nats.Conn, err error) {
if err != nil {
logger.Warn("NATS disconnected", zap.Error(err))
}
}),
nats.ReconnectHandler(func(nc *nats.Conn) {
logger.Info("NATS reconnected", zap.String("url", nc.ConnectedUrl()))
}),
)
if err != nil {
return nil, fmt.Errorf("failed to connect to NATS: %w", err)
}
// Get JetStream context
js, err := nc.JetStream()
if err != nil {
nc.Close()
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
}
// Create stream if it doesn't exist
streamName := cfg.NATS.Stream
subject := cfg.Subscription.Topic
if subject == "" {
subject = "telegram.>"
}
streamConfig := &nats.StreamConfig{
Name: streamName,
Subjects: []string{subject},
Retention: nats.LimitsPolicy,
MaxAge: 24 * time.Hour,
Storage: nats.FileStorage,
Replicas: 1,
}
_, err = js.AddStream(streamConfig)
if err != nil && err != nats.ErrStreamNameAlreadyInUse {
nc.Close()
return nil, fmt.Errorf("failed to create stream: %w", err)
}
if err == nil {
logger.Info("Created JetStream", zap.String("stream", streamName), zap.String("subject", subject))
}
return js, nil
}
+57
View File
@@ -0,0 +1,57 @@
package nats
import (
"caatsm/internal/adapter"
"caatsm/internal/infra/config"
"encoding/json"
"fmt"
"go.uber.org/zap"
"github.com/nats-io/nats.go"
)
// Publisher publishes messages to NATS JetStream
type Publisher struct {
js nats.JetStreamContext
cfg *config.Config
logger *zap.Logger
}
// ProvidePublisher creates a NATS publisher
func ProvidePublisher(
js nats.JetStreamContext,
cfg *config.Config,
logger *zap.Logger,
) (adapter.Publisher, error) {
return &Publisher{
js: js,
cfg: cfg,
logger: logger,
}, nil
}
// Publish publishes a message
func (p *Publisher) Publish(message interface{}) error {
topic := p.cfg.Publisher.Topic
if topic == "" {
return fmt.Errorf("publisher topic is not configured")
}
// Marshal message to JSON
messageBytes, err := json.Marshal(message)
if err != nil {
return fmt.Errorf("failed to marshal message: %w", err)
}
// Publish to JetStream
_, err = p.js.Publish(topic, messageBytes)
if err != nil {
return fmt.Errorf("failed to publish message: %w", err)
}
p.logger.Debug("Published message",
zap.String("topic", topic),
zap.Int("size", len(messageBytes)),
)
return nil
}
+38
View File
@@ -0,0 +1,38 @@
package postgres
import (
"caatsm/internal/infra/config"
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// ProvideDB creates a PostgreSQL connection pool
func ProvideDB(cfg *config.Config) (*pgxpool.Pool, error) {
ctx := context.Background()
poolConfig, err := pgxpool.ParseConfig(cfg.Postgres.URL)
if err != nil {
return nil, fmt.Errorf("failed to parse postgres URL: %w", err)
}
poolConfig.MaxConns = int32(cfg.Postgres.MaxConns)
poolConfig.MinConns = int32(cfg.Postgres.MinConns)
poolConfig.MaxConnLifetime = time.Hour
poolConfig.MaxConnIdleTime = time.Minute * 30
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, fmt.Errorf("failed to create connection pool: %w", err)
}
// Test connection
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return pool, nil
}
+101
View File
@@ -0,0 +1,101 @@
package postgres
import (
"caatsm/internal/adapter"
"caatsm/internal/adapter/mapper"
"caatsm/internal/domain"
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"go.uber.org/zap"
)
// Repository implements the adapter.Repository interface using PostgreSQL
type Repository struct {
pool *pgxpool.Pool
mapper *mapper.TelegramMapper
logger *zap.Logger
}
// ProvideRepository creates a PostgreSQL repository
func ProvideRepository(pool *pgxpool.Pool, logger *zap.Logger) (adapter.Repository, error) {
return &Repository{
pool: pool,
mapper: mapper.NewTelegramMapper(),
logger: logger,
}, nil
}
// InsertOne inserts a single telegram message
func (r *Repository) InsertOne(ctx context.Context, msg *domain.ParsedMessage) error {
row, err := r.mapper.ToDBRow(msg)
if err != nil {
return fmt.Errorf("failed to map message to DB row: %w", err)
}
query := `
INSERT INTO aviation.telegrams (
uuid, message_id, date_time, priority_indicator, primary_address,
secondary_addresses, originator, originator_date_time, category,
content, body_data, received_at, parsed_at, dispatched_at, need_dispatch
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
)
ON CONFLICT (uuid) DO NOTHING
`
_, err = r.pool.Exec(ctx, query,
row[0], row[1], row[2], row[3], row[4], row[5], row[6], row[7], row[8],
row[9], row[10], row[11], row[12], row[13], row[14],
)
if err != nil {
return fmt.Errorf("failed to insert message: %w", err)
}
r.logger.Debug("Inserted message",
zap.String("uuid", msg.Uuid),
zap.String("message_id", msg.MessageID),
)
return nil
}
// InsertBatch inserts multiple telegram messages in a batch using CopyFrom
func (r *Repository) InsertBatch(ctx context.Context, msgs []*domain.ParsedMessage) error {
if len(msgs) == 0 {
return nil
}
// Convert messages to rows
rows := make([][]interface{}, len(msgs))
for i, msg := range msgs {
row, err := r.mapper.ToDBRow(msg)
if err != nil {
return fmt.Errorf("failed to map message %d to DB row: %w", i, err)
}
rows[i] = row
}
// Use CopyFrom for efficient batch insert
copyCount, err := r.pool.CopyFrom(
ctx,
pgx.Identifier{"aviation", "telegrams"},
[]string{
"uuid", "message_id", "date_time", "priority_indicator", "primary_address",
"secondary_addresses", "originator", "originator_date_time", "category",
"content", "body_data", "received_at", "parsed_at", "dispatched_at", "need_dispatch",
},
pgx.CopyFromRows(rows),
)
if err != nil {
return fmt.Errorf("failed to batch insert messages: %w", err)
}
r.logger.Info("Batch inserted messages",
zap.Int("count", int(copyCount)),
zap.Int("attempted", len(msgs)),
)
return nil
}