# Notification Service Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a pure-Go notification service — webhook in, parse (JSON/Regex/Text), match rules, evaluate conditions, render Go templates, dispatch to DingTalk/WeCom/Email/Bark. **Architecture:** Gin HTTP → auth+ratelimit middleware → handler → parser → engine(matcher→condition→renderer→router) → adapter(ChannelSender interface) → external services. MySQL for config, Redis for cache+ratelimit. **Tech Stack:** Go 1.22+, gin, sqlx, go-redis/v9, text/template, viper, slog, golang-migrate, MySQL 8.0, Redis 7 ## Global Constraints - Go 1.22+, module path: `aiaa-notification-service` - MySQL 8.0 InnoDB, Redis 7 - Config via `config/config.yaml` with `${ENV_VAR}` interpolation - Tests: `testing` stdlib + `httptest` for handlers, `testify/assert` for assertions - Docker multi-stage: `golang:1.22-alpine` → `alpine:3.20` ## File Structure ``` notification-service/ ├── cmd/server/main.go ├── config/config.yaml ├── migrations/ │ ├── 001_init.up.sql │ └── 001_init.down.sql ├── internal/ │ ├── config/config.go │ ├── model/model.go │ ├── store/ │ │ ├── mysql.go │ │ ├── source.go │ │ ├── template.go │ │ ├── channel.go │ │ ├── rule.go │ │ └── message_log.go │ ├── cache/redis.go │ ├── parser/ │ │ ├── parser.go ← Parser interface + factory │ │ ├── json.go │ │ ├── regex.go │ │ └── text.go │ ├── condition/ │ │ └── evaluator.go ← condition array evaluator │ ├── adapter/ │ │ ├── adapter.go │ │ ├── dingtalk.go │ │ ├── wecom.go │ │ ├── email.go │ │ └── bark.go │ ├── engine/ │ │ ├── matcher.go │ │ ├── renderer.go │ │ └── router.go │ ├── handler/ │ │ ├── middleware.go │ │ ├── notify.go │ │ ├── source.go │ │ ├── template.go │ │ ├── channel.go │ │ ├── rule.go │ │ └── message_log.go │ └── retry/retry.go ├── Dockerfile ├── docker-compose.yml ├── Makefile └── go.mod ``` --- ### Task 1: Project Scaffold & Configuration Loading **Files:** - Create: `go.mod` - Create: `cmd/server/main.go` (skeleton) - Create: `config/config.yaml` - Create: `internal/config/config.go` - Create: `Makefile` **Interfaces:** - Produces: `config.Config` struct, `config.Load(path string) (*Config, error)` - [ ] **Step 1: Initialize Go module** ```bash cd /Users/ryan/Documents/code/go/aiaa-notification-service go mod init aiaa-notification-service ``` - [ ] **Step 2: Create config file** File: `config/config.yaml` ```yaml server: port: 8080 admin_key: "admin-sk-change-me" database: host: "127.0.0.1" port: 3306 user: "notify" password: "${DB_PASSWORD:-notify}" database: "notification" redis: host: "127.0.0.1" port: 6379 password: "" db: 0 smtp: host: "smtp.example.com" port: 587 user: "notify@example.com" password: "${SMTP_PASSWORD:-}" from: "Notification Service " rate_limit: default: 100 ``` - [ ] **Step 3: Write Config loader** File: `internal/config/config.go` ```go package config import ( "fmt" "os" "strings" "github.com/spf13/viper" ) type Config struct { Server ServerConfig `mapstructure:"server"` Database DatabaseConfig `mapstructure:"database"` Redis RedisConfig `mapstructure:"redis"` SMTP SMTPConfig `mapstructure:"smtp"` RateLimit RateLimitConfig `mapstructure:"rate_limit"` } type ServerConfig struct { Port int `mapstructure:"port"` AdminKey string `mapstructure:"admin_key"` } type DatabaseConfig struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` User string `mapstructure:"user"` Password string `mapstructure:"password"` Database string `mapstructure:"database"` } func (d DatabaseConfig) DSN() string { return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local", d.User, d.Password, d.Host, d.Port, d.Database) } type RedisConfig struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` Password string `mapstructure:"password"` DB int `mapstructure:"db"` } func (r RedisConfig) Addr() string { return fmt.Sprintf("%s:%d", r.Host, r.Port) } type SMTPConfig struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` User string `mapstructure:"user"` Password string `mapstructure:"password"` From string `mapstructure:"from"` } type RateLimitConfig struct { Default int `mapstructure:"default"` } func Load(path string) (*Config, error) { v := viper.New() v.SetConfigFile(path) v.SetEnvPrefix("NOTIFY") v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.AutomaticEnv() if err := v.ReadInConfig(); err != nil { return nil, fmt.Errorf("read config: %w", err) } // Expand ${ENV_VAR} placeholders in config values for _, key := range v.AllKeys() { val := v.GetString(key) if strings.Contains(val, "${") { expanded := expandEnv(val) v.Set(key, expanded) } } var cfg Config if err := v.Unmarshal(&cfg); err != nil { return nil, fmt.Errorf("unmarshal config: %w", err) } return &cfg, nil } func expandEnv(s string) string { return os.Expand(s, func(key string) string { // support ${VAR:-default} if i := strings.Index(key, ":-"); i >= 0 { name := key[:i] def := key[i+2:] if v, ok := os.LookupEnv(name); ok { return v } return def } return os.Getenv(key) }) } ``` - [ ] **Step 4: Create skeleton main.go** File: `cmd/server/main.go` ```go package main import ( "log/slog" "os" "aiaa-notification-service/internal/config" ) func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) slog.SetDefault(logger) cfg, err := config.Load("config/config.yaml") if err != nil { slog.Error("failed to load config", "error", err) os.Exit(1) } slog.Info("config loaded", "port", cfg.Server.Port) } ``` - [ ] **Step 5: Create Makefile** File: `Makefile` ```makefile .PHONY: build run test docker-build docker-up docker-down migrate-up migrate-down build: go build -o bin/server ./cmd/server run: go run ./cmd/server test: go test ./internal/... -v -count=1 docker-build: docker build -t notification-service . docker-up: docker-compose up -d docker-down: docker-compose down migrate-up: migrate -path migrations -database "mysql://notify:notify@tcp(127.0.0.1:3306)/notification" up migrate-down: migrate -path migrations -database "mysql://notify:notify@tcp(127.0.0.1:3306)/notification" down ``` - [ ] **Step 6: Install dependencies and verify build** ```bash go mod tidy go build ./... ``` Expected: build succeeds with no errors. - [ ] **Step 7: Commit** ```bash git add -A && git commit -m "feat: project scaffold, config loading, skeleton main" ``` --- ### Task 2: Models & Database Migration **Files:** - Create: `internal/model/model.go` - Create: `migrations/001_init.up.sql` - Create: `migrations/001_init.down.sql` **Interfaces:** - Produces: `model.Source`, `model.Template`, `model.Channel`, `model.Rule`, `model.RuleChannel`, `model.MessageLog`, `model.Condition` - [ ] **Step 1: Write Go models** File: `internal/model/model.go` ```go package model import ( "database/sql" "encoding/json" "time" ) type Source struct { ID int `db:"id" json:"id"` Name string `db:"name" json:"name"` APIKey string `db:"api_key" json:"api_key,omitempty"` ParseMode string `db:"parse_mode" json:"parse_mode"` ParsePattern string `db:"parse_pattern" json:"parse_pattern,omitempty"` Status int `db:"status" json:"status"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } type Template struct { ID int `db:"id" json:"id"` Name string `db:"name" json:"name"` Content string `db:"content" json:"content"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } type Channel struct { ID int `db:"id" json:"id"` Name string `db:"name" json:"name"` Type string `db:"type" json:"type"` Config *json.RawMessage `db:"config" json:"config"` Status int `db:"status" json:"status"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } type Rule struct { ID int `db:"id" json:"id"` SourceID int `db:"source_id" json:"source_id"` Event string `db:"event" json:"event"` TemplateID int `db:"template_id" json:"template_id"` Conditions *json.RawMessage `db:"conditions" json:"conditions,omitempty"` Enabled int `db:"enabled" json:"enabled"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } type Condition struct { Field string `json:"field"` Op string `json:"op"` Value string `json:"value,omitempty"` } type RuleChannel struct { ID int `db:"id" json:"id"` RuleID int `db:"rule_id" json:"rule_id"` ChannelID int `db:"channel_id" json:"channel_id"` Enabled int `db:"enabled" json:"enabled"` } type MessageLog struct { ID int64 `db:"id" json:"id"` RuleID int `db:"rule_id" json:"rule_id"` ChannelID int `db:"channel_id" json:"channel_id"` Source string `db:"source" json:"source"` Event string `db:"event" json:"event"` Payload json.RawMessage `db:"payload" json:"payload"` Content string `db:"content" json:"content"` Status string `db:"status" json:"status"` RetryCount int `db:"retry_count" json:"retry_count"` Response sql.NullString `db:"response" json:"response,omitempty"` ErrorMsg sql.NullString `db:"error_msg" json:"error_msg,omitempty"` CreatedAt time.Time `db:"created_at" json:"created_at"` } ``` - [ ] **Step 2: Write migration SQL** File: `migrations/001_init.up.sql` ```sql CREATE TABLE source ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, api_key VARCHAR(128) NOT NULL, parse_mode VARCHAR(16) NOT NULL DEFAULT 'json', parse_pattern VARCHAR(512) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_name (name), UNIQUE KEY uk_api_key (api_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE template ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, content TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE channel ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(32) NOT NULL, type VARCHAR(32) NOT NULL, config JSON NOT NULL, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE rule ( id INT AUTO_INCREMENT PRIMARY KEY, source_id INT NOT NULL, event VARCHAR(64) NOT NULL, template_id INT NOT NULL, conditions JSON DEFAULT NULL, enabled TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_source_event (source_id, event), FOREIGN KEY (source_id) REFERENCES source(id), FOREIGN KEY (template_id) REFERENCES template(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE rule_channel ( id INT AUTO_INCREMENT PRIMARY KEY, rule_id INT NOT NULL, channel_id INT NOT NULL, enabled TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_rule_channel (rule_id, channel_id), FOREIGN KEY (rule_id) REFERENCES rule(id) ON DELETE CASCADE, FOREIGN KEY (channel_id) REFERENCES channel(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE message_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, rule_id INT NOT NULL, channel_id INT NOT NULL, source VARCHAR(64) NOT NULL, event VARCHAR(64) NOT NULL, payload JSON NOT NULL, content TEXT NOT NULL, status ENUM('pending','success','failed','retrying') NOT NULL DEFAULT 'pending', retry_count INT NOT NULL DEFAULT 0, response TEXT DEFAULT NULL, error_msg TEXT DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_source_event_time (source, event, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` File: `migrations/001_init.down.sql` ```sql DROP TABLE IF EXISTS message_log; DROP TABLE IF EXISTS rule_channel; DROP TABLE IF EXISTS rule; DROP TABLE IF EXISTS channel; DROP TABLE IF EXISTS template; DROP TABLE IF EXISTS source; ``` - [ ] **Step 3: Verify models compile** ```bash go build ./internal/model/... ``` Expected: no errors. - [ ] **Step 4: Commit** ```bash git add -A && git commit -m "feat: data models and database migration" ``` --- ### Task 3: MySQL Store — Connection & Source CRUD **Files:** - Create: `internal/store/mysql.go` - Create: `internal/store/source.go` **Interfaces:** - Consumes: `config.Config`, `model.Source` - Produces: `store.NewStore(cfg *config.DatabaseConfig) (*Store, error)`, `Store.CreateSource`, `Store.GetSource`, `Store.GetSourceByAPIKey`, `Store.GetSourceByName`, `Store.ListSources`, `Store.UpdateSource`, `Store.DeleteSource` - [ ] **Step 1: Write store connection** File: `internal/store/mysql.go` ```go package store import ( "fmt" "aiaa-notification-service/internal/config" "github.com/jmoiron/sqlx" _ "github.com/go-sql-driver/mysql" ) type Store struct { DB *sqlx.DB } func NewStore(cfg config.DatabaseConfig) (*Store, error) { db, err := sqlx.Connect("mysql", cfg.DSN()) if err != nil { return nil, fmt.Errorf("connect mysql: %w", err) } db.SetMaxOpenConns(25) db.SetMaxIdleConns(5) return &Store{DB: db}, nil } func (s *Store) Close() error { return s.DB.Close() } ``` - [ ] **Step 2: Write Source store methods** File: `internal/store/source.go` ```go package store import ( "context" "crypto/rand" "encoding/hex" "fmt" "aiaa-notification-service/internal/model" ) func generateAPIKey() string { b := make([]byte, 32) rand.Read(b) return "sk-" + hex.EncodeToString(b) } func (s *Store) CreateSource(ctx context.Context, src *model.Source) error { src.APIKey = generateAPIKey() query := `INSERT INTO source (name, api_key, parse_mode, parse_pattern, status) VALUES (?, ?, ?, ?, ?)` result, err := s.DB.ExecContext(ctx, query, src.Name, src.APIKey, src.ParseMode, src.ParsePattern, src.Status) if err != nil { return fmt.Errorf("create source: %w", err) } id, _ := result.LastInsertId() src.ID = int(id) return nil } func (s *Store) GetSource(ctx context.Context, id int) (*model.Source, error) { var src model.Source err := s.DB.GetContext(ctx, &src, `SELECT * FROM source WHERE id = ?`, id) if err != nil { return nil, fmt.Errorf("get source %d: %w", id, err) } return &src, nil } func (s *Store) GetSourceByAPIKey(ctx context.Context, apiKey string) (*model.Source, error) { var src model.Source err := s.DB.GetContext(ctx, &src, `SELECT * FROM source WHERE api_key = ? AND status = 1`, apiKey) if err != nil { return nil, fmt.Errorf("get source by api_key: %w", err) } return &src, nil } func (s *Store) GetSourceByName(ctx context.Context, name string) (*model.Source, error) { var src model.Source err := s.DB.GetContext(ctx, &src, `SELECT * FROM source WHERE name = ?`, name) if err != nil { return nil, fmt.Errorf("get source by name %s: %w", name, err) } return &src, nil } func (s *Store) ListSources(ctx context.Context) ([]model.Source, error) { var sources []model.Source err := s.DB.SelectContext(ctx, &sources, `SELECT * FROM source ORDER BY id`) if err != nil { return nil, fmt.Errorf("list sources: %w", err) } return sources, nil } func (s *Store) UpdateSource(ctx context.Context, id int, src *model.Source) error { query := `UPDATE source SET name=?, parse_mode=?, parse_pattern=?, status=? WHERE id=?` _, err := s.DB.ExecContext(ctx, query, src.Name, src.ParseMode, src.ParsePattern, src.Status, id) if err != nil { return fmt.Errorf("update source %d: %w", id, err) } return nil } func (s *Store) DeleteSource(ctx context.Context, id int) error { _, err := s.DB.ExecContext(ctx, `DELETE FROM source WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete source %d: %w", id, err) } return nil } ``` - [ ] **Step 3: Install dependencies** ```bash go get github.com/jmoiron/sqlx github.com/go-sql-driver/mysql go mod tidy ``` - [ ] **Step 4: Verify compilation** ```bash go build ./internal/store/... ``` Expected: no errors. - [ ] **Step 5: Commit** ```bash git add -A && git commit -m "feat: store connection and source CRUD" ``` --- ### Task 4: MySQL Store — Template & Channel CRUD **Files:** - Create: `internal/store/template.go` - Create: `internal/store/channel.go` **Interfaces:** - Produces: `Store.CreateTemplate`, `Store.GetTemplate`, `Store.GetTemplateByName`, `Store.ListTemplates`, `Store.UpdateTemplate`, `Store.DeleteTemplate` - Produces: `Store.CreateChannel`, `Store.GetChannel`, `Store.GetChannelByName`, `Store.ListChannels`, `Store.UpdateChannel`, `Store.DeleteChannel` - [ ] **Step 1: Write Template store** File: `internal/store/template.go` ```go package store import ( "context" "fmt" "aiaa-notification-service/internal/model" ) func (s *Store) CreateTemplate(ctx context.Context, t *model.Template) error { query := `INSERT INTO template (name, content) VALUES (?, ?)` result, err := s.DB.ExecContext(ctx, query, t.Name, t.Content) if err != nil { return fmt.Errorf("create template: %w", err) } id, _ := result.LastInsertId() t.ID = int(id) return nil } func (s *Store) GetTemplate(ctx context.Context, id int) (*model.Template, error) { var t model.Template err := s.DB.GetContext(ctx, &t, `SELECT * FROM template WHERE id = ?`, id) if err != nil { return nil, fmt.Errorf("get template %d: %w", id, err) } return &t, nil } func (s *Store) GetTemplateByName(ctx context.Context, name string) (*model.Template, error) { var t model.Template err := s.DB.GetContext(ctx, &t, `SELECT * FROM template WHERE name = ?`, name) if err != nil { return nil, fmt.Errorf("get template by name %s: %w", name, err) } return &t, nil } func (s *Store) ListTemplates(ctx context.Context) ([]model.Template, error) { var templates []model.Template err := s.DB.SelectContext(ctx, &templates, `SELECT * FROM template ORDER BY id`) if err != nil { return nil, fmt.Errorf("list templates: %w", err) } return templates, nil } func (s *Store) UpdateTemplate(ctx context.Context, id int, t *model.Template) error { query := `UPDATE template SET name=?, content=? WHERE id=?` _, err := s.DB.ExecContext(ctx, query, t.Name, t.Content, id) if err != nil { return fmt.Errorf("update template %d: %w", id, err) } return nil } func (s *Store) DeleteTemplate(ctx context.Context, id int) error { _, err := s.DB.ExecContext(ctx, `DELETE FROM template WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete template %d: %w", id, err) } return nil } ``` - [ ] **Step 2: Write Channel store** File: `internal/store/channel.go` ```go package store import ( "context" "encoding/json" "fmt" "aiaa-notification-service/internal/model" ) func (s *Store) CreateChannel(ctx context.Context, ch *model.Channel) error { configJSON, err := json.Marshal(ch.Config) if err != nil { return fmt.Errorf("marshal channel config: %w", err) } query := `INSERT INTO channel (name, type, config, status) VALUES (?, ?, ?, ?)` result, err := s.DB.ExecContext(ctx, query, ch.Name, ch.Type, configJSON, ch.Status) if err != nil { return fmt.Errorf("create channel: %w", err) } id, _ := result.LastInsertId() ch.ID = int(id) return nil } func (s *Store) GetChannel(ctx context.Context, id int) (*model.Channel, error) { var ch model.Channel var configBytes []byte row := s.DB.QueryRowContext(ctx, `SELECT id, name, type, config, status, created_at, updated_at FROM channel WHERE id = ?`, id) if err := row.Scan(&ch.ID, &ch.Name, &ch.Type, &configBytes, &ch.Status, &ch.CreatedAt, &ch.UpdatedAt); err != nil { return nil, fmt.Errorf("get channel %d: %w", id, err) } raw := json.RawMessage(configBytes) ch.Config = &raw return &ch, nil } func (s *Store) GetChannelByName(ctx context.Context, name string) (*model.Channel, error) { var ch model.Channel var configBytes []byte row := s.DB.QueryRowContext(ctx, `SELECT id, name, type, config, status, created_at, updated_at FROM channel WHERE name = ?`, name) if err := row.Scan(&ch.ID, &ch.Name, &ch.Type, &configBytes, &ch.Status, &ch.CreatedAt, &ch.UpdatedAt); err != nil { return nil, fmt.Errorf("get channel by name %s: %w", name, err) } raw := json.RawMessage(configBytes) ch.Config = &raw return &ch, nil } func (s *Store) ListChannels(ctx context.Context) ([]model.Channel, error) { rows, err := s.DB.QueryContext(ctx, `SELECT id, name, type, config, status, created_at, updated_at FROM channel ORDER BY id`) if err != nil { return nil, fmt.Errorf("list channels: %w", err) } defer rows.Close() var channels []model.Channel for rows.Next() { var ch model.Channel var configBytes []byte if err := rows.Scan(&ch.ID, &ch.Name, &ch.Type, &configBytes, &ch.Status, &ch.CreatedAt, &ch.UpdatedAt); err != nil { return nil, fmt.Errorf("scan channel: %w", err) } raw := json.RawMessage(configBytes) ch.Config = &raw channels = append(channels, ch) } return channels, rows.Err() } func (s *Store) UpdateChannel(ctx context.Context, id int, ch *model.Channel) error { configJSON, err := json.Marshal(ch.Config) if err != nil { return fmt.Errorf("marshal channel config: %w", err) } query := `UPDATE channel SET name=?, type=?, config=?, status=? WHERE id=?` _, err = s.DB.ExecContext(ctx, query, ch.Name, ch.Type, configJSON, ch.Status, id) if err != nil { return fmt.Errorf("update channel %d: %w", id, err) } return nil } func (s *Store) DeleteChannel(ctx context.Context, id int) error { _, err := s.DB.ExecContext(ctx, `DELETE FROM channel WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete channel %d: %w", id, err) } return nil } ``` - [ ] **Step 3: Verify compilation** ```bash go build ./internal/store/... ``` Expected: no errors. - [ ] **Step 4: Commit** ```bash git add -A && git commit -m "feat: template and channel store CRUD" ``` --- ### Task 5: MySQL Store — Rule & MessageLog **Files:** - Create: `internal/store/rule.go` - Create: `internal/store/message_log.go` **Interfaces:** - Produces: `Store.CreateRule`, `Store.GetRule`, `Store.GetRuleBySourceEvent`, `Store.ListRules`, `Store.UpdateRule`, `Store.DeleteRule`, `Store.AddRuleChannel`, `Store.RemoveRuleChannel`, `Store.GetRuleChannels`, `Store.UpdateRuleChannelEnabled` - Produces: `Store.CreateMessageLog`, `Store.UpdateMessageLog`, `Store.ListMessageLogs`, `MessageLogFilter` - [ ] **Step 1: Write Rule store (includes RuleChannel ops)** File: `internal/store/rule.go` ```go package store import ( "context" "encoding/json" "fmt" "aiaa-notification-service/internal/model" ) func (s *Store) CreateRule(ctx context.Context, r *model.Rule, channelIDs []int) error { tx, err := s.DB.BeginTxx(ctx, nil) if err != nil { return fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() query := `INSERT INTO rule (source_id, event, template_id, conditions, enabled) VALUES (?, ?, ?, ?, ?)` condsJSON, _ := marshalJSON(r.Conditions) result, err := tx.ExecContext(ctx, query, r.SourceID, r.Event, r.TemplateID, condsJSON, r.Enabled) if err != nil { return fmt.Errorf("create rule: %w", err) } id, _ := result.LastInsertId() r.ID = int(id) for _, chID := range channelIDs { _, err := tx.ExecContext(ctx, `INSERT INTO rule_channel (rule_id, channel_id, enabled) VALUES (?, ?, 1)`, r.ID, chID) if err != nil { return fmt.Errorf("add rule_channel: %w", err) } } return tx.Commit() } func (s *Store) GetRule(ctx context.Context, id int) (*model.Rule, error) { var r model.Rule var condsBytes []byte row := s.DB.QueryRowContext(ctx, `SELECT id, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM rule WHERE id = ?`, id) if err := row.Scan(&r.ID, &r.SourceID, &r.Event, &r.TemplateID, &condsBytes, &r.Enabled, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, fmt.Errorf("get rule %d: %w", id, err) } if len(condsBytes) > 0 && string(condsBytes) != "null" { raw := json.RawMessage(condsBytes) r.Conditions = &raw } return &r, nil } func (s *Store) GetRuleBySourceEvent(ctx context.Context, sourceID int, event string) (*model.Rule, error) { var r model.Rule var condsBytes []byte query := `SELECT id, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM rule WHERE source_id = ? AND event = ? AND enabled = 1` row := s.DB.QueryRowContext(ctx, query, sourceID, event) if err := row.Scan(&r.ID, &r.SourceID, &r.Event, &r.TemplateID, &condsBytes, &r.Enabled, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, fmt.Errorf("get rule by source+event: %w", err) } if len(condsBytes) > 0 && string(condsBytes) != "null" { raw := json.RawMessage(condsBytes) r.Conditions = &raw } return &r, nil } func (s *Store) ListRules(ctx context.Context) ([]model.Rule, error) { rows, err := s.DB.QueryContext(ctx, `SELECT id, source_id, event, template_id, conditions, enabled, created_at, updated_at FROM rule ORDER BY id`) if err != nil { return nil, fmt.Errorf("list rules: %w", err) } defer rows.Close() return scanRules(rows) } func (s *Store) UpdateRule(ctx context.Context, id int, r *model.Rule, channelIDs []int) error { tx, err := s.DB.BeginTxx(ctx, nil) if err != nil { return fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() condsJSON, _ := marshalJSON(r.Conditions) _, err = tx.ExecContext(ctx, `UPDATE rule SET source_id=?, event=?, template_id=?, conditions=?, enabled=? WHERE id=?`, r.SourceID, r.Event, r.TemplateID, condsJSON, r.Enabled, id) if err != nil { return fmt.Errorf("update rule: %w", err) } if channelIDs != nil { _, _ = tx.ExecContext(ctx, `DELETE FROM rule_channel WHERE rule_id = ?`, id) for _, chID := range channelIDs { _, err := tx.ExecContext(ctx, `INSERT INTO rule_channel (rule_id, channel_id, enabled) VALUES (?, ?, 1)`, id, chID) if err != nil { return fmt.Errorf("add rule_channel: %w", err) } } } return tx.Commit() } func (s *Store) DeleteRule(ctx context.Context, id int) error { _, err := s.DB.ExecContext(ctx, `DELETE FROM rule WHERE id = ?`, id) return err } func (s *Store) SetRuleEnabled(ctx context.Context, id int, enabled bool) error { v := 0 if enabled { v = 1 } _, err := s.DB.ExecContext(ctx, `UPDATE rule SET enabled = ? WHERE id = ?`, v, id) return err } func (s *Store) GetRuleChannels(ctx context.Context, ruleID int) ([]model.RuleChannel, error) { var rcs []model.RuleChannel err := s.DB.SelectContext(ctx, &rcs, `SELECT id, rule_id, channel_id, enabled FROM rule_channel WHERE rule_id = ? AND enabled = 1`, ruleID) if err != nil { return nil, fmt.Errorf("get rule channels: %w", err) } return rcs, nil } func (s *Store) SetRuleChannelEnabled(ctx context.Context, ruleID, channelID int, enabled bool) error { v := 0 if enabled { v = 1 } _, err := s.DB.ExecContext(ctx, `UPDATE rule_channel SET enabled = ? WHERE rule_id = ? AND channel_id = ?`, v, ruleID, channelID) return err } // helpers func marshalJSON(v *json.RawMessage) (interface{}, error) { if v == nil { return nil, nil } return json.Marshal(v) } func scanRules(rows *sql.Rows) ([]model.Rule, error) { var rules []model.Rule for rows.Next() { var r model.Rule var condsBytes []byte if err := rows.Scan(&r.ID, &r.SourceID, &r.Event, &r.TemplateID, &condsBytes, &r.Enabled, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, err } if len(condsBytes) > 0 && string(condsBytes) != "null" { raw := json.RawMessage(condsBytes) r.Conditions = &raw } rules = append(rules, r) } return rules, rows.Err() } ``` Note: `scanRules` needs `database/sql` import. Add to top of file: ```go import ( "context" "database/sql" "encoding/json" "fmt" "aiaa-notification-service/internal/model" ) ``` - [ ] **Step 2: Write MessageLog store** File: `internal/store/message_log.go` ```go package store import ( "context" "fmt" "aiaa-notification-service/internal/model" ) type MessageLogFilter struct { Source string `form:"source"` Event string `form:"event"` Status string `form:"status"` Page int `form:"page"` PageSize int `form:"page_size"` } func (s *Store) CreateMessageLog(ctx context.Context, ml *model.MessageLog) error { query := `INSERT INTO message_log (rule_id, channel_id, source, event, payload, content, status, retry_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` result, err := s.DB.ExecContext(ctx, query, ml.RuleID, ml.ChannelID, ml.Source, ml.Event, ml.Payload, ml.Content, ml.Status, ml.RetryCount) if err != nil { return fmt.Errorf("create message_log: %w", err) } id, _ := result.LastInsertId() ml.ID = id return nil } func (s *Store) UpdateMessageLog(ctx context.Context, id int64, status string, response, errMsg *string) error { query := `UPDATE message_log SET status=?, response=?, error_msg=? WHERE id=?` _, err := s.DB.ExecContext(ctx, query, status, response, errMsg, id) return err } func (s *Store) ListMessageLogs(ctx context.Context, filter MessageLogFilter) ([]model.MessageLog, int, error) { where := "WHERE 1=1" args := []interface{}{} if filter.Source != "" { where += " AND source = ?" args = append(args, filter.Source) } if filter.Event != "" { where += " AND event = ?" args = append(args, filter.Event) } if filter.Status != "" { where += " AND status = ?" args = append(args, filter.Status) } var count int countQuery := "SELECT COUNT(*) FROM message_log " + where if err := s.DB.GetContext(ctx, &count, countQuery, args...); err != nil { return nil, 0, err } if filter.Page <= 0 { filter.Page = 1 } if filter.PageSize <= 0 { filter.PageSize = 20 } offset := (filter.Page - 1) * filter.PageSize var logs []model.MessageLog query := "SELECT * FROM message_log " + where + " ORDER BY id DESC LIMIT ? OFFSET ?" args = append(args, filter.PageSize, offset) if err := s.DB.SelectContext(ctx, &logs, query, args...); err != nil { return nil, 0, err } return logs, count, nil } ``` - [ ] **Step 3: Verify compilation** ```bash go build ./internal/store/... ``` Expected: no errors. Fix any import issues. - [ ] **Step 4: Commit** ```bash git add -A && git commit -m "feat: rule and message_log store CRUD" ``` --- ### Task 6: Redis Cache Layer **Files:** - Create: `internal/cache/redis.go` **Interfaces:** - Consumes: `config.RedisConfig` - Produces: `cache.NewCache(cfg config.RedisConfig) (*Cache, error)`, `Cache.GetRule`, `Cache.SetRule`, `Cache.InvalidateRule`, `Cache.GetChannels`, `Cache.SetChannels`, `Cache.InvalidateChannels`, `Cache.CheckRateLimit`, `Cache.InvalidateBySource`, `Cache.InvalidateByTemplate` - [ ] **Step 1: Write cache layer** File: `internal/cache/redis.go` ```go package cache import ( "context" "encoding/json" "fmt" "strconv" "time" "aiaa-notification-service/internal/model" "github.com/redis/go-redis/v9" ) type Cache struct { rdb *redis.Client } // CachedRule holds the minimal info needed after rule matching. type CachedRule struct { RuleID int `json:"rule_id"` TemplateID int `json:"template_id"` Content string `json:"content"` Conditions string `json:"conditions"` // JSON string, empty if null } type CachedChannel struct { ID int `json:"id"` Type string `json:"type"` Config *json.RawMessage `json:"config"` } func NewCache(cfg model.config.RedisConfig) (*Cache, error) { rdb := redis.NewClient(&redis.Options{ Addr: cfg.Addr(), Password: cfg.Password, DB: cfg.DB, }) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := rdb.Ping(ctx).Err(); err != nil { return nil, fmt.Errorf("redis ping: %w", err) } return &Cache{rdb: rdb}, nil } // --- Rule cache --- func ruleKey(sourceID int, event string) string { return fmt.Sprintf("notify:rule:%d:%s", sourceID, event) } func (c *Cache) GetRule(ctx context.Context, sourceID int, event string) (*CachedRule, error) { data, err := c.rdb.Get(ctx, ruleKey(sourceID, event)).Bytes() if err != nil { return nil, err } var cr CachedRule if err := json.Unmarshal(data, &cr); err != nil { return nil, err } return &cr, nil } func (c *Cache) SetRule(ctx context.Context, sourceID int, event string, cr *CachedRule) error { data, err := json.Marshal(cr) if err != nil { return err } return c.rdb.Set(ctx, ruleKey(sourceID, event), data, 5*time.Minute).Err() } func (c *Cache) InvalidateRule(ctx context.Context, sourceID int, event string) error { return c.rdb.Del(ctx, ruleKey(sourceID, event)).Err() } // --- Channel cache --- func channelsKey(ruleID int) string { return fmt.Sprintf("notify:channels:%d", ruleID) } func (c *Cache) GetChannels(ctx context.Context, ruleID int) ([]CachedChannel, error) { data, err := c.rdb.Get(ctx, channelsKey(ruleID)).Bytes() if err != nil { return nil, err } var channels []CachedChannel if err := json.Unmarshal(data, &channels); err != nil { return nil, err } return channels, nil } func (c *Cache) SetChannels(ctx context.Context, ruleID int, channels []CachedChannel) error { data, err := json.Marshal(channels) if err != nil { return err } return c.rdb.Set(ctx, channelsKey(ruleID), data, 5*time.Minute).Err() } func (c *Cache) InvalidateChannels(ctx context.Context, ruleID int) error { return c.rdb.Del(ctx, channelsKey(ruleID)).Err() } // --- Bulk invalidation --- func (c *Cache) InvalidateBySource(ctx context.Context, sourceID int) error { pattern := fmt.Sprintf("notify:rule:%d:*", sourceID) iter := c.rdb.Scan(ctx, 0, pattern, 0).Iterator() for iter.Next(ctx) { c.rdb.Del(ctx, iter.Val()) } return iter.Err() } func (c *Cache) InvalidateByTemplate(ctx context.Context, templateID int) error { // Template changes mean all cached rule info is stale. Simplest: scan all rule keys. // In production, you'd maintain a template→rule reverse index. For v1, scan is acceptable. iter := c.rdb.Scan(ctx, 0, "notify:rule:*", 0).Iterator() for iter.Next(ctx) { c.rdb.Del(ctx, iter.Val()) } return iter.Err() } // --- Rate limiter --- func (c *Cache) CheckRateLimit(ctx context.Context, sourceID int, limitPerSec int) (bool, int, error) { key := fmt.Sprintf("ratelimit:%d", sourceID) now := time.Now().Unix() pipe := c.rdb.Pipeline() pipe.ZRemRangeByScore(ctx, key, "0", strconv.FormatInt(now-1, 10)) countCmd := pipe.ZCard(ctx, key) pipe.Exec(ctx) count := countCmd.Val() if count >= int64(limitPerSec) { return false, 1, nil // rate limited, retry after 1s } c.rdb.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: strconv.FormatInt(now*1000, 10)}) c.rdb.Expire(ctx, key, 2*time.Second) return true, 0, nil } func (c *Cache) Close() error { return c.rdb.Close() } ``` Note: the import references `model.config.RedisConfig` — need to fix this. The cache package imports `config`, not model. Update the function signature: ```go import ( "aiaa-notification-service/internal/config" ) func NewCache(cfg config.RedisConfig) (*Cache, error) { ``` - [ ] **Step 2: Install go-redis** ```bash go get github.com/redis/go-redis/v9 go mod tidy ``` - [ ] **Step 3: Verify compilation** ```bash go build ./internal/cache/... ``` Expected: no errors. - [ ] **Step 4: Commit** ```bash git add -A && git commit -m "feat: redis cache layer with rule/channel caching and rate limiter" ``` --- ### Task 7: Message Parser (JSON / Regex / Text) **Files:** - Create: `internal/parser/parser.go` - Create: `internal/parser/json.go` - Create: `internal/parser/regex.go` - Create: `internal/parser/text.go` **Interfaces:** - Produces: `parser.ParsedMessage`, `parser.Parser` interface, `parser.NewParser(parseMode string, parsePattern string) (Parser, error)` - [ ] **Step 1: Write parser interface and ParsedMessage** File: `internal/parser/parser.go` ```go package parser import ( "fmt" ) // ParsedMessage is the result of parsing an incoming notification body. type ParsedMessage struct { Event string Data map[string]interface{} // field name → value, passed to template Body string // raw body (for text mode) } // Parser converts a raw HTTP body into a ParsedMessage. type Parser interface { Parse(body []byte) (*ParsedMessage, error) } func NewParser(parseMode string, parsePattern string) (Parser, error) { switch parseMode { case "json": return &JSONParser{}, nil case "regex": if parsePattern == "" { return nil, fmt.Errorf("regex parse mode requires parse_pattern") } return NewRegexParser(parsePattern) case "text": return &TextParser{}, nil default: return nil, fmt.Errorf("unknown parse_mode: %s", parseMode) } } ``` - [ ] **Step 2: Write JSON parser** File: `internal/parser/json.go` ```go package parser import ( "encoding/json" "fmt" ) type JSONParser struct{} func (p *JSONParser) Parse(body []byte) (*ParsedMessage, error) { var raw map[string]interface{} if err := json.Unmarshal(body, &raw); err != nil { return nil, fmt.Errorf("json parse: %w", err) } event, ok := raw["event"].(string) if !ok { return nil, fmt.Errorf("json: missing 'event' field") } data, _ := raw["data"].(map[string]interface{}) if data == nil { // If no "data" key, use the entire JSON as data (minus event) data = make(map[string]interface{}) for k, v := range raw { if k != "event" { data[k] = v } } } return &ParsedMessage{ Event: event, Data: data, Body: string(body), }, nil } ``` - [ ] **Step 3: Write Regex parser** File: `internal/parser/regex.go` ```go package parser import ( "fmt" "regexp" ) type RegexParser struct { re *regexp.Regexp } func NewRegexParser(pattern string) (*RegexParser, error) { re, err := regexp.Compile(pattern) if err != nil { return nil, fmt.Errorf("compile regex pattern: %w", err) } return &RegexParser{re: re}, nil } func (p *RegexParser) Parse(body []byte) (*ParsedMessage, error) { matches := p.re.FindStringSubmatch(string(body)) if matches == nil { return nil, fmt.Errorf("regex: body does not match pattern") } names := p.re.SubexpNames() data := make(map[string]interface{}) var event string for i, name := range names { if i == 0 || name == "" { continue } if name == "event" { event = matches[i] } else { data[name] = matches[i] } } return &ParsedMessage{ Event: event, Data: data, Body: string(body), }, nil } ``` - [ ] **Step 4: Write Text parser** File: `internal/parser/text.go` ```go package parser type TextParser struct{} func (p *TextParser) Parse(body []byte) (*ParsedMessage, error) { return &ParsedMessage{ Event: "default", // text mode has no explicit event Data: map[string]interface{}{"Body": string(body)}, Body: string(body), }, nil } ``` - [ ] **Step 5: Write parser tests** File: `internal/parser/parser_test.go` ```go package parser import ( "testing" ) func TestJSONParser(t *testing.T) { p := &JSONParser{} body := []byte(`{"event":"trade.open","data":{"symbol":"BTC","price":65000}}`) msg, err := p.Parse(body) if err != nil { t.Fatalf("unexpected error: %v", err) } if msg.Event != "trade.open" { t.Errorf("expected event 'trade.open', got '%s'", msg.Event) } if msg.Data["symbol"] != "BTC" { t.Errorf("expected symbol 'BTC', got '%v'", msg.Data["symbol"]) } } func TestJSONParser_NoDataKey(t *testing.T) { p := &JSONParser{} body := []byte(`{"event":"alert","symbol":"ETH"}`) msg, err := p.Parse(body) if err != nil { t.Fatalf("unexpected error: %v", err) } if msg.Data["symbol"] != "ETH" { t.Errorf("expected symbol 'ETH' in data, got '%v'", msg.Data["symbol"]) } } func TestRegexParser(t *testing.T) { p, err := NewRegexParser(`(?P\w+) (?P.+)`) if err != nil { t.Fatalf("unexpected error: %v", err) } body := []byte("trade.open BTC long at 65000") msg, err := p.Parse(body) if err != nil { t.Fatalf("unexpected error: %v", err) } if msg.Event != "trade.open" { t.Errorf("expected event 'trade.open', got '%s'", msg.Event) } if msg.Data["message"] != "BTC long at 65000" { t.Errorf("expected message, got '%v'", msg.Data["message"]) } } func TestTextParser(t *testing.T) { p := &TextParser{} body := []byte("raw notification text here") msg, err := p.Parse(body) if err != nil { t.Fatalf("unexpected error: %v", err) } if msg.Data["Body"] != "raw notification text here" { t.Errorf("expected Body in data, got '%v'", msg.Data["Body"]) } } ``` - [ ] **Step 6: Run tests** ```bash go test ./internal/parser/... -v ``` Expected: all tests PASS. - [ ] **Step 7: Commit** ```bash git add -A && git commit -m "feat: message parser with json/regex/text modes" ``` --- ### Task 8: Condition Evaluator **Files:** - Create: `internal/condition/evaluator.go` **Interfaces:** - Produces: `condition.Evaluate(conditions []model.Condition, data map[string]interface{}) bool` - [ ] **Step 1: Write condition evaluator** File: `internal/condition/evaluator.go` ```go package condition import ( "fmt" "strconv" "strings" "aiaa-notification-service/internal/model" ) // Evaluate checks all conditions against data. Returns true if ALL conditions pass. // An empty conditions slice always returns true. func Evaluate(conditions []model.Condition, data map[string]interface{}) bool { if len(conditions) == 0 { return true } for _, c := range conditions { if !evaluateOne(c, data) { return false } } return true } func evaluateOne(c model.Condition, data map[string]interface{}) bool { fieldVal, fieldExists := data[c.Field] switch c.Op { case "exists": return fieldExists case "not_exists": return !fieldExists case "eq": if !fieldExists { return false } return fmt.Sprintf("%v", fieldVal) == c.Value case "ne": if !fieldExists { return false } return fmt.Sprintf("%v", fieldVal) != c.Value case "contains": if !fieldExists { return false } return strings.Contains(fmt.Sprintf("%v", fieldVal), c.Value) case "gt", "gte", "lt", "lte": if !fieldExists { return false } return compareNumeric(fieldVal, c.Value, c.Op) default: return false } } func compareNumeric(fieldVal interface{}, value string, op string) bool { fv, err := toFloat64(fieldVal) if err != nil { return false } cv, err := strconv.ParseFloat(value, 64) if err != nil { return false } switch op { case "gt": return fv > cv case "gte": return fv >= cv case "lt": return fv < cv case "lte": return fv <= cv } return false } func toFloat64(v interface{}) (float64, error) { switch val := v.(type) { case float64: return val, nil case float32: return float64(val), nil case int: return float64(val), nil case int64: return float64(val), nil case string: return strconv.ParseFloat(val, 64) case json.Number: return val.Float64() default: return 0, fmt.Errorf("cannot convert %T to float64", v) } } ``` Note: needs `encoding/json` import for `json.Number`. - [ ] **Step 2: Write tests** File: `internal/condition/evaluator_test.go` ```go package condition import ( "testing" "aiaa-notification-service/internal/model" ) func TestEvaluate_Empty(t *testing.T) { if !Evaluate(nil, nil) { t.Error("empty conditions should pass") } } func TestEvaluate_Exists(t *testing.T) { conds := []model.Condition{{Field: "symbol", Op: "exists"}} data := map[string]interface{}{"symbol": "BTC"} if !Evaluate(conds, data) { t.Error("symbol exists, should pass") } } func TestEvaluate_NotExists(t *testing.T) { conds := []model.Condition{{Field: "symbol", Op: "not_exists"}} data := map[string]interface{}{"price": 100} if !Evaluate(conds, data) { t.Error("symbol not exists, should pass") } } func TestEvaluate_Gt(t *testing.T) { conds := []model.Condition{{Field: "price", Op: "gt", Value: "100"}} data := map[string]interface{}{"price": float64(200)} if !Evaluate(conds, data) { t.Error("200 > 100, should pass") } } func TestEvaluate_Fail(t *testing.T) { conds := []model.Condition{ {Field: "symbol", Op: "exists"}, {Field: "price", Op: "lt", Value: "100"}, } data := map[string]interface{}{"symbol": "BTC", "price": float64(200)} if Evaluate(conds, data) { t.Error("200 < 100 is false, should fail") } } func TestEvaluate_Contains(t *testing.T) { conds := []model.Condition{{Field: "msg", Op: "contains", Value: "error"}} data := map[string]interface{}{"msg": "connection error occurred"} if !Evaluate(conds, data) { t.Error("msg contains 'error', should pass") } } ``` - [ ] **Step 3: Run tests** ```bash go test ./internal/condition/... -v ``` Expected: all tests PASS. - [ ] **Step 4: Commit** ```bash git add -A && git commit -m "feat: condition evaluator for rule filtering" ``` --- ### Task 9: Channel Adapters — Interface + HTTP-based (DingTalk, WeCom, Bark) **Files:** - Create: `internal/adapter/adapter.go` - Create: `internal/adapter/dingtalk.go` - Create: `internal/adapter/wecom.go` - Create: `internal/adapter/bark.go` **Interfaces:** - Produces: `adapter.ChannelSender` interface, `adapter.NewSender(channelType string, smtpCfg *config.SMTPConfig) (ChannelSender, error)` - [ ] **Step 1: Write ChannelSender interface + factory** File: `internal/adapter/adapter.go` ```go package adapter import ( "encoding/json" "fmt" "aiaa-notification-service/internal/config" ) // ChannelSender sends a rendered message to a specific channel. type ChannelSender interface { Type() string Send(title, content string, config json.RawMessage) error } func NewSender(channelType string, smtpCfg *config.SMTPConfig) (ChannelSender, error) { switch channelType { case "dingtalk": return &DingTalkSender{}, nil case "wecom": return &WeComSender{}, nil case "email": if smtpCfg == nil { return nil, fmt.Errorf("SMTP config required for email sender") } return NewEmailSender(*smtpCfg), nil case "bark": return &BarkSender{}, nil default: return nil, fmt.Errorf("unknown channel type: %s", channelType) } } ``` - [ ] **Step 2: Write DingTalk sender** File: `internal/adapter/dingtalk.go` ```go package adapter import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "net/http" "net/url" "time" ) type dingtalkConfig struct { WebhookURL string `json:"webhook_url"` Secret string `json:"secret,omitempty"` } type dingtalkMessage struct { MsgType string `json:"msgtype"` Markdown *dingtalkMD `json:"markdown"` } type dingtalkMD struct { Title string `json:"title"` Text string `json:"text"` } type DingTalkSender struct{} func (s *DingTalkSender) Type() string { return "dingtalk" } func (s *DingTalkSender) Send(title, content string, config json.RawMessage) error { var cfg dingtalkConfig if err := json.Unmarshal(config, &cfg); err != nil { return fmt.Errorf("parse dingtalk config: %w", err) } reqURL := cfg.WebhookURL if cfg.Secret != "" { timestamp := time.Now().UnixMilli() sign := dingtalkSign(timestamp, cfg.Secret) reqURL = fmt.Sprintf("%s×tamp=%d&sign=%s", cfg.WebhookURL, timestamp, sign) } msg := dingtalkMessage{ MsgType: "markdown", Markdown: &dingtalkMD{ Title: title, Text: content, }, } body, _ := json.Marshal(msg) resp, err := http.Post(reqURL, "application/json", bytes.NewReader(body)) if err != nil { return fmt.Errorf("dingtalk send: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return fmt.Errorf("dingtalk returned status %d", resp.StatusCode) } return nil } func dingtalkSign(timestamp int64, secret string) string { mac := hmac.New(sha256.New, []byte(secret)) fmt.Fprintf(mac, "%d\n%s", timestamp, secret) signData := mac.Sum(nil) return url.QueryEscape(base64.StdEncoding.EncodeToString(signData)) } ``` - [ ] **Step 3: Write WeCom (企业微信) sender** File: `internal/adapter/wecom.go` ```go package adapter import ( "bytes" "encoding/json" "fmt" "net/http" ) type wecomConfig struct { WebhookURL string `json:"webhook_url"` } type wecomMessage struct { MsgType string `json:"msgtype"` Markdown *wecomMD `json:"markdown"` } type wecomMD struct { Content string `json:"content"` } type WeComSender struct{} func (s *WeComSender) Type() string { return "wecom" } func (s *WeComSender) Send(title, content string, config json.RawMessage) error { var cfg wecomConfig if err := json.Unmarshal(config, &cfg); err != nil { return fmt.Errorf("parse wecom config: %w", err) } msg := wecomMessage{ MsgType: "markdown", Markdown: &wecomMD{ Content: fmt.Sprintf("## %s\n%s", title, content), }, } body, _ := json.Marshal(msg) resp, err := http.Post(cfg.WebhookURL, "application/json", bytes.NewReader(body)) if err != nil { return fmt.Errorf("wecom send: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return fmt.Errorf("wecom returned status %d", resp.StatusCode) } return nil } ``` - [ ] **Step 4: Write Bark sender** File: `internal/adapter/bark.go` ```go package adapter import ( "bytes" "encoding/json" "fmt" "net/http" ) type barkConfig struct { URL string `json:"url"` } type barkPayload struct { Title string `json:"title"` Body string `json:"body"` } type BarkSender struct{} func (s *BarkSender) Type() string { return "bark" } func (s *BarkSender) Send(title, content string, config json.RawMessage) error { var cfg barkConfig if err := json.Unmarshal(config, &cfg); err != nil { return fmt.Errorf("parse bark config: %w", err) } payload := barkPayload{Title: title, Body: content} body, _ := json.Marshal(payload) resp, err := http.Post(cfg.URL, "application/json", bytes.NewReader(body)) if err != nil { return fmt.Errorf("bark send: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { return fmt.Errorf("bark returned status %d", resp.StatusCode) } return nil } ``` - [ ] **Step 5: Verify compilation** ```bash go build ./internal/adapter/... ``` Expected: no errors. - [ ] **Step 6: Commit** ```bash git add -A && git commit -m "feat: channel adapters — dingtalk, wecom, bark" ``` --- ### Task 10: Email Adapter (SMTP) **Files:** - Create: `internal/adapter/email.go` **Interfaces:** - Produces: `EmailSender` implementing `ChannelSender` - [ ] **Step 1: Write Email sender** File: `internal/adapter/email.go` ```go package adapter import ( "crypto/tls" "encoding/json" "fmt" "net" "net/smtp" "strings" "aiaa-notification-service/internal/config" ) type emailConfig struct { To []string `json:"to"` } type EmailSender struct { cfg config.SMTPConfig } func NewEmailSender(cfg config.SMTPConfig) *EmailSender { return &EmailSender{cfg: cfg} } func (s *EmailSender) Type() string { return "email" } func (s *EmailSender) Send(title, content string, config json.RawMessage) error { var ecfg emailConfig if err := json.Unmarshal(config, &ecfg); err != nil { return fmt.Errorf("parse email config: %w", err) } if len(ecfg.To) == 0 { return fmt.Errorf("email: 'to' list is empty") } msg := buildEmail(s.cfg.From, ecfg.To, title, content) addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port) if s.cfg.Port == 465 { return s.sendTLS(addr, ecfg.To, msg) } return s.sendSTARTTLS(addr, ecfg.To, msg) } func (s *EmailSender) sendTLS(addr string, to []string, msg []byte) error { tlsCfg := &tls.Config{ServerName: s.cfg.Host} conn, err := tls.Dial("tcp", addr, tlsCfg) if err != nil { return fmt.Errorf("tls dial: %w", err) } defer conn.Close() client, err := smtp.NewClient(conn, s.cfg.Host) if err != nil { return fmt.Errorf("smtp client: %w", err) } defer client.Quit() return s.authAndSend(client, to, msg) } func (s *EmailSender) sendSTARTTLS(addr string, to []string, msg []byte) error { conn, err := net.Dial("tcp", addr) if err != nil { return fmt.Errorf("dial: %w", err) } defer conn.Close() client, err := smtp.NewClient(conn, s.cfg.Host) if err != nil { return fmt.Errorf("smtp client: %w", err) } defer client.Quit() if ok, _ := client.Extension("STARTTLS"); ok { tlsCfg := &tls.Config{ServerName: s.cfg.Host} if err := client.StartTLS(tlsCfg); err != nil { return fmt.Errorf("starttls: %w", err) } } return s.authAndSend(client, to, msg) } func (s *EmailSender) authAndSend(client *smtp.Client, to []string, msg []byte) error { if s.cfg.User != "" { auth := smtp.PlainAuth("", s.cfg.User, s.cfg.Password, s.cfg.Host) if err := client.Auth(auth); err != nil { return fmt.Errorf("auth: %w", err) } } if err := client.Mail(s.cfg.From); err != nil { return fmt.Errorf("mail: %w", err) } for _, recipient := range to { if err := client.Rcpt(recipient); err != nil { return fmt.Errorf("rcpt %s: %w", recipient, err) } } w, err := client.Data() if err != nil { return fmt.Errorf("data: %w", err) } _, err = w.Write(msg) if err != nil { return fmt.Errorf("write: %w", err) } return w.Close() } func buildEmail(from string, to []string, subject, body string) []byte { var sb strings.Builder sb.WriteString(fmt.Sprintf("From: %s\r\n", from)) sb.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(to, ", "))) sb.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) sb.WriteString("MIME-Version: 1.0\r\n") sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n") sb.WriteString("\r\n") sb.WriteString(body) return []byte(sb.String()) } ``` Note: fix `s.cfg` references — they should be `s.cfg`. - [ ] **Step 2: Verify compilation** ```bash go build ./internal/adapter/... ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add -A && git commit -m "feat: email adapter with SMTP TLS support" ``` --- ### Task 11: Engine — Renderer, Matcher, Router **Files:** - Create: `internal/engine/renderer.go` - Create: `internal/engine/matcher.go` - Create: `internal/engine/router.go` **Interfaces:** - Produces: `engine.NewRenderer() *Renderer`, `Renderer.Render(templateContent string, data map[string]interface{}) (string, error)` - Produces: `engine.NewMatcher(store *store.Store, cache *cache.Cache) *Matcher`, `Matcher.Match(ctx, sourceID, event) (*model.Rule, error)` - Produces: `engine.NewRouter(store *store.Store, cache *cache.Cache, senderFactory func(string) (adapter.ChannelSender, error)) *Router`, `Router.Route(ctx, rule *model.Rule, content string) []SendResult` - [ ] **Step 1: Write Template Renderer** File: `internal/engine/renderer.go` ```go package engine import ( "bytes" "fmt" "text/template" ) type Renderer struct{} func NewRenderer() *Renderer { return &Renderer{} } func (r *Renderer) Render(tmplContent string, data map[string]interface{}) (string, error) { tmpl, err := template.New("notify").Parse(tmplContent) if err != nil { return "", fmt.Errorf("parse template: %w", err) } var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { return "", fmt.Errorf("execute template: %w", err) } return buf.String(), nil } ``` - [ ] **Step 2: Write Rule Matcher** File: `internal/engine/matcher.go` ```go package engine import ( "context" "encoding/json" "fmt" "aiaa-notification-service/internal/cache" "aiaa-notification-service/internal/model" "aiaa-notification-service/internal/store" ) type Matcher struct { store *store.Store cache *cache.Cache } func NewMatcher(s *store.Store, c *cache.Cache) *Matcher { return &Matcher{store: s, cache: c} } func (m *Matcher) Match(ctx context.Context, sourceID int, event string) (*model.Rule, error) { // Try cache first if m.cache != nil { cr, err := m.cache.GetRule(ctx, sourceID, event) if err == nil { rule := &model.Rule{ID: cr.RuleID, TemplateID: cr.TemplateID, SourceID: sourceID, Event: event} if cr.Conditions != "" && cr.Conditions != "null" { raw := json.RawMessage(cr.Conditions) rule.Conditions = &raw } return rule, nil } } // Fall back to DB rule, err := m.store.GetRuleBySourceEvent(ctx, sourceID, event) if err != nil { return nil, fmt.Errorf("match rule: %w", err) } // Warm cache if m.cache != nil { tmpl, err := m.store.GetTemplate(ctx, rule.TemplateID) if err != nil { return rule, nil // rule found but template fetch failed — still return rule } cr := &cache.CachedRule{ RuleID: rule.ID, TemplateID: rule.TemplateID, Content: tmpl.Content, } if rule.Conditions != nil { cr.Conditions = string(*rule.Conditions) } _ = m.cache.SetRule(ctx, sourceID, event, cr) } return rule, nil } ``` - [ ] **Step 3: Write Router** File: `internal/engine/router.go` ```go package engine import ( "context" "fmt" "log/slog" "aiaa-notification-service/internal/adapter" "aiaa-notification-service/internal/cache" "aiaa-notification-service/internal/model" "aiaa-notification-service/internal/store" ) type SendResult struct { ChannelType string `json:"channel_type"` ChannelID int `json:"channel_id"` Success bool `json:"success"` Error string `json:"error,omitempty"` } type Router struct { store *store.Store cache *cache.Cache senderFactory func(channelType string) (adapter.ChannelSender, error) } func NewRouter(s *store.Store, c *cache.Cache, sf func(channelType string) (adapter.ChannelSender, error)) *Router { return &Router{store: s, cache: c, senderFactory: sf} } // Route sends content to all enabled channels for the rule. Returns immediately, sends async. func (r *Router) Route(ctx context.Context, rule *model.Rule, title, content string) []string { channels, err := r.getChannels(ctx, rule.ID) if err != nil { slog.Error("get channels for rule", "rule_id", rule.ID, "error", err) return nil } channelNames := make([]string, 0, len(channels)) for _, ch := range channels { channelNames = append(channelNames, fmt.Sprintf("%s:%d", ch.Type, ch.ID)) sender, err := r.senderFactory(ch.Type) if err != nil { slog.Error("create sender", "type", ch.Type, "error", err) continue } go func(ch cache.CachedChannel, s adapter.ChannelSender) { var cfg json.RawMessage if ch.Config != nil { cfg = *ch.Config } if err := s.Send(title, content, cfg); err != nil { slog.Error("send failed", "channel_type", ch.Type, "channel_id", ch.ID, "error", err) } else { slog.Info("sent", "channel_type", ch.Type, "channel_id", ch.ID) } }(ch, sender) } return channelNames } func (r *Router) getChannels(ctx context.Context, ruleID int) ([]cache.CachedChannel, error) { if r.cache != nil { chs, err := r.cache.GetChannels(ctx, ruleID) if err == nil { return chs, nil } } rcs, err := r.store.GetRuleChannels(ctx, ruleID) if err != nil { return nil, err } var cached []cache.CachedChannel for _, rc := range rcs { ch, err := r.store.GetChannel(ctx, rc.ChannelID) if err != nil { continue } cc := cache.CachedChannel{ID: ch.ID, Type: ch.Type, Config: ch.Config} cached = append(cached, cc) } if r.cache != nil { _ = r.cache.SetChannels(ctx, ruleID, cached) } return cached, nil } ``` Note: need `encoding/json` import in router.go. - [ ] **Step 4: Write tests for renderer** File: `internal/engine/renderer_test.go` ```go package engine import ( "strings" "testing" ) func TestRenderer(t *testing.T) { r := NewRenderer() tmpl := "🚀 {{.symbol}} 开仓通知\n价格: {{.price}}" data := map[string]interface{}{"symbol": "BTC", "price": 65000} result, err := r.Render(tmpl, data) if err != nil { t.Fatalf("unexpected error: %v", err) } if !strings.Contains(result, "BTC") || !strings.Contains(result, "65000") { t.Errorf("unexpected output: %s", result) } } func TestRenderer_Error(t *testing.T) { r := NewRenderer() _, err := r.Render("{{.nonexistent}}", map[string]interface{}{}) if err == nil { t.Error("expected error for missing field, got nil") } } ``` - [ ] **Step 5: Run tests** ```bash go test ./internal/engine/... -v ``` Expected: renderer tests PASS. - [ ] **Step 6: Commit** ```bash git add -A && git commit -m "feat: engine — template renderer, rule matcher, channel router" ``` --- ### Task 12: HTTP Middleware (Auth + Rate Limit) **Files:** - Create: `internal/handler/middleware.go` **Interfaces:** - Produces: `handler.AdminAuth(adminKey string) gin.HandlerFunc`, `handler.SourceAuth(store *store.Store) gin.HandlerFunc`, `handler.RateLimit(cache *cache.Cache, defaultLimit int) gin.HandlerFunc` - [ ] **Step 1: Write middleware** File: `internal/handler/middleware.go` ```go package handler import ( "net/http" "strings" "aiaa-notification-service/internal/cache" "aiaa-notification-service/internal/store" "github.com/gin-gonic/gin" ) // AdminAuth checks the admin key for management API endpoints. func AdminAuth(adminKey string) gin.HandlerFunc { return func(c *gin.Context) { key := extractBearer(c) if key == "" || key != adminKey { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } c.Next() } } // SourceAuth identifies the source by its API key and sets it in context. func SourceAuth(s *store.Store) gin.HandlerFunc { return func(c *gin.Context) { key := extractBearer(c) if key == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing api key"}) return } source, err := s.GetSourceByAPIKey(c.Request.Context(), key) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"}) return } c.Set("source", source) c.Next() } } // RateLimit applies per-source rate limiting. func RateLimit(cache *cache.Cache, defaultLimit int) gin.HandlerFunc { return func(c *gin.Context) { source, exists := c.Get("source") if !exists { c.Next() return } src := source.(*model.Source) allowed, retryAfter, err := cache.CheckRateLimit(c.Request.Context(), src.ID, defaultLimit) if err != nil { // Redis error — allow pass through c.Next() return } if !allowed { c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ "error": "rate_limit_exceeded", "message": "too many requests", "retry_after": retryAfter, }) return } c.Next() } } func extractBearer(c *gin.Context) string { auth := c.GetHeader("Authorization") if auth == "" { return "" } parts := strings.SplitN(auth, " ", 2) if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { return "" } return parts[1] } ``` Note: `rateLimit` needs the `model` import for `source.(*model.Source)`. Add: ```go import ( "aiaa-notification-service/internal/model" ) ``` - [ ] **Step 2: Verify compilation** ```bash go get github.com/gin-gonic/gin go build ./internal/handler/... ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add -A && git commit -m "feat: auth and rate limit middleware" ``` --- ### Task 13: Notify Handler (POST /api/v1/notify) **Files:** - Create: `internal/handler/notify.go` **Interfaces:** - Consumes: `*store.Store`, `*cache.Cache`, `*engine.Matcher`, `*engine.Renderer`, `*engine.Router`, `*condition.Evaluator` - Produces: `POST /api/v1/notify` endpoint - [ ] **Step 1: Write notify handler** File: `internal/handler/notify.go` ```go package handler import ( "encoding/json" "io" "log/slog" "net/http" "aiaa-notification-service/internal/condition" "aiaa-notification-service/internal/engine" "aiaa-notification-service/internal/model" "aiaa-notification-service/internal/parser" "aiaa-notification-service/internal/store" "github.com/gin-gonic/gin" ) type NotifyHandler struct { store *store.Store cache *cache.Cache // used by engine matcher *engine.Matcher renderer *engine.Renderer router *engine.Router } func NewNotifyHandler(s *store.Store, c *cache.Cache, m *engine.Matcher, r *engine.Renderer, rt *engine.Router) *NotifyHandler { return &NotifyHandler{store: s, cache: c, matcher: m, renderer: r, router: rt} } func (h *NotifyHandler) Handle(c *gin.Context) { src := c.MustGet("source").(*model.Source) // 1. Read raw body body, err := io.ReadAll(c.Request.Body) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "read body failed"}) return } // 2. Parse message p, err := parser.NewParser(src.ParseMode, src.ParsePattern) if err != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "parser setup: " + err.Error()}) return } msg, err := p.Parse(body) if err != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "parse failed: " + err.Error()}) return } // 3. Match rule rule, err := h.matcher.Match(c.Request.Context(), src.ID, msg.Event) if err != nil { // No matching rule → 200 with matched: false c.JSON(http.StatusOK, gin.H{"matched": false}) return } // 4. Evaluate conditions if rule.Conditions != nil { var conds []model.Condition if err := json.Unmarshal(*rule.Conditions, &conds); err == nil { if !condition.Evaluate(conds, msg.Data) { c.JSON(http.StatusOK, gin.H{ "matched": true, "filtered": true, "reason": "condition not met", }) return } } } // 5. Get template content tmpl, err := h.store.GetTemplate(c.Request.Context(), rule.TemplateID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "template not found"}) return } // 6. Render template content, err := h.renderer.Render(tmpl.Content, msg.Data) if err != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "template render failed: " + err.Error()}) return } // 7. Route to channels title := src.Name + ": " + msg.Event channels := h.router.Route(c.Request.Context(), rule, title, content) // 8. Log message (best effort) go func() { payloadJSON, _ := json.Marshal(msg.Data) for _, chName := range channels { ml := &model.MessageLog{ RuleID: rule.ID, Source: src.Name, Event: msg.Event, Payload: payloadJSON, Content: content, Status: "pending", } _ = h.store.CreateMessageLog(c.Request.Context(), ml) } }() slog.Info("notification accepted", "source", src.Name, "event", msg.Event, "channels", channels, ) c.JSON(http.StatusOK, gin.H{ "matched": true, "channels": channels, "accepted": true, }) } ``` Note: need imports for `cache` and `engine` — add: ```go import ( "aiaa-notification-service/internal/cache" ) ``` - [ ] **Step 2: Verify compilation** ```bash go build ./internal/handler/... ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add -A && git commit -m "feat: notify handler — parse, match, evaluate, render, route" ``` --- ### Task 14: Management Handlers — Source, Template, Channel CRUD **Files:** - Create: `internal/handler/source.go` - Create: `internal/handler/template.go` - Create: `internal/handler/channel.go` **Interfaces:** - Consumes: `*store.Store`, `*cache.Cache` - Produces: Source/Template/Channel CRUD endpoints - [ ] **Step 1: Write Source CRUD handler** File: `internal/handler/source.go` ```go package handler import ( "net/http" "strconv" "aiaa-notification-service/internal/cache" "aiaa-notification-service/internal/model" "aiaa-notification-service/internal/store" "github.com/gin-gonic/gin" ) type SourceHandler struct { store *store.Store cache *cache.Cache } func NewSourceHandler(s *store.Store, c *cache.Cache) *SourceHandler { return &SourceHandler{store: s, cache: c} } type createSourceReq struct { Name string `json:"name" binding:"required"` ParseMode string `json:"parse_mode"` ParsePattern string `json:"parse_pattern"` Status int `json:"status"` } func (h *SourceHandler) Create(c *gin.Context) { var req createSourceReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.ParseMode == "" { req.ParseMode = "json" } if req.Status == 0 { req.Status = 1 } src := &model.Source{ Name: req.Name, ParseMode: req.ParseMode, ParsePattern: req.ParsePattern, Status: req.Status, } if err := h.store.CreateSource(c.Request.Context(), src); err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, src) } func (h *SourceHandler) List(c *gin.Context) { sources, err := h.store.ListSources(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, sources) } func (h *SourceHandler) Get(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) src, err := h.store.GetSource(c.Request.Context(), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "source not found"}) return } c.JSON(http.StatusOK, src) } func (h *SourceHandler) Update(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var req createSourceReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } src := &model.Source{ Name: req.Name, ParseMode: req.ParseMode, ParsePattern: req.ParsePattern, Status: req.Status, } if err := h.store.UpdateSource(c.Request.Context(), id, src); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Invalidate cache if h.cache != nil { _ = h.cache.InvalidateBySource(c.Request.Context(), id) } c.JSON(http.StatusOK, gin.H{"ok": true}) } func (h *SourceHandler) Delete(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) if err := h.store.DeleteSource(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } ``` - [ ] **Step 2: Write Template CRUD handler** File: `internal/handler/template.go` ```go package handler import ( "net/http" "strconv" "aiaa-notification-service/internal/cache" "aiaa-notification-service/internal/model" "aiaa-notification-service/internal/store" "github.com/gin-gonic/gin" ) type TemplateHandler struct { store *store.Store cache *cache.Cache } func NewTemplateHandler(s *store.Store, c *cache.Cache) *TemplateHandler { return &TemplateHandler{store: s, cache: c} } type createTemplateReq struct { Name string `json:"name" binding:"required"` Content string `json:"content" binding:"required"` } func (h *TemplateHandler) Create(c *gin.Context) { var req createTemplateReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } tmpl := &model.Template{Name: req.Name, Content: req.Content} if err := h.store.CreateTemplate(c.Request.Context(), tmpl); err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, tmpl) } func (h *TemplateHandler) List(c *gin.Context) { templates, err := h.store.ListTemplates(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, templates) } func (h *TemplateHandler) Get(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) tmpl, err := h.store.GetTemplate(c.Request.Context(), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "template not found"}) return } c.JSON(http.StatusOK, tmpl) } func (h *TemplateHandler) Update(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var req createTemplateReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } tmpl := &model.Template{Name: req.Name, Content: req.Content} if err := h.store.UpdateTemplate(c.Request.Context(), id, tmpl); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if h.cache != nil { _ = h.cache.InvalidateByTemplate(c.Request.Context(), id) } c.JSON(http.StatusOK, gin.H{"ok": true}) } func (h *TemplateHandler) Delete(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) if err := h.store.DeleteTemplate(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } ``` - [ ] **Step 3: Write Channel CRUD handler** File: `internal/handler/channel.go` ```go package handler import ( "encoding/json" "net/http" "strconv" "aiaa-notification-service/internal/cache" "aiaa-notification-service/internal/model" "aiaa-notification-service/internal/store" "github.com/gin-gonic/gin" ) type ChannelHandler struct { store *store.Store cache *cache.Cache } func NewChannelHandler(s *store.Store, c *cache.Cache) *ChannelHandler { return &ChannelHandler{store: s, cache: c} } type createChannelReq struct { Name string `json:"name" binding:"required"` Type string `json:"type" binding:"required"` Config json.RawMessage `json:"config" binding:"required"` Status int `json:"status"` } func (h *ChannelHandler) Create(c *gin.Context) { var req createChannelReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Status == 0 { req.Status = 1 } raw := json.RawMessage(req.Config) ch := &model.Channel{Name: req.Name, Type: req.Type, Config: &raw, Status: req.Status} if err := h.store.CreateChannel(c.Request.Context(), ch); err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, ch) } func (h *ChannelHandler) List(c *gin.Context) { channels, err := h.store.ListChannels(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, channels) } func (h *ChannelHandler) Get(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) ch, err := h.store.GetChannel(c.Request.Context(), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } c.JSON(http.StatusOK, ch) } func (h *ChannelHandler) Update(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var req createChannelReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } raw := json.RawMessage(req.Config) ch := &model.Channel{Name: req.Name, Type: req.Type, Config: &raw, Status: req.Status} if err := h.store.UpdateChannel(c.Request.Context(), id, ch); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } func (h *ChannelHandler) Delete(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) if err := h.store.DeleteChannel(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } ``` - [ ] **Step 4: Verify compilation** ```bash go build ./internal/handler/... ``` Expected: no errors. - [ ] **Step 5: Commit** ```bash git add -A && git commit -m "feat: management handlers — source, template, channel CRUD" ``` --- ### Task 15: Management Handlers — Rule CRUD + MessageLog **Files:** - Create: `internal/handler/rule.go` - Create: `internal/handler/message_log.go` **Interfaces:** - Produces: Rule CRUD + enable/disable/channel-toggle endpoints - Produces: GET message-logs with pagination - [ ] **Step 1: Write Rule CRUD handler** File: `internal/handler/rule.go` ```go package handler import ( "encoding/json" "net/http" "strconv" "aiaa-notification-service/internal/cache" "aiaa-notification-service/internal/model" "aiaa-notification-service/internal/store" "github.com/gin-gonic/gin" ) type RuleHandler struct { store *store.Store cache *cache.Cache } func NewRuleHandler(s *store.Store, c *cache.Cache) *RuleHandler { return &RuleHandler{store: s, cache: c} } type createRuleReq struct { SourceName string `json:"source_name" binding:"required"` Event string `json:"event" binding:"required"` TemplateName string `json:"template_name" binding:"required"` Channels []string `json:"channels"` Conditions []model.Condition `json:"conditions,omitempty"` Enabled int `json:"enabled"` } func (h *RuleHandler) Create(c *gin.Context) { var req createRuleReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Enabled == 0 { req.Enabled = 1 } // Resolve names → IDs src, err := h.store.GetSourceByName(c.Request.Context(), req.SourceName) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "source not found: " + req.SourceName}) return } tmpl, err := h.store.GetTemplateByName(c.Request.Context(), req.TemplateName) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "template not found: " + req.TemplateName}) return } channelIDs, err := resolveChannelNames(h.store, c, req.Channels) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var condsJSON *json.RawMessage if len(req.Conditions) > 0 { data, _ := json.Marshal(req.Conditions) raw := json.RawMessage(data) condsJSON = &raw } rule := &model.Rule{ SourceID: src.ID, Event: req.Event, TemplateID: tmpl.ID, Conditions: condsJSON, Enabled: req.Enabled, } if err := h.store.CreateRule(c.Request.Context(), rule, channelIDs); err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, rule) } func (h *RuleHandler) List(c *gin.Context) { rules, err := h.store.ListRules(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, rules) } func (h *RuleHandler) Get(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) rule, err := h.store.GetRule(c.Request.Context(), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "rule not found"}) return } c.JSON(http.StatusOK, rule) } func (h *RuleHandler) Update(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) var req createRuleReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } src, err := h.store.GetSourceByName(c.Request.Context(), req.SourceName) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "source not found"}) return } tmpl, err := h.store.GetTemplateByName(c.Request.Context(), req.TemplateName) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "template not found"}) return } channelIDs, err := resolveChannelNames(h.store, c, req.Channels) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var condsJSON *json.RawMessage if len(req.Conditions) > 0 { data, _ := json.Marshal(req.Conditions) raw := json.RawMessage(data) condsJSON = &raw } rule := &model.Rule{ SourceID: src.ID, Event: req.Event, TemplateID: tmpl.ID, Conditions: condsJSON, Enabled: req.Enabled, } if err := h.store.UpdateRule(c.Request.Context(), id, rule, channelIDs); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Invalidate cache if h.cache != nil { _ = h.cache.InvalidateRule(c.Request.Context(), src.ID, req.Event) _ = h.cache.InvalidateChannels(c.Request.Context(), id) } c.JSON(http.StatusOK, gin.H{"ok": true}) } func (h *RuleHandler) Delete(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) if err := h.store.DeleteRule(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } func (h *RuleHandler) Enable(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) if err := h.store.SetRuleEnabled(c.Request.Context(), id, true); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } func (h *RuleHandler) Disable(c *gin.Context) { id, _ := strconv.Atoi(c.Param("id")) if err := h.store.SetRuleEnabled(c.Request.Context(), id, false); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } func (h *RuleHandler) EnableChannel(c *gin.Context) { ruleID, _ := strconv.Atoi(c.Param("id")) channelID, _ := strconv.Atoi(c.Param("channel_id")) if err := h.store.SetRuleChannelEnabled(c.Request.Context(), ruleID, channelID, true); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } func (h *RuleHandler) DisableChannel(c *gin.Context) { ruleID, _ := strconv.Atoi(c.Param("id")) channelID, _ := strconv.Atoi(c.Param("channel_id")) if err := h.store.SetRuleChannelEnabled(c.Request.Context(), ruleID, channelID, false); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } func resolveChannelNames(s *store.Store, c *gin.Context, names []string) ([]int, error) { var ids []int for _, name := range names { ch, err := s.GetChannelByName(c.Request.Context(), name) if err != nil { return nil, err } ids = append(ids, ch.ID) } return ids, nil } ``` - [ ] **Step 2: Write MessageLog handler** File: `internal/handler/message_log.go` ```go package handler import ( "net/http" "aiaa-notification-service/internal/store" "github.com/gin-gonic/gin" ) type MessageLogHandler struct { store *store.Store } func NewMessageLogHandler(s *store.Store) *MessageLogHandler { return &MessageLogHandler{store: s} } func (h *MessageLogHandler) List(c *gin.Context) { var filter store.MessageLogFilter if err := c.ShouldBindQuery(&filter); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } logs, total, err := h.store.ListMessageLogs(c.Request.Context(), filter) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "data": logs, "total": total, "page": filter.Page, }) } ``` - [ ] **Step 3: Verify compilation** ```bash go build ./internal/handler/... ``` Expected: no errors. - [ ] **Step 4: Commit** ```bash git add -A && git commit -m "feat: rule CRUD with enable/disable and message log query" ``` --- ### Task 16: Retry Mechanism **Files:** - Create: `internal/retry/retry.go` **Interfaces:** - Produces: `retry.NewRetrier(maxRetries int, backoff []time.Duration) *Retrier`, `Retrier.Do(ctx context.Context, fn func() error) error` - [ ] **Step 1: Write retry module** File: `internal/retry/retry.go` ```go package retry import ( "context" "fmt" "log/slog" "time" ) type Retrier struct { maxRetries int backoff []time.Duration } func NewRetrier(maxRetries int, backoff []time.Duration) *Retrier { return &Retrier{maxRetries: maxRetries, backoff: backoff} } // DefaultRetrier returns a retrier with 3 attempts, exponential backoff: 1s, 5s, 30s. func DefaultRetrier() *Retrier { return NewRetrier(3, []time.Duration{1 * time.Second, 5 * time.Second, 30 * time.Second}) } func (r *Retrier) Do(ctx context.Context, fn func() error) error { var lastErr error for attempt := 0; attempt <= r.maxRetries; attempt++ { if attempt > 0 { delay := r.backoff[attempt-1] slog.Info("retrying", "attempt", attempt, "delay", delay) select { case <-ctx.Done(): return ctx.Err() case <-time.After(delay): } } err := fn() if err == nil { return nil } lastErr = err slog.Warn("attempt failed", "attempt", attempt, "error", err) } return fmt.Errorf("all %d attempts failed, last error: %w", r.maxRetries+1, lastErr) } ``` - [ ] **Step 2: Verify compilation** ```bash go build ./internal/retry/... ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash git add -A && git commit -m "feat: retry mechanism with exponential backoff" ``` --- ### Task 17: Main Assembly — Wire Everything Together **Files:** - Modify: `cmd/server/main.go` - Modify: `internal/handler/notify.go` (fix cache import) **Interfaces:** - Produces: fully functional HTTP server - [ ] **Step 1: Write complete main.go** File: `cmd/server/main.go` ```go package main import ( "context" "fmt" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "aiaa-notification-service/internal/cache" "aiaa-notification-service/internal/config" "aiaa-notification-service/internal/engine" "aiaa-notification-service/internal/handler" "aiaa-notification-service/internal/store" "github.com/gin-gonic/gin" ) func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) slog.SetDefault(logger) // Load config cfg, err := config.Load("config/config.yaml") if err != nil { slog.Error("failed to load config", "error", err) os.Exit(1) } // Connect MySQL store, err := store.NewStore(cfg.Database) if err != nil { slog.Error("failed to connect to MySQL", "error", err) os.Exit(1) } defer store.Close() // Connect Redis var redisCache *cache.Cache if cfg.Redis.Host != "" { redisCache, err = cache.NewCache(cfg.Redis) if err != nil { slog.Warn("redis connection failed, running without cache", "error", err) redisCache = nil } } else { slog.Warn("redis not configured, running without cache") } if redisCache != nil { defer redisCache.Close() } // Build engine matcher := engine.NewMatcher(store, redisCache) renderer := engine.NewRenderer() // Build sender factory senderFactory := func(channelType string) (adapter.ChannelSender, error) { return adapter.NewSender(channelType, &cfg.SMTP) } router := engine.NewRouter(store, redisCache, senderFactory) // Build handlers notifyH := handler.NewNotifyHandler(store, redisCache, matcher, renderer, router) sourceH := handler.NewSourceHandler(store, redisCache) templateH := handler.NewTemplateHandler(store, redisCache) channelH := handler.NewChannelHandler(store, redisCache) ruleH := handler.NewRuleHandler(store, redisCache) msgLogH := handler.NewMessageLogHandler(store) // Setup Gin gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) // Health check r.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) api := r.Group("/api/v1") // Notify endpoint (source auth + rate limit) notifyGroup := api.Group("/notify") notifyGroup.Use(handler.SourceAuth(store)) if redisCache != nil { notifyGroup.Use(handler.RateLimit(redisCache, cfg.RateLimit.Default)) } notifyGroup.POST("", notifyH.Handle) // Management endpoints (admin auth) admin := api.Group("") admin.Use(handler.AdminAuth(cfg.Server.AdminKey)) // Sources admin.POST("/sources", sourceH.Create) admin.GET("/sources", sourceH.List) admin.GET("/sources/:id", sourceH.Get) admin.PUT("/sources/:id", sourceH.Update) admin.DELETE("/sources/:id", sourceH.Delete) // Templates admin.POST("/templates", templateH.Create) admin.GET("/templates", templateH.List) admin.GET("/templates/:id", templateH.Get) admin.PUT("/templates/:id", templateH.Update) admin.DELETE("/templates/:id", templateH.Delete) // Channels admin.POST("/channels", channelH.Create) admin.GET("/channels", channelH.List) admin.GET("/channels/:id", channelH.Get) admin.PUT("/channels/:id", channelH.Update) admin.DELETE("/channels/:id", channelH.Delete) // Rules admin.POST("/rules", ruleH.Create) admin.GET("/rules", ruleH.List) admin.GET("/rules/:id", ruleH.Get) admin.PUT("/rules/:id", ruleH.Update) admin.DELETE("/rules/:id", ruleH.Delete) admin.PATCH("/rules/:id/enable", ruleH.Enable) admin.PATCH("/rules/:id/disable", ruleH.Disable) admin.PATCH("/rules/:id/channels/:channel_id/enable", ruleH.EnableChannel) admin.PATCH("/rules/:id/channels/:channel_id/disable", ruleH.DisableChannel) // Message Logs admin.GET("/message-logs", msgLogH.List) // Start server srv := &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Server.Port), Handler: r, } go func() { slog.Info("server starting", "port", cfg.Server.Port) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("server error", "error", err) os.Exit(1) } }() // Graceful shutdown quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit slog.Info("shutting down...") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { slog.Error("forced shutdown", "error", err) } slog.Info("server stopped") } ``` - [ ] **Step 2: Fix imports — add adapter import to main.go** Add to imports: ```go import ( "aiaa-notification-service/internal/adapter" ) ``` - [ ] **Step 3: Verify full build** ```bash go mod tidy go build ./cmd/server/... ``` Expected: builds successfully. Fix any import or type issues. - [ ] **Step 4: Commit** ```bash git add -A && git commit -m "feat: main assembly — wire all components, gin router, graceful shutdown" ``` --- ### Task 18: Docker & Docker Compose **Files:** - Create: `Dockerfile` - Create: `docker-compose.yml` - [ ] **Step 1: Write Dockerfile** File: `Dockerfile` ```dockerfile FROM golang:1.22-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server FROM alpine:3.20 RUN apk add --no-cache ca-certificates tzdata COPY --from=builder /server /usr/local/bin/server COPY config/config.yaml /etc/notification/config.yaml EXPOSE 8080 ENTRYPOINT ["server"] ``` - [ ] **Step 2: Write docker-compose.yml** File: `docker-compose.yml` ```yaml version: '3.8' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: notification MYSQL_USER: notify MYSQL_PASSWORD: notify ports: - "3306:3306" volumes: - mysql_data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 5s timeout: 3s retries: 10 redis: image: redis:7-alpine ports: - "6379:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5 api: build: . ports: - "8080:8080" environment: - DB_PASSWORD=notify - SMTP_PASSWORD= depends_on: mysql: condition: service_healthy redis: condition: service_healthy restart: unless-stopped volumes: mysql_data: ``` - [ ] **Step 3: Update config for Docker** The `config/config.yaml` should use Docker service names for hosts: ```yaml database: host: "mysql" port: 3306 # ... redis: host: "redis" port: 6379 # ... ``` Leave the existing config.yaml as-is (for local dev) and the docker-compose env vars will override as needed. - [ ] **Step 4: Commit** ```bash git add -A && git commit -m "feat: docker multi-stage build and docker-compose" ``` --- ## Final Verification After all tasks complete, run the full test suite and build: ```bash # Tests go test ./internal/... -v # Build go build -o bin/server ./cmd/server # Start with docker-compose docker-compose up -d # Run migrations (inside container or locally) # Then test with curl: # Create a source curl -X POST http://localhost:8080/api/v1/sources \ -H "Authorization: Bearer admin-sk-change-me" \ -H "Content-Type: application/json" \ -d '{"name":"test-system","parse_mode":"json"}' # Create a template curl -X POST http://localhost:8080/api/v1/templates \ -H "Authorization: Bearer admin-sk-change-me" \ -H "Content-Type: application/json" \ -d '{"name":"test_tmpl","content":"🚀 {{.symbol}} {{.price}}"}' # Create a channel curl -X POST http://localhost:8080/api/v1/channels \ -H "Authorization: Bearer admin-sk-change-me" \ -H "Content-Type: application/json" \ -d '{"name":"test-bark","type":"bark","config":{"url":"https://api.day.app/test"}}' # Create a rule curl -X POST http://localhost:8080/api/v1/rules \ -H "Authorization: Bearer admin-sk-change-me" \ -H "Content-Type: application/json" \ -d '{"source_name":"test-system","event":"trade.open","template_name":"test_tmpl","channels":["test-bark"]}' # Send a notification (use the api_key from source creation response) curl -X POST http://localhost:8080/api/v1/notify \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"event":"trade.open","data":{"symbol":"BTC","price":65000}}' ``` ---