代码分层 #1

Merged
dindang merged 2 commits from dev into main 2026-07-16 02:34:09 +00:00
16 changed files with 323 additions and 249 deletions

87
internal/api/router.go Normal file
View File

@ -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})
}

15
internal/handler/api.go Normal file
View File

@ -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
}

View File

@ -9,7 +9,7 @@ import (
"floorvisualizer/internal/repository" "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) { return func(w http.ResponseWriter, r *http.Request) {
// /articles/{id} — detail // /articles/{id} — detail
idStr := strings.TrimPrefix(r.URL.Path, "/articles/") 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) { return func(w http.ResponseWriter, r *http.Request) {
count := 6 count := 6
if c, err := strconv.Atoi(r.URL.Query().Get("count")); err == nil && c > 0 && c <= 20 { if c, err := strconv.Atoi(r.URL.Query().Get("count")); err == nil && c > 0 && c <= 20 {

View File

@ -8,7 +8,7 @@ import (
"floorvisualizer/internal/service" "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) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeErr(w, 405, "Method not allowed") 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) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeErr(w, 405, "Method not allowed") writeErr(w, 405, "Method not allowed")

View File

@ -4,68 +4,10 @@ import (
"encoding/json" "encoding/json"
"math" "math"
"net/http" "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 { var woodPatternsCalc = []struct {
Name string Name string
Waste float64 Waste float64
@ -90,13 +32,13 @@ var tilePatternsCalc = []struct {
// ── Handler ───────────────────────────────────────────────── // ── Handler ─────────────────────────────────────────────────
func handleCalc(w http.ResponseWriter, r *http.Request) { func Calc(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeErr(w, 405, "Method not allowed") writeErr(w, 405, "Method not allowed")
return return
} }
var input CalcInput var input model.CalcInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil { if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid JSON: " + err.Error()}) writeJSON(w, 400, map[string]string{"error": "invalid JSON: " + err.Error()})
return return
@ -117,7 +59,7 @@ func handleCalc(w http.ResponseWriter, r *http.Request) {
// ── Calculation ───────────────────────────────────────────── // ── Calculation ─────────────────────────────────────────────
func calculate(input CalcInput) CalcResult { func calculate(input model.CalcInput) model.CalcResult {
patterns := woodPatternsCalc patterns := woodPatternsCalc
if input.FloorType == "tile" { if input.FloorType == "tile" {
patterns = tilePatternsCalc patterns = tilePatternsCalc
@ -127,14 +69,14 @@ func calculate(input CalcInput) CalcResult {
} }
wasteRate := patterns[input.PatternIndex].Waste wasteRate := patterns[input.PatternIndex].Waste
var roomResults []CalcRoomResult var roomResults []model.CalcRoomResult
totalRoomArea := 0.0 totalRoomArea := 0.0
totalDeduct := 0.0 totalDeduct := 0.0
for _, room := range input.Rooms { for _, room := range input.Rooms {
area := room.Length * room.Width area := room.Length * room.Width
deductTotal := 0.0 deductTotal := 0.0
var dedResults []CalcDeduction var dedResults []model.CalcDeduction
for _, d := range room.Deductions { for _, d := range room.Deductions {
dedArea := deductionArea(d) dedArea := deductionArea(d)
@ -144,9 +86,11 @@ func calculate(input CalcInput) CalcResult {
} }
netArea := area - deductTotal 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, Name: room.Name, Length: room.Length, Width: room.Width,
Area: round2(area), Deductions: dedResults, Area: round2(area), Deductions: dedResults,
DeductionTotal: round2(deductTotal), NetArea: round2(netArea), DeductionTotal: round2(deductTotal), NetArea: round2(netArea),
@ -156,7 +100,9 @@ func calculate(input CalcInput) CalcResult {
} }
totalNetArea := totalRoomArea - totalDeduct totalNetArea := totalRoomArea - totalDeduct
if totalNetArea < 0 { totalNetArea = 0 } if totalNetArea < 0 {
totalNetArea = 0
}
// Adjust piece for joint // Adjust piece for joint
effLength := input.PieceLength effLength := input.PieceLength
@ -167,14 +113,16 @@ func calculate(input CalcInput) CalcResult {
} }
pieceArea := effLength * effWidth pieceArea := effLength * effWidth
if pieceArea <= 0 { pieceArea = 1 } if pieceArea <= 0 {
pieceArea = 1
}
totalPieces := totalNetArea / pieceArea totalPieces := totalNetArea / pieceArea
wasteArea := totalNetArea * wasteRate wasteArea := totalNetArea * wasteRate
totalArea := totalNetArea + wasteArea totalArea := totalNetArea + wasteArea
piecesNeeded := int(math.Ceil(totalArea / pieceArea)) piecesNeeded := int(math.Ceil(totalArea / pieceArea))
result := CalcResult{ result := model.CalcResult{
FloorType: input.FloorType, FloorType: input.FloorType,
Rooms: roomResults, Rooms: roomResults,
TotalRoomArea: round2(totalRoomArea), TotalRoomArea: round2(totalRoomArea),
@ -214,7 +162,7 @@ func calculate(input CalcInput) CalcResult {
return result return result
} }
func deductionArea(d CalcDeduction) float64 { func deductionArea(d model.CalcDeduction) float64 {
switch d.Shape { switch d.Shape {
case "circle": case "circle":
return math.Pi * d.Radius * d.Radius return math.Pi * d.Radius * d.Radius

View File

@ -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 FindFloorBySKUPublic(db *sql.DB, sku string) *FloorOption { return findFloorBySKU(db, sku) }
func FindPatternByCodePublic(code string) *PatternOption { 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 { 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) ────────────────── // ── Floor Generate Handler (Redis queue) ──────────────────
@ -162,7 +168,7 @@ type JobStatusResp struct {
Error string `json:"error,omitempty"` 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) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeErr(w, 405, "Method not allowed") 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}) writeJSON(w, 200, map[string]string{"job_id": jobID})
} }
} }
// ── Progress ─────────────────────────────────────────────── // ── Progress ───────────────────────────────────────────────
// ── Floor Status Query ───────────────────────────────── // ── 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") jobID := r.URL.Query().Get("job_id")
if jobID == "" { if jobID == "" {
writeErr(w, 400, "job_id is required") writeErr(w, 400, "job_id is required")
@ -244,11 +251,12 @@ func handleFloorStatus(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, 200, js) writeJSON(w, 200, js)
} }
// ── Options ──────────────────────────────────────────────── // ── Options ────────────────────────────────────────────────
func handleFloorOptions(db *sql.DB) http.HandlerFunc { func FloorOptions(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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") woodFloors2, _ := loadFloorOptions(db, "Engineered Wood")
tileFloors, _ := loadFloorOptions(db, "Wood-Look Tile") tileFloors, _ := loadFloorOptions(db, "Wood-Look Tile")
vinylFloors, _ := loadFloorOptions(db, "SPC/LVP") vinylFloors, _ := loadFloorOptions(db, "SPC/LVP")
@ -308,34 +316,6 @@ Style: photorealistic, soft natural matte finish, minimal reflections, no mirror
// ── Content Check ───────────────────────────────────────── // ── 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) { func validateContent(client *openrouter.Client, ctx context.Context, imagePath string) (bool, int) {
imgData, err := os.ReadFile(imagePath) imgData, err := os.ReadFile(imagePath)
if err != nil { if err != nil {

View File

@ -4,7 +4,7 @@ import (
"net/http" "net/http"
) )
func handleHealthz(w http.ResponseWriter, r *http.Request) { func Healthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]string{ writeJSON(w, 200, map[string]string{
"status": "ok", "status": "ok",
}) })

View File

@ -31,11 +31,11 @@ type ProductItem struct {
} }
type ProductListResponse struct { type ProductListResponse struct {
Products []ProductItem `json:"products"` Products []ProductItem `json:"products"`
Total int `json:"total"` Total int `json:"total"`
Page int `json:"page"` Page int `json:"page"`
Limit int `json:"limit"` Limit int `json:"limit"`
TotalPages int `json:"total_pages"` TotalPages int `json:"total_pages"`
FilterOptions *repository.FilterOptions `json:"filter_options"` FilterOptions *repository.FilterOptions `json:"filter_options"`
} }
@ -53,7 +53,7 @@ func SetProductsCache(
productsTrackView = tracker productsTrackView = tracker
} }
func handleProducts(db *sql.DB) http.HandlerFunc { func Products(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// Detail: /products/{sku} // Detail: /products/{sku}
sku := strings.TrimPrefix(r.URL.Path, "/products/") sku := strings.TrimPrefix(r.URL.Path, "/products/")
@ -69,8 +69,7 @@ func handleProducts(db *sql.DB) http.HandlerFunc {
} }
} }
if product == nil { if product == nil {
var err error product, err := repository.GetProductBySKU(db, sku)
product, err = repository.GetProductBySKU(db, sku)
if err != nil { if err != nil {
writeJSON(w, 404, map[string]string{"error": "product not found"}) writeJSON(w, 404, map[string]string{"error": "product not found"})
return return
@ -80,12 +79,13 @@ func handleProducts(db *sql.DB) http.HandlerFunc {
} }
} }
// Attach variants (same style_name, different sizes) // 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 product.Variants = variants
} }
// Check favorite status and build specs // Check favorite status and build specs
userID := middleware.UserID(r.Context()) 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] product.IsFavorited = favMap[sku]
// Build specs: main product + variants as size options // Build specs: main product + variants as size options
allVariants := append([]model.Product{*product}, product.Variants...) allVariants := append([]model.Product{*product}, product.Variants...)
@ -140,7 +140,7 @@ func handleProducts(db *sql.DB) http.HandlerFunc {
skus[i] = p.SKU skus[i] = p.SKU
} }
userID := middleware.UserID(r.Context()) 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) specCounts := repository.GetSpecCounts(db, skus)
items := make([]ProductItem, 0, len(prods)) items := make([]ProductItem, 0, len(prods))
@ -152,8 +152,8 @@ func handleProducts(db *sql.DB) http.HandlerFunc {
PricePerSqft: p.PricePerSqft, PriceTier: p.PriceTier, PricePerSqft: p.PricePerSqft, PriceTier: p.PriceTier,
MainImageURL: p.MainImageURL, SizeLabel: p.SizeLabel, MainImageURL: p.MainImageURL, SizeLabel: p.SizeLabel,
CoverageSqftPerBox: p.CoverageSqftPerBox, CoverageSqftPerBox: p.CoverageSqftPerBox,
IsFavorited: favSKUs[p.SKU], IsFavorited: favSKUs[p.SKU],
SpecCount: specCounts[p.Brand+"|"+p.StyleName+"|"+p.SeriesName], 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) { return func(w http.ResponseWriter, r *http.Request) {
opts, err := repository.GetFilterOptions(db) opts, err := repository.GetFilterOptions(db)
if err != nil { 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) { return func(w http.ResponseWriter, r *http.Request) {
filters, err := repository.GetFilterHierarchy(db) filters, err := repository.GetFilterHierarchy(db)
if err != nil { if err != nil {

View File

@ -2,8 +2,8 @@ package handler
import ( import (
"database/sql" "database/sql"
"floorvisualizer/internal/logger"
"fmt" "fmt"
"log"
"net/http" "net/http"
"floorvisualizer/internal/model" "floorvisualizer/internal/model"
@ -21,17 +21,19 @@ type RecommendResponse struct {
var productService *service.ProductService var productService *service.ProductService
func initProductService(db *sql.DB) { func initProductService(db *sql.DB) {
if productService != nil { return } if productService != nil {
products, err := repository.QueryAllProducts(db)
if err != nil {
log.Printf("Failed to load products: %v", err)
return 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()) 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) { return func(w http.ResponseWriter, r *http.Request) {
sku := r.URL.Query().Get("sku") sku := r.URL.Query().Get("sku")
if sku == "" { if sku == "" {

View File

@ -1,15 +1,11 @@
package handler package handler
import ( import (
"database/sql"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"floorvisualizer/internal/middleware" "floorvisualizer/internal/model"
"floorvisualizer/internal/openrouter"
"floorvisualizer/internal/service"
) )
func NewProxyClient(proxyURLStr string) *http.Client { 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)}} 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) { func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if status >= 400 { if status >= 400 {
msg := "Unknown error" msg := "Unknown error"
switch x := v.(type) { switch x := v.(type) {
@ -80,19 +28,15 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
if e, ok := x["error"]; ok { if e, ok := x["error"]; ok {
msg = e msg = e
} }
case map[string]any:
if e, ok := x["error"]; ok {
msg = fmt.Sprint(e)
}
} }
w.WriteHeader(status) json.NewEncoder(w).Encode(model.ApiError{Code: status, Error: msg})
json.NewEncoder(w).Encode(map[string]any{"code": status, "error": msg})
return return
} }
w.WriteHeader(status) json.NewEncoder(w).Encode(model.ApiResponse{Code: status, Data: v})
json.NewEncoder(w).Encode(map[string]any{"code": status, "data": v})
} }
func writeErr(w http.ResponseWriter, status int, msg string) { 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})
} }

View File

@ -12,7 +12,7 @@ import (
"time" "time"
) )
func handleUpload(w http.ResponseWriter, r *http.Request) { func Upload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeErr(w, 405, "Method not allowed") writeErr(w, 405, "Method not allowed")
return return

View File

@ -18,7 +18,7 @@ import (
"floorvisualizer/internal/repository" "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) { return func(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context()) userID := middleware.UserID(r.Context())
switch r.Method { 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) { return func(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context()) userID := middleware.UserID(r.Context())
switch r.Method { 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) { return func(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context()) userID := middleware.UserID(r.Context())
projectID := strings.TrimPrefix(r.URL.Path, "/user/projects/") 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) writeJSON(w, 200, project)
case http.MethodPut: 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 { if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid request body"}) writeJSON(w, 400, map[string]string{"error": "invalid request body"})
return 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) { return func(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context()) userID := middleware.UserID(r.Context())
if err := r.ParseMultipartForm(5 << 20); err != nil { if err := r.ParseMultipartForm(5 << 20); err != nil {
@ -233,10 +235,9 @@ func handleUploadAvatar(db *sql.DB) http.HandlerFunc {
} }
} }
// ── Brand view tracking ─────────────────────────────────── // ── 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) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeErr(w, 405, "Method not allowed") 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) { return func(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context()) userID := middleware.UserID(r.Context())
if userID == "" { if userID == "" {

63
internal/model/calc.go Normal file
View File

@ -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"`
}

View File

@ -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"`
}

View File

@ -3,9 +3,8 @@ package queue
import ( import (
"context" "context"
"database/sql" "database/sql"
"fmt"
"io"
"floorvisualizer/internal/logger" "floorvisualizer/internal/logger"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@ -78,7 +77,11 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien
// Step 0: content check // Step 0: content check
SetJobProgress(jobCtx, rdb, jobID, "check", "Checking content...") SetJobProgress(jobCtx, rdb, jobID, "check", "Checking content...")
logger.Info("[%s] Content check", jobID) 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") SetJobError(jobCtx, rdb, jobID, "Content check failed: Please upload an indoor room photo")
return 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) logger.Info("[%s] Inpainting: %s + %s", jobID, floor.Name, pattern.Name)
floorPrompt := handler.BuildFloorPromptPublic(*floor, *pattern, room) 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, Prompt: floorPrompt, OutputPath: outputPath, InputImagePath: inputPath,
MaskImagePath: maskPath, ImageSize: "2K", MaskImagePath: maskPath, ImageSize: "2K",
}) })
@ -113,10 +116,13 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien
} }
// Clean up intermediate files // Clean up intermediate files
os.Remove(inputPath) if err := os.Remove(inputPath); err != nil {
os.Remove(maskPath) 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)) SetJobResult(jobCtx, rdb, jobID, "/"+filepath.ToSlash(outputPath))
logger.Info("[%s] Done", jobID) logger.Info("[%s] Done", jobID)
} }
@ -181,6 +187,3 @@ func base64enc(data []byte) string {
} }
return string(b) return string(b)
} }
// Make sure io is used (for any future imports)
var _ = io.Discard

67
main.go
View File

@ -2,19 +2,20 @@ package main
import ( import (
"context" "context"
"encoding/json"
"log" "log"
"math/rand"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strconv" "strconv"
"time" "time"
"encoding/json"
"math/rand"
"floorvisualizer/internal/api"
"floorvisualizer/internal/handler" "floorvisualizer/internal/handler"
"floorvisualizer/internal/logger" "floorvisualizer/internal/logger"
"floorvisualizer/internal/model"
"floorvisualizer/internal/middleware" "floorvisualizer/internal/middleware"
"floorvisualizer/internal/model"
"floorvisualizer/internal/openrouter" "floorvisualizer/internal/openrouter"
"floorvisualizer/internal/queue" "floorvisualizer/internal/queue"
"floorvisualizer/internal/repository" "floorvisualizer/internal/repository"
@ -23,6 +24,7 @@ import (
const defaultAPIKey = "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" const defaultAPIKey = "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"
const defaultJWTSecret = "floorviz-dev-secret-change-in-production" const defaultJWTSecret = "floorviz-dev-secret-change-in-production"
var proxyURL = "http://127.0.0.1:7897" var proxyURL = "http://127.0.0.1:7897"
func init() { func init() {
@ -132,39 +134,38 @@ func main() {
}() }()
} }
// ── Hot product cache ────────────────────────────────
// ── Hot product cache ──────────────────────────────── handler.SetProductsCache(
handler.SetProductsCache( // Getter: check Redis cache for hot products
// Getter: check Redis cache for hot products func(ctx context.Context, sku string) (*model.Product, bool) {
func(ctx context.Context, sku string) (*model.Product, bool) { val, err := rdb.Get(ctx, "product:"+sku).Result()
val, err := rdb.Get(ctx, "product:"+sku).Result() if err != nil {
if err != nil {
return nil, false
}
var p model.Product
if json.Unmarshal([]byte(val), &p) == nil {
return &p, true
}
return nil, false return nil, false
}, }
// Setter: only cache if this SKU is in the hot list (top 200) var p model.Product
func(ctx context.Context, sku string, product *model.Product) error { if json.Unmarshal([]byte(val), &p) == nil {
rank, err := rdb.ZRevRank(ctx, "product:views", sku).Result() return &p, true
if err != nil || rank >= 200 { }
return nil // not hot enough, skip caching return nil, false
} },
data, _ := json.Marshal(product) // Setter: only cache if this SKU is in the hot list (top 200)
ttl := 1*time.Hour + time.Duration(rand.Intn(600))*time.Second // ±10min jitter func(ctx context.Context, sku string, product *model.Product) error {
return rdb.Set(ctx, "product:"+sku, data, ttl).Err() rank, err := rdb.ZRevRank(ctx, "product:views", sku).Result()
}, if err != nil || rank >= 200 {
// View tracker: increment view count in sorted set return nil // not hot enough, skip caching
func(ctx context.Context, sku string) { }
rdb.ZIncrBy(ctx, "product:views", 1, sku) 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() mux := http.NewServeMux()
handler.RegisterAll(mux, client, db, authSvc) api.RegisterRoutes(mux, client, db, authSvc)
port := "8099" port := "8099"
if p := os.Getenv("PORT"); p != "" { if p := os.Getenv("PORT"); p != "" {