diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..7526cfb --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,87 @@ +package api + +import ( + "database/sql" + "encoding/json" + "net/http" + + "floorvisualizer/internal/handler" + "floorvisualizer/internal/middleware" + "floorvisualizer/internal/model" + "floorvisualizer/internal/openrouter" + "floorvisualizer/internal/service" +) + +// RegisterRoutes sets up all HTTP routes with injected dependencies. +func RegisterRoutes(mux *http.ServeMux, client *openrouter.Client, db *sql.DB, authSvc *service.AuthService) { + auth := middleware.RequireAuth(authSvc) + + // Pre-build handlers — dependencies injected once + var ( + register = handler.Register(authSvc, db) + login = handler.Login(authSvc, db) + me = auth(handler.Me(db)) + uploadAvatar = auth(handler.UploadAvatar(db)) + favorites = auth(handler.Favorites(db)) + favoritesSub = auth(handler.Favorites(db)) + projects = auth(handler.Projects(db)) + projectsSub = auth(handler.Projects(db)) + frequentBrands = auth(handler.FrequentBrands(db)) + products = handler.Products(db) + productsSub = handler.Products(db) + productOpts = handler.ProductOptions(db) + filterOpts = handler.FilterOptions(db) + recommend = handler.Recommend(db) + floorGenerate = handler.FloorGenerate(client, db) + floorOptions = handler.FloorOptions(db) + articleRecommend = handler.ArticleRecommend(db) + articles = handler.Articles(db) + articlesSub = handler.Articles(db) + ) + + // Route mapping + mux.HandleFunc("/auth/register", register) + mux.HandleFunc("/auth/login", login) + mux.HandleFunc("/user/me", me) + mux.HandleFunc("/user/avatar", uploadAvatar) + mux.HandleFunc("/user/favorites", favorites) + mux.HandleFunc("/user/favorites/", favoritesSub) + mux.HandleFunc("/user/projects", projects) + mux.HandleFunc("/user/projects/", projectsSub) + mux.HandleFunc("/user/frequent-brands", frequentBrands) + mux.HandleFunc("/healthz", handler.Healthz) + mux.HandleFunc("/upload", handler.Upload) + mux.HandleFunc("/products", products) + mux.HandleFunc("/products/", productsSub) + mux.HandleFunc("/product-options", productOpts) + mux.HandleFunc("/filter-options", filterOpts) + mux.HandleFunc("/recommend/api", recommend) + mux.HandleFunc("/calculator/calc", handler.Calc) + mux.HandleFunc("/floor/generate", floorGenerate) + mux.HandleFunc("/floor/status", handler.FloorStatus) + mux.HandleFunc("/floor/options", floorOptions) + mux.HandleFunc("/articles/recommend", articleRecommend) + mux.HandleFunc("/articles", articles) + mux.HandleFunc("/articles/", articlesSub) + + // Static files + mux.Handle("/outputs/", http.StripPrefix("/outputs/", http.FileServer(http.Dir("./outputs")))) + mux.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir("./uploads")))) + mux.Handle("/knowledge_images/", http.StripPrefix("/knowledge_images/", http.FileServer(http.Dir("./knowledge_images")))) + mux.Handle("/images/knowledge/", http.StripPrefix("/images/knowledge/", http.FileServer(http.Dir("./knowledge_images")))) +} + +// ── Response helpers ────────────────────────────────────── + +// JSON writes a success response with the API envelope. +func JSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(model.ApiResponse{Code: status, Data: v}) +} + +func Error(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(model.ApiError{Code: status, Error: msg}) +} diff --git a/internal/handler/api.go b/internal/handler/api.go new file mode 100644 index 0000000..a084361 --- /dev/null +++ b/internal/handler/api.go @@ -0,0 +1,15 @@ +package handler + +import ( + "database/sql" + + "floorvisualizer/internal/openrouter" + "floorvisualizer/internal/service" +) + +// API holds all handler dependencies, injected once at startup. +type API struct { + DB *sql.DB + Client *openrouter.Client + AuthSvc *service.AuthService +} diff --git a/internal/handler/article_handler.go b/internal/handler/article_handler.go index b7ad49f..8e4f6a7 100644 --- a/internal/handler/article_handler.go +++ b/internal/handler/article_handler.go @@ -9,7 +9,7 @@ import ( "floorvisualizer/internal/repository" ) -func handleArticles(db *sql.DB) http.HandlerFunc { +func Articles(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // /articles/{id} — detail idStr := strings.TrimPrefix(r.URL.Path, "/articles/") @@ -56,7 +56,7 @@ func handleArticles(db *sql.DB) http.HandlerFunc { } } -func handleArticleRecommend(db *sql.DB) http.HandlerFunc { +func ArticleRecommend(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { count := 6 if c, err := strconv.Atoi(r.URL.Query().Get("count")); err == nil && c > 0 && c <= 20 { diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go index d94bd6d..fc92ae9 100644 --- a/internal/handler/auth_handler.go +++ b/internal/handler/auth_handler.go @@ -8,7 +8,7 @@ import ( "floorvisualizer/internal/service" ) -func handleRegister(authSvc *service.AuthService, db *sql.DB) http.HandlerFunc { +func Register(authSvc *service.AuthService, db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeErr(w, 405, "Method not allowed") @@ -28,7 +28,7 @@ func handleRegister(authSvc *service.AuthService, db *sql.DB) http.HandlerFunc { } } -func handleLogin(authSvc *service.AuthService, db *sql.DB) http.HandlerFunc { +func Login(authSvc *service.AuthService, db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeErr(w, 405, "Method not allowed") diff --git a/internal/handler/calc_handler.go b/internal/handler/calc_handler.go index 476b595..f974f16 100644 --- a/internal/handler/calc_handler.go +++ b/internal/handler/calc_handler.go @@ -4,68 +4,10 @@ import ( "encoding/json" "math" "net/http" + + "floorvisualizer/internal/model" ) -// ── Calculator models ─────────────────────────────────────── - -type CalcRoomInput struct { - Name string `json:"name"` - Length float64 `json:"length"` - Width float64 `json:"width"` - Deductions []CalcDeduction `json:"deductions"` -} - -type CalcDeduction struct { - Name string `json:"name"` - Shape string `json:"shape"` // rectangle, circle, custom - Length float64 `json:"length"` - Width float64 `json:"width"` - Radius float64 `json:"radius"` - Area float64 `json:"area"` // custom area override -} - -type CalcInput struct { - FloorType string `json:"floor_type"` // wood, tile - Rooms []CalcRoomInput `json:"rooms"` - PatternIndex int `json:"pattern_index"` - PieceLength float64 `json:"piece_length"` - PieceWidth float64 `json:"piece_width"` - PiecesPerBox int `json:"pieces_per_box"` - PricePerSqm float64 `json:"price_per_sqm"` - JointWidth float64 `json:"joint_width"` -} - -type CalcRoomResult struct { - Name string `json:"name"` - Length float64 `json:"length"` - Width float64 `json:"width"` - Area float64 `json:"area"` - Deductions []CalcDeduction `json:"deductions"` - DeductionTotal float64 `json:"deduction_total"` - NetArea float64 `json:"net_area"` -} - -type CalcResult struct { - FloorType string `json:"floor_type"` - Rooms []CalcRoomResult `json:"rooms"` - TotalRoomArea float64 `json:"total_room_area"` - TotalDeduct float64 `json:"total_deduct"` - TotalNetArea float64 `json:"total_net_area"` - WasteRate float64 `json:"waste_rate"` - WasteArea float64 `json:"waste_area"` - TotalArea float64 `json:"total_area"` - PiecesNeeded int `json:"pieces_needed"` - BoxesNeeded *float64 `json:"boxes_needed,omitempty"` - JointWidth float64 `json:"joint_width"` - GroutKg *float64 `json:"grout_kg,omitempty"` - GroutBags *int `json:"grout_bags,omitempty"` - PricePerSqm float64 `json:"price_per_sqm"` - TotalPrice *float64 `json:"total_price,omitempty"` - PatternName string `json:"pattern_name"` - PieceLength float64 `json:"piece_length"` - PieceWidth float64 `json:"piece_width"` -} - var woodPatternsCalc = []struct { Name string Waste float64 @@ -90,13 +32,13 @@ var tilePatternsCalc = []struct { // ── Handler ───────────────────────────────────────────────── -func handleCalc(w http.ResponseWriter, r *http.Request) { +func Calc(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeErr(w, 405, "Method not allowed") return } - var input CalcInput + var input model.CalcInput if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeJSON(w, 400, map[string]string{"error": "invalid JSON: " + err.Error()}) return @@ -117,7 +59,7 @@ func handleCalc(w http.ResponseWriter, r *http.Request) { // ── Calculation ───────────────────────────────────────────── -func calculate(input CalcInput) CalcResult { +func calculate(input model.CalcInput) model.CalcResult { patterns := woodPatternsCalc if input.FloorType == "tile" { patterns = tilePatternsCalc @@ -127,14 +69,14 @@ func calculate(input CalcInput) CalcResult { } wasteRate := patterns[input.PatternIndex].Waste - var roomResults []CalcRoomResult + var roomResults []model.CalcRoomResult totalRoomArea := 0.0 totalDeduct := 0.0 for _, room := range input.Rooms { area := room.Length * room.Width deductTotal := 0.0 - var dedResults []CalcDeduction + var dedResults []model.CalcDeduction for _, d := range room.Deductions { dedArea := deductionArea(d) @@ -144,9 +86,11 @@ func calculate(input CalcInput) CalcResult { } netArea := area - deductTotal - if netArea < 0 { netArea = 0 } + if netArea < 0 { + netArea = 0 + } - roomResults = append(roomResults, CalcRoomResult{ + roomResults = append(roomResults, model.CalcRoomResult{ Name: room.Name, Length: room.Length, Width: room.Width, Area: round2(area), Deductions: dedResults, DeductionTotal: round2(deductTotal), NetArea: round2(netArea), @@ -156,7 +100,9 @@ func calculate(input CalcInput) CalcResult { } totalNetArea := totalRoomArea - totalDeduct - if totalNetArea < 0 { totalNetArea = 0 } + if totalNetArea < 0 { + totalNetArea = 0 + } // Adjust piece for joint effLength := input.PieceLength @@ -167,14 +113,16 @@ func calculate(input CalcInput) CalcResult { } pieceArea := effLength * effWidth - if pieceArea <= 0 { pieceArea = 1 } + if pieceArea <= 0 { + pieceArea = 1 + } totalPieces := totalNetArea / pieceArea wasteArea := totalNetArea * wasteRate totalArea := totalNetArea + wasteArea piecesNeeded := int(math.Ceil(totalArea / pieceArea)) - result := CalcResult{ + result := model.CalcResult{ FloorType: input.FloorType, Rooms: roomResults, TotalRoomArea: round2(totalRoomArea), @@ -214,7 +162,7 @@ func calculate(input CalcInput) CalcResult { return result } -func deductionArea(d CalcDeduction) float64 { +func deductionArea(d model.CalcDeduction) float64 { switch d.Shape { case "circle": return math.Pi * d.Radius * d.Radius diff --git a/internal/handler/floor_handler.go b/internal/handler/floor_handler.go index fd16302..9ada160 100644 --- a/internal/handler/floor_handler.go +++ b/internal/handler/floor_handler.go @@ -131,13 +131,19 @@ func findFloorBySKU(db *sql.DB, sku string) *FloorOption { func FindFloorBySKUPublic(db *sql.DB, sku string) *FloorOption { return findFloorBySKU(db, sku) } func FindPatternByCodePublic(code string) *PatternOption { - c := 0; fmt.Sscanf(code, "%d", &c); return findPatternByCode(c) + c := 0 + fmt.Sscanf(code, "%d", &c) + return findPatternByCode(c) } func FindRoomByCodePublic(code string) *RoomOption { - c := 0; fmt.Sscanf(code, "%d", &c); return findRoomByCode(c) + c := 0 + fmt.Sscanf(code, "%d", &c) + return findRoomByCode(c) +} +func BuildMaskPromptPublic(room *RoomOption) string { return buildMaskPrompt(room) } +func BuildFloorPromptPublic(f FloorOption, p PatternOption, room *RoomOption) string { + return buildFloorPrompt(f, p, room) } -func BuildMaskPromptPublic(room *RoomOption) string { return buildMaskPrompt(room) } -func BuildFloorPromptPublic(f FloorOption, p PatternOption, room *RoomOption) string { return buildFloorPrompt(f, p, room) } // ── Floor Generate Handler (Redis queue) ────────────────── @@ -162,7 +168,7 @@ type JobStatusResp struct { Error string `json:"error,omitempty"` } -func handleFloorGenerate(client *openrouter.Client, db *sql.DB) http.HandlerFunc { +func FloorGenerate(client *openrouter.Client, db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeErr(w, 405, "Method not allowed") @@ -223,11 +229,12 @@ func handleFloorGenerate(client *openrouter.Client, db *sql.DB) http.HandlerFunc writeJSON(w, 200, map[string]string{"job_id": jobID}) } } + // ── Progress ─────────────────────────────────────────────── // ── Floor Status Query ───────────────────────────────── -func handleFloorStatus(w http.ResponseWriter, r *http.Request) { +func FloorStatus(w http.ResponseWriter, r *http.Request) { jobID := r.URL.Query().Get("job_id") if jobID == "" { writeErr(w, 400, "job_id is required") @@ -244,11 +251,12 @@ func handleFloorStatus(w http.ResponseWriter, r *http.Request) { } writeJSON(w, 200, js) } + // ── Options ──────────────────────────────────────────────── -func handleFloorOptions(db *sql.DB) http.HandlerFunc { +func FloorOptions(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - woodFloors, _ := loadFloorOptions(db, "Hardwood") + woodFloors, _ := loadFloorOptions(db, "Hardwood") // non-critical: returns empty on error woodFloors2, _ := loadFloorOptions(db, "Engineered Wood") tileFloors, _ := loadFloorOptions(db, "Wood-Look Tile") vinylFloors, _ := loadFloorOptions(db, "SPC/LVP") @@ -308,34 +316,6 @@ Style: photorealistic, soft natural matte finish, minimal reflections, no mirror // ── Content Check ───────────────────────────────────────── -func handleCheckContent(client *openrouter.Client) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeErr(w, 405, "Method not allowed") - return - } - if err := r.ParseMultipartForm(20 << 20); err != nil { - writeJSON(w, 400, map[string]string{"error": err.Error()}) - return - } - file, _, _ := r.FormFile("image") - defer file.Close() - os.MkdirAll("uploads", 0755) - tmpPath := filepath.Join("uploads", fmt.Sprintf("check_%d.png", time.Now().UnixNano())) - dst, _ := os.Create(tmpPath) - io.Copy(dst, file) - dst.Close() - ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) - defer cancel() - passed, score := validateContent(client, ctx, tmpPath) - msg := fmt.Sprintf("✅ Indoor photo score: %d/10", score) - if !passed { - msg = fmt.Sprintf("Non-indoor photo score: %d/10", score) - } - writeJSON(w, 200, map[string]any{"passed": passed, "score": score, "message": msg}) - } -} - func validateContent(client *openrouter.Client, ctx context.Context, imagePath string) (bool, int) { imgData, err := os.ReadFile(imagePath) if err != nil { diff --git a/internal/handler/health_handler.go b/internal/handler/health_handler.go index d8607ae..c2caecc 100644 --- a/internal/handler/health_handler.go +++ b/internal/handler/health_handler.go @@ -4,7 +4,7 @@ import ( "net/http" ) -func handleHealthz(w http.ResponseWriter, r *http.Request) { +func Healthz(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]string{ "status": "ok", }) diff --git a/internal/handler/product_handler.go b/internal/handler/product_handler.go index 4fa6a87..25a4a42 100644 --- a/internal/handler/product_handler.go +++ b/internal/handler/product_handler.go @@ -31,11 +31,11 @@ type ProductItem struct { } type ProductListResponse struct { - Products []ProductItem `json:"products"` - Total int `json:"total"` - Page int `json:"page"` - Limit int `json:"limit"` - TotalPages int `json:"total_pages"` + Products []ProductItem `json:"products"` + Total int `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` FilterOptions *repository.FilterOptions `json:"filter_options"` } @@ -53,7 +53,7 @@ func SetProductsCache( productsTrackView = tracker } -func handleProducts(db *sql.DB) http.HandlerFunc { +func Products(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Detail: /products/{sku} sku := strings.TrimPrefix(r.URL.Path, "/products/") @@ -69,8 +69,7 @@ func handleProducts(db *sql.DB) http.HandlerFunc { } } if product == nil { - var err error - product, err = repository.GetProductBySKU(db, sku) + product, err := repository.GetProductBySKU(db, sku) if err != nil { writeJSON(w, 404, map[string]string{"error": "product not found"}) return @@ -80,12 +79,13 @@ func handleProducts(db *sql.DB) http.HandlerFunc { } } // Attach variants (same style_name, different sizes) - if variants, _ := repository.GetVariantsByStyle(db, sku); len(variants) > 0 { + variants, _ := repository.GetVariantsByStyle(db, sku) + if len(variants) > 0 { product.Variants = variants } // Check favorite status and build specs userID := middleware.UserID(r.Context()) - favMap, _ := repository.GetFavoritedSKUs(db, userID, []string{sku}) + favMap, _ := repository.GetFavoritedSKUs(db, userID, []string{sku}) // ok to ignore err: non-critical product.IsFavorited = favMap[sku] // Build specs: main product + variants as size options allVariants := append([]model.Product{*product}, product.Variants...) @@ -140,7 +140,7 @@ func handleProducts(db *sql.DB) http.HandlerFunc { skus[i] = p.SKU } userID := middleware.UserID(r.Context()) - favSKUs, _ := repository.GetFavoritedSKUs(db, userID, skus) + favSKUs, _ := repository.GetFavoritedSKUs(db, userID, skus) // ok to ignore err: non-critical specCounts := repository.GetSpecCounts(db, skus) items := make([]ProductItem, 0, len(prods)) @@ -152,8 +152,8 @@ func handleProducts(db *sql.DB) http.HandlerFunc { PricePerSqft: p.PricePerSqft, PriceTier: p.PriceTier, MainImageURL: p.MainImageURL, SizeLabel: p.SizeLabel, CoverageSqftPerBox: p.CoverageSqftPerBox, - IsFavorited: favSKUs[p.SKU], - SpecCount: specCounts[p.Brand+"|"+p.StyleName+"|"+p.SeriesName], + IsFavorited: favSKUs[p.SKU], + SpecCount: specCounts[p.Brand+"|"+p.StyleName+"|"+p.SeriesName], }) } @@ -173,7 +173,7 @@ func handleProducts(db *sql.DB) http.HandlerFunc { } } -func handleProductOptions(db *sql.DB) http.HandlerFunc { +func ProductOptions(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { opts, err := repository.GetFilterOptions(db) if err != nil { @@ -184,7 +184,7 @@ func handleProductOptions(db *sql.DB) http.HandlerFunc { } } -func handleFilterOptions(db *sql.DB) http.HandlerFunc { +func FilterOptions(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { filters, err := repository.GetFilterHierarchy(db) if err != nil { diff --git a/internal/handler/recommend_handler.go b/internal/handler/recommend_handler.go index 1c3f41d..68728eb 100644 --- a/internal/handler/recommend_handler.go +++ b/internal/handler/recommend_handler.go @@ -2,8 +2,8 @@ package handler import ( "database/sql" + "floorvisualizer/internal/logger" "fmt" - "log" "net/http" "floorvisualizer/internal/model" @@ -21,17 +21,19 @@ type RecommendResponse struct { var productService *service.ProductService func initProductService(db *sql.DB) { - if productService != nil { return } - products, err := repository.QueryAllProducts(db) - if err != nil { - log.Printf("Failed to load products: %v", err) + if productService != nil { return } - log.Printf("Loaded %d products for recommendation engine", len(products)) + products, err := repository.QueryAllProducts(db) + if err != nil { + logger.Warn("Failed to load products: %v", err) + return + } + logger.Info("Loaded %d products for recommendation engine", len(products)) productService = service.NewProductService(products, model.DefaultEngineConfig()) } -func handleRecommend(db *sql.DB) http.HandlerFunc { +func Recommend(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sku := r.URL.Query().Get("sku") if sku == "" { diff --git a/internal/handler/router.go b/internal/handler/router.go index 635f553..26b2aee 100644 --- a/internal/handler/router.go +++ b/internal/handler/router.go @@ -1,15 +1,11 @@ package handler import ( - "database/sql" "encoding/json" - "fmt" "net/http" "net/url" - "floorvisualizer/internal/middleware" - "floorvisualizer/internal/openrouter" - "floorvisualizer/internal/service" + "floorvisualizer/internal/model" ) func NewProxyClient(proxyURLStr string) *http.Client { @@ -20,57 +16,9 @@ func NewProxyClient(proxyURLStr string) *http.Client { return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(u)}} } -func RegisterAll(mux *http.ServeMux, client *openrouter.Client, db *sql.DB, authSvc *service.AuthService) { - requireAuth := middleware.RequireAuth(authSvc) - -// Auth - mux.HandleFunc("/auth/register", handleRegister(authSvc, db)) - mux.HandleFunc("/auth/login", handleLogin(authSvc, db)) - - // User (protected) - mux.HandleFunc("/user/me", requireAuth(handleMe(db))) - mux.HandleFunc("/user/avatar", requireAuth(handleUploadAvatar(db))) - mux.HandleFunc("/user/favorites", requireAuth(handleFavorites(db))) - mux.HandleFunc("/user/favorites/", requireAuth(handleFavorites(db))) - mux.HandleFunc("/user/projects", requireAuth(handleProjects(db))) - mux.HandleFunc("/user/projects/", requireAuth(handleProjects(db))) - mux.HandleFunc("/user/frequent-brands", requireAuth(handleFrequentBrands(db))) - - // Upload - mux.HandleFunc("/healthz", handleHealthz) - mux.HandleFunc("/upload", handleUpload) - - // Products - mux.HandleFunc("/products", handleProducts(db)) - mux.HandleFunc("/products/", handleProducts(db)) - mux.HandleFunc("/product-options", handleProductOptions(db)) - mux.HandleFunc("/filter-options", handleFilterOptions(db)) - - // Recommend - mux.HandleFunc("/recommend/api", handleRecommend(db)) - - // Calculator - mux.HandleFunc("/calculator/calc", handleCalc) - - // Floor AI - mux.HandleFunc("/floor/generate", handleFloorGenerate(client, db)) - mux.HandleFunc("/floor/status", handleFloorStatus) - mux.HandleFunc("/floor/options", handleFloorOptions(db)) - - // Articles (recommend before /articles/ to avoid path conflict) - mux.HandleFunc("/articles/recommend", handleArticleRecommend(db)) - mux.HandleFunc("/articles", handleArticles(db)) - mux.HandleFunc("/articles/", handleArticles(db)) - - // Static - mux.Handle("/outputs/", http.StripPrefix("/outputs/", http.FileServer(http.Dir("./outputs")))) - mux.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir("./uploads")))) - mux.Handle("/knowledge_images/", http.StripPrefix("/knowledge_images/", http.FileServer(http.Dir("./knowledge_images")))) - mux.Handle("/images/knowledge/", http.StripPrefix("/images/knowledge/", http.FileServer(http.Dir("./knowledge_images")))) -} - func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) if status >= 400 { msg := "Unknown error" switch x := v.(type) { @@ -80,19 +28,15 @@ func writeJSON(w http.ResponseWriter, status int, v any) { if e, ok := x["error"]; ok { msg = e } - case map[string]any: - if e, ok := x["error"]; ok { - msg = fmt.Sprint(e) - } } - w.WriteHeader(status) - json.NewEncoder(w).Encode(map[string]any{"code": status, "error": msg}) + json.NewEncoder(w).Encode(model.ApiError{Code: status, Error: msg}) return } - w.WriteHeader(status) - json.NewEncoder(w).Encode(map[string]any{"code": status, "data": v}) + json.NewEncoder(w).Encode(model.ApiResponse{Code: status, Data: v}) } func writeErr(w http.ResponseWriter, status int, msg string) { - writeJSON(w, status, msg) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(model.ApiError{Code: status, Error: msg}) } diff --git a/internal/handler/upload_handler.go b/internal/handler/upload_handler.go index 6fedb2d..1b0782e 100644 --- a/internal/handler/upload_handler.go +++ b/internal/handler/upload_handler.go @@ -12,7 +12,7 @@ import ( "time" ) -func handleUpload(w http.ResponseWriter, r *http.Request) { +func Upload(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeErr(w, 405, "Method not allowed") return diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index 6f51b39..ca45396 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -18,7 +18,7 @@ import ( "floorvisualizer/internal/repository" ) -func handleMe(db *sql.DB) http.HandlerFunc { +func Me(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID := middleware.UserID(r.Context()) switch r.Method { @@ -50,7 +50,7 @@ func handleMe(db *sql.DB) http.HandlerFunc { } } -func handleFavorites(db *sql.DB) http.HandlerFunc { +func Favorites(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID := middleware.UserID(r.Context()) switch r.Method { @@ -103,7 +103,7 @@ func handleFavorites(db *sql.DB) http.HandlerFunc { } } -func handleProjects(db *sql.DB) http.HandlerFunc { +func Projects(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID := middleware.UserID(r.Context()) projectID := strings.TrimPrefix(r.URL.Path, "/user/projects/") @@ -161,7 +161,9 @@ func handleSingleProject(w http.ResponseWriter, r *http.Request, db *sql.DB, use } writeJSON(w, 200, project) case http.MethodPut: - var input struct{ Name string `json:"name"` } + var input struct { + Name string `json:"name"` + } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeJSON(w, 400, map[string]string{"error": "invalid request body"}) return @@ -183,7 +185,7 @@ func handleSingleProject(w http.ResponseWriter, r *http.Request, db *sql.DB, use } } -func handleUploadAvatar(db *sql.DB) http.HandlerFunc { +func UploadAvatar(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID := middleware.UserID(r.Context()) if err := r.ParseMultipartForm(5 << 20); err != nil { @@ -233,10 +235,9 @@ func handleUploadAvatar(db *sql.DB) http.HandlerFunc { } } - // ── Brand view tracking ─────────────────────────────────── -func handleBrandView(db *sql.DB) http.HandlerFunc { +func BrandView(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeErr(w, 405, "Method not allowed") @@ -268,7 +269,7 @@ func handleBrandView(db *sql.DB) http.HandlerFunc { } } -func handleFrequentBrands(db *sql.DB) http.HandlerFunc { +func FrequentBrands(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID := middleware.UserID(r.Context()) if userID == "" { diff --git a/internal/model/calc.go b/internal/model/calc.go new file mode 100644 index 0000000..1f3068e --- /dev/null +++ b/internal/model/calc.go @@ -0,0 +1,63 @@ +package model + +// ── Calculator input ────────────────────────────────────── + +type CalcRoomInput struct { + Name string `json:"name"` + Length float64 `json:"length"` + Width float64 `json:"width"` + Deductions []CalcDeduction `json:"deductions"` +} + +type CalcDeduction struct { + Name string `json:"name"` + Shape string `json:"shape"` // rectangle, circle, custom + Length float64 `json:"length"` + Width float64 `json:"width"` + Radius float64 `json:"radius"` + Area float64 `json:"area"` +} + +type CalcInput struct { + FloorType string `json:"floor_type"` + Rooms []CalcRoomInput `json:"rooms"` + PatternIndex int `json:"pattern_index"` + PieceLength float64 `json:"piece_length"` + PieceWidth float64 `json:"piece_width"` + PiecesPerBox int `json:"pieces_per_box"` + PricePerSqm float64 `json:"price_per_sqm"` + JointWidth float64 `json:"joint_width"` +} + +// ── Calculator output ───────────────────────────────────── + +type CalcRoomResult struct { + Name string `json:"name"` + Length float64 `json:"length"` + Width float64 `json:"width"` + Area float64 `json:"area"` + Deductions []CalcDeduction `json:"deductions"` + DeductionTotal float64 `json:"deduction_total"` + NetArea float64 `json:"net_area"` +} + +type CalcResult struct { + FloorType string `json:"floor_type"` + Rooms []CalcRoomResult `json:"rooms"` + TotalRoomArea float64 `json:"total_room_area"` + TotalDeduct float64 `json:"total_deduct"` + TotalNetArea float64 `json:"total_net_area"` + WasteRate float64 `json:"waste_rate"` + WasteArea float64 `json:"waste_area"` + TotalArea float64 `json:"total_area"` + PiecesNeeded int `json:"pieces_needed"` + BoxesNeeded *float64 `json:"boxes_needed,omitempty"` + JointWidth float64 `json:"joint_width"` + GroutKg *float64 `json:"grout_kg,omitempty"` + GroutBags *int `json:"grout_bags,omitempty"` + PricePerSqm float64 `json:"price_per_sqm"` + TotalPrice *float64 `json:"total_price,omitempty"` + PatternName string `json:"pattern_name"` + PieceLength float64 `json:"piece_length"` + PieceWidth float64 `json:"piece_width"` +} diff --git a/internal/model/response.go b/internal/model/response.go new file mode 100644 index 0000000..301bf4c --- /dev/null +++ b/internal/model/response.go @@ -0,0 +1,30 @@ +package model + +// ── Common API response envelope ───────────────────────── + +// ApiResponse wraps all API responses. +type ApiResponse struct { + Code int `json:"code"` + Data any `json:"data,omitempty"` +} + +// ApiError wraps error responses. +type ApiError struct { + Code int `json:"code"` + Error string `json:"error"` +} + +// ── Simple response types ───────────────────────────────── + +type OKResponse struct { + OK string `json:"ok"` +} + +type JobResponse struct { + JobID string `json:"job_id"` +} + +type UploadResponse struct { + URL string `json:"url"` + Filename string `json:"filename"` +} diff --git a/internal/queue/worker.go b/internal/queue/worker.go index 1ab77d0..a36e21d 100644 --- a/internal/queue/worker.go +++ b/internal/queue/worker.go @@ -3,9 +3,8 @@ package queue import ( "context" "database/sql" - "fmt" - "io" "floorvisualizer/internal/logger" + "fmt" "os" "path/filepath" "time" @@ -78,7 +77,11 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien // Step 0: content check SetJobProgress(jobCtx, rdb, jobID, "check", "Checking content...") logger.Info("[%s] Content check", jobID) - if passed, _ := validateContent(client, jobCtx, inputPath); !passed { + passed, score := validateContent(client, jobCtx, inputPath) + if score == 0 { + logger.Warn("[%s] Content check failed (score=0)", jobID) + } + if !passed { SetJobError(jobCtx, rdb, jobID, "Content check failed: Please upload an indoor room photo") return } @@ -103,7 +106,7 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien logger.Info("[%s] Inpainting: %s + %s", jobID, floor.Name, pattern.Name) floorPrompt := handler.BuildFloorPromptPublic(*floor, *pattern, room) - result, err := client.GenerateImageToFile(jobCtx, openrouter.ImageGenerationRequest{ + _, err = client.GenerateImageToFile(jobCtx, openrouter.ImageGenerationRequest{ Prompt: floorPrompt, OutputPath: outputPath, InputImagePath: inputPath, MaskImagePath: maskPath, ImageSize: "2K", }) @@ -113,10 +116,13 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien } // Clean up intermediate files - os.Remove(inputPath) - os.Remove(maskPath) + if err := os.Remove(inputPath); err != nil { + logger.Warn("[%s] Failed to remove input: %v", jobID, err) + } + if err := os.Remove(maskPath); err != nil { + logger.Warn("[%s] Failed to remove mask: %v", jobID, err) + } - _ = result SetJobResult(jobCtx, rdb, jobID, "/"+filepath.ToSlash(outputPath)) logger.Info("[%s] Done", jobID) } @@ -181,6 +187,3 @@ func base64enc(data []byte) string { } return string(b) } - -// Make sure io is used (for any future imports) -var _ = io.Discard diff --git a/main.go b/main.go index bbb6476..a4d2a59 100644 --- a/main.go +++ b/main.go @@ -2,19 +2,20 @@ package main import ( "context" + "encoding/json" "log" + "math/rand" "net/http" "os" "os/signal" "strconv" "time" - "encoding/json" - "math/rand" + "floorvisualizer/internal/api" "floorvisualizer/internal/handler" "floorvisualizer/internal/logger" - "floorvisualizer/internal/model" "floorvisualizer/internal/middleware" + "floorvisualizer/internal/model" "floorvisualizer/internal/openrouter" "floorvisualizer/internal/queue" "floorvisualizer/internal/repository" @@ -23,6 +24,7 @@ import ( const defaultAPIKey = "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" const defaultJWTSecret = "floorviz-dev-secret-change-in-production" + var proxyURL = "http://127.0.0.1:7897" func init() { @@ -132,39 +134,38 @@ func main() { }() } - - // ── Hot product cache ──────────────────────────────── - handler.SetProductsCache( - // Getter: check Redis cache for hot products - func(ctx context.Context, sku string) (*model.Product, bool) { - val, err := rdb.Get(ctx, "product:"+sku).Result() - if err != nil { - return nil, false - } - var p model.Product - if json.Unmarshal([]byte(val), &p) == nil { - return &p, true - } + // ── Hot product cache ──────────────────────────────── + handler.SetProductsCache( + // Getter: check Redis cache for hot products + func(ctx context.Context, sku string) (*model.Product, bool) { + val, err := rdb.Get(ctx, "product:"+sku).Result() + if err != nil { return nil, false - }, - // Setter: only cache if this SKU is in the hot list (top 200) - func(ctx context.Context, sku string, product *model.Product) error { - rank, err := rdb.ZRevRank(ctx, "product:views", sku).Result() - if err != nil || rank >= 200 { - return nil // not hot enough, skip caching - } - data, _ := json.Marshal(product) - ttl := 1*time.Hour + time.Duration(rand.Intn(600))*time.Second // ±10min jitter - return rdb.Set(ctx, "product:"+sku, data, ttl).Err() - }, - // View tracker: increment view count in sorted set - func(ctx context.Context, sku string) { - rdb.ZIncrBy(ctx, "product:views", 1, sku) - }, - ) + } + var p model.Product + if json.Unmarshal([]byte(val), &p) == nil { + return &p, true + } + return nil, false + }, + // Setter: only cache if this SKU is in the hot list (top 200) + func(ctx context.Context, sku string, product *model.Product) error { + rank, err := rdb.ZRevRank(ctx, "product:views", sku).Result() + if err != nil || rank >= 200 { + return nil // not hot enough, skip caching + } + data, _ := json.Marshal(product) + ttl := 1*time.Hour + time.Duration(rand.Intn(600))*time.Second // ±10min jitter + return rdb.Set(ctx, "product:"+sku, data, ttl).Err() + }, + // View tracker: increment view count in sorted set + func(ctx context.Context, sku string) { + rdb.ZIncrBy(ctx, "product:views", 1, sku) + }, + ) mux := http.NewServeMux() - handler.RegisterAll(mux, client, db, authSvc) + api.RegisterRoutes(mux, client, db, authSvc) port := "8099" if p := os.Getenv("PORT"); p != "" {