代码分层 #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"
)
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 {

View File

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

View File

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

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 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 {

View File

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

View File

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

View File

@ -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 == "" {

View File

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

View File

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

View File

@ -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 == "" {

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 (
"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

67
main.go
View File

@ -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 != "" {