Async recognition pipeline:
- POST /ai/recognize-dish → 202 {job_id, queue_position, estimated_seconds}
- GET /ai/jobs/{id}/stream — SSE stream: queued → processing → done/failed
- Kafka topics: ai.recognize.paid (3 partitions) + ai.recognize.free (1 partition)
- 5-worker WorkerPool with priority loop (paid consumers first)
- SSEBroker via PostgreSQL LISTEN/NOTIFY
- Kafka adapter migrated from franz-go to Watermill (watermill-kafka/v2)
- Docker Compose: added Kafka + Zookeeper + kafka-init service
- Flutter: recognition_service.dart uses SSE; home_screen shows live job status
Remove google/wire (archived):
- Deleted wire.go (wireinject spec) and wire_gen.go
- Added cmd/server/init.go — plain Go manual DI, same initApp() logic
- Removed github.com/google/wire from go.mod
Consolidate migrations:
- Merged 001_initial_schema + 002_seed_data + 003_recognition_jobs into single 001_initial_schema.sql
- Deleted 002_seed_data.sql and 003_recognition_jobs.sql
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
224 lines
7.8 KiB
Go
224 lines
7.8 KiB
Go
package main
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/food-ai/backend/internal/domain/auth"
|
|
"github.com/food-ai/backend/internal/infra/config"
|
|
"github.com/food-ai/backend/internal/domain/diary"
|
|
"github.com/food-ai/backend/internal/domain/dish"
|
|
"github.com/food-ai/backend/internal/adapters/kafka"
|
|
"github.com/food-ai/backend/internal/adapters/openai"
|
|
"github.com/food-ai/backend/internal/domain/home"
|
|
"github.com/food-ai/backend/internal/domain/ingredient"
|
|
"github.com/food-ai/backend/internal/domain/menu"
|
|
"github.com/food-ai/backend/internal/infra/middleware"
|
|
"github.com/food-ai/backend/internal/adapters/pexels"
|
|
"github.com/food-ai/backend/internal/domain/product"
|
|
"github.com/food-ai/backend/internal/domain/recipe"
|
|
"github.com/food-ai/backend/internal/domain/recognition"
|
|
"github.com/food-ai/backend/internal/domain/recommendation"
|
|
"github.com/food-ai/backend/internal/domain/savedrecipe"
|
|
"github.com/food-ai/backend/internal/domain/cuisine"
|
|
"github.com/food-ai/backend/internal/infra/server"
|
|
"github.com/food-ai/backend/internal/domain/tag"
|
|
"github.com/food-ai/backend/internal/domain/units"
|
|
"github.com/food-ai/backend/internal/domain/user"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Newtypes for config primitives — prevents Wire type collisions.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Newtypes for list handlers — prevents Wire type collisions on http.HandlerFunc.
|
|
type unitsListHandler http.HandlerFunc
|
|
type cuisineListHandler http.HandlerFunc
|
|
type tagListHandler http.HandlerFunc
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type openaiAPIKey string
|
|
type pexelsAPIKey string
|
|
type jwtSecret string
|
|
type jwtAccessDuration time.Duration
|
|
type jwtRefreshDuration time.Duration
|
|
type allowedOrigins []string
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config extractors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func newOpenAIAPIKey(appConfig *config.Config) openaiAPIKey {
|
|
return openaiAPIKey(appConfig.OpenAIAPIKey)
|
|
}
|
|
|
|
func newPexelsAPIKey(appConfig *config.Config) pexelsAPIKey {
|
|
return pexelsAPIKey(appConfig.PexelsAPIKey)
|
|
}
|
|
|
|
func newJWTSecret(appConfig *config.Config) jwtSecret {
|
|
return jwtSecret(appConfig.JWTSecret)
|
|
}
|
|
|
|
func newJWTAccessDuration(appConfig *config.Config) jwtAccessDuration {
|
|
return jwtAccessDuration(appConfig.JWTAccessDuration)
|
|
}
|
|
|
|
func newJWTRefreshDuration(appConfig *config.Config) jwtRefreshDuration {
|
|
return jwtRefreshDuration(appConfig.JWTRefreshDuration)
|
|
}
|
|
|
|
func newAllowedOrigins(appConfig *config.Config) allowedOrigins {
|
|
return allowedOrigins(appConfig.AllowedOrigins)
|
|
}
|
|
|
|
// newFirebaseCredentialsFile is the only string that reaches NewFirebaseAuthOrNoop,
|
|
// so no newtype is needed here.
|
|
func newFirebaseCredentialsFile(appConfig *config.Config) string {
|
|
return appConfig.FirebaseCredentialsFile
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constructor wrappers for functions that accept primitive newtypes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func newOpenAIClient(key openaiAPIKey) *openai.Client {
|
|
return openai.NewClient(string(key))
|
|
}
|
|
|
|
func newPexelsClient(key pexelsAPIKey) *pexels.Client {
|
|
return pexels.NewClient(string(key))
|
|
}
|
|
|
|
func newJWTManager(
|
|
secret jwtSecret,
|
|
accessDuration jwtAccessDuration,
|
|
refreshDuration jwtRefreshDuration,
|
|
) *auth.JWTManager {
|
|
return auth.NewJWTManager(string(secret), time.Duration(accessDuration), time.Duration(refreshDuration))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// List handler providers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func newUnitsListHandler(pool *pgxpool.Pool) unitsListHandler {
|
|
return unitsListHandler(units.NewListHandler(pool))
|
|
}
|
|
|
|
func newCuisineListHandler(pool *pgxpool.Pool) cuisineListHandler {
|
|
return cuisineListHandler(cuisine.NewListHandler(pool))
|
|
}
|
|
|
|
func newTagListHandler(pool *pgxpool.Pool) tagListHandler {
|
|
return tagListHandler(tag.NewListHandler(pool))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// newRouter wraps server.NewRouter to accept newtypes.
|
|
func newRouter(
|
|
pool *pgxpool.Pool,
|
|
authHandler *auth.Handler,
|
|
userHandler *user.Handler,
|
|
recommendationHandler *recommendation.Handler,
|
|
savedRecipeHandler *savedrecipe.Handler,
|
|
ingredientHandler *ingredient.Handler,
|
|
productHandler *product.Handler,
|
|
recognitionHandler *recognition.Handler,
|
|
menuHandler *menu.Handler,
|
|
diaryHandler *diary.Handler,
|
|
homeHandler *home.Handler,
|
|
dishHandler *dish.Handler,
|
|
recipeHandler *recipe.Handler,
|
|
authMiddleware func(http.Handler) http.Handler,
|
|
origins allowedOrigins,
|
|
unitsHandler unitsListHandler,
|
|
cuisineHandler cuisineListHandler,
|
|
tagHandler tagListHandler,
|
|
) http.Handler {
|
|
return server.NewRouter(
|
|
pool,
|
|
authHandler,
|
|
userHandler,
|
|
recommendationHandler,
|
|
savedRecipeHandler,
|
|
ingredientHandler,
|
|
productHandler,
|
|
recognitionHandler,
|
|
menuHandler,
|
|
diaryHandler,
|
|
homeHandler,
|
|
dishHandler,
|
|
recipeHandler,
|
|
authMiddleware,
|
|
[]string(origins),
|
|
http.HandlerFunc(unitsHandler),
|
|
http.HandlerFunc(cuisineHandler),
|
|
http.HandlerFunc(tagHandler),
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// jwtAdapter — adapts *auth.JWTManager to middleware.AccessTokenValidator.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type jwtAdapter struct {
|
|
jwtManager *auth.JWTManager
|
|
}
|
|
|
|
func newJWTAdapter(jm *auth.JWTManager) *jwtAdapter {
|
|
return &jwtAdapter{jwtManager: jm}
|
|
}
|
|
|
|
func (adapter *jwtAdapter) ValidateAccessToken(tokenStr string) (*middleware.TokenClaims, error) {
|
|
claims, validationError := adapter.jwtManager.ValidateAccessToken(tokenStr)
|
|
if validationError != nil {
|
|
return nil, validationError
|
|
}
|
|
return &middleware.TokenClaims{
|
|
UserID: claims.UserID,
|
|
Plan: claims.Plan,
|
|
}, nil
|
|
}
|
|
|
|
// newAuthMiddleware wraps middleware.Auth for Wire injection.
|
|
func newAuthMiddleware(validator middleware.AccessTokenValidator) func(http.Handler) http.Handler {
|
|
return middleware.Auth(validator)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Kafka providers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func newKafkaProducer(appConfig *config.Config) (*kafka.Producer, error) {
|
|
return kafka.NewProducer(appConfig.KafkaBrokers)
|
|
}
|
|
|
|
func newPaidKafkaConsumer(appConfig *config.Config) (*kafka.Consumer, error) {
|
|
return kafka.NewConsumer(appConfig.KafkaBrokers, "dish-recognition-workers", recognition.TopicPaid)
|
|
}
|
|
|
|
func newFreeKafkaConsumer(appConfig *config.Config) (*kafka.Consumer, error) {
|
|
return kafka.NewConsumer(appConfig.KafkaBrokers, "dish-recognition-workers", recognition.TopicFree)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Interface assertions (compile-time checks)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
var _ middleware.AccessTokenValidator = (*jwtAdapter)(nil)
|
|
var _ menu.PhotoSearcher = (*pexels.Client)(nil)
|
|
var _ menu.UserLoader = (*user.Repository)(nil)
|
|
var _ menu.ProductLister = (*product.Repository)(nil)
|
|
var _ menu.RecipeSaver = (*dish.Repository)(nil)
|
|
var _ recommendation.PhotoSearcher = (*pexels.Client)(nil)
|
|
var _ recommendation.UserLoader = (*user.Repository)(nil)
|
|
var _ recommendation.ProductLister = (*product.Repository)(nil)
|
|
var _ recognition.IngredientRepository = (*ingredient.Repository)(nil)
|
|
var _ recognition.KafkaPublisher = (*kafka.Producer)(nil)
|
|
var _ recognition.JobRepository = (*recognition.PostgresJobRepository)(nil)
|
|
var _ user.UserRepository = (*user.Repository)(nil)
|