package handler import ( "database/sql" "encoding/json" "fmt" "math" "net/http" "sort" "strings" "floorvisualizer/internal/model" "floorvisualizer/internal/repository" ) type QuestionnaireBudget struct { Min float64 `json:"min"` Max float64 `json:"max"` } type QuestionnaireRequest struct { Rooms []string `json:"rooms"` Styles []string `json:"styles"` Looks []string `json:"looks"` ColorTones []string `json:"color_tones"` Budget QuestionnaireBudget `json:"budget"` Brands []string `json:"brands"` BrandMode string `json:"brand_mode"` Performance []string `json:"performance"` Finishes []string `json:"finishes"` SizePreference string `json:"size_preference"` Priority string `json:"priority"` Answers map[string]any `json:"answers,omitempty"` Limit int `json:"limit"` } type QuestionnaireRecommendation struct { Product model.Product `json:"product"` Score float64 `json:"score"` Reasons []string `json:"reasons"` SpecCount int `json:"spec_count"` } type QuestionnaireResponse struct { Recommendations []QuestionnaireRecommendation `json:"recommendations"` TotalCandidates int `json:"total_candidates"` UniqueCandidates int `json:"unique_candidates"` AnsweredWeights float64 `json:"answered_weights"` } type questionnaireScoredProduct struct { product model.Product score float64 reasons []string } func QuestionnaireOptions(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if db == nil { writeErr(w, http.StatusServiceUnavailable, "database is not available") return } out := map[string]any{ "brands": queryOptionCounts(db, "brand", 20), "categories": queryOptionCounts(db, "category", 20), "materials": queryOptionCounts(db, "material", 40), "color_tones": queryOptionCounts(db, "color_tone", 30), "finishes": queryOptionCounts(db, "finish", 20), "price_stats": queryPriceStats(db), } writeJSON(w, http.StatusOK, out) } } func QuestionnaireRecommend(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if db == nil { writeErr(w, http.StatusServiceUnavailable, "database is not available") return } if r.Method != http.MethodPost { writeErr(w, http.StatusMethodNotAllowed, "method not allowed") return } var req QuestionnaireRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid questionnaire payload") return } normalizeQuestionnaireRequest(&req) if req.Limit <= 0 || req.Limit > 30 { req.Limit = 10 } products, err := repository.QueryAllProductRows(db) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } brandOnly := req.BrandMode == "only" && len(req.Brands) > 0 brandSet := toSet(req.Brands) scored := make([]questionnaireScoredProduct, 0, len(products)) for _, p := range products { if !recommendableProduct(p) { continue } if brandOnly && !brandSet[p.Brand] { continue } score, reasons, answeredWeight := scoreQuestionnaireProduct(req, p) if answeredWeight == 0 { score = neutralProductScore(p) } scored = append(scored, questionnaireScoredProduct{product: p, score: roundOne(score), reasons: reasons}) } unique := dedupeQuestionnaireResults(scored) sort.SliceStable(unique, func(i, j int) bool { if unique[i].score != unique[j].score { return unique[i].score > unique[j].score } if unique[i].product.IsOfficialPrice != unique[j].product.IsOfficialPrice { return unique[i].product.IsOfficialPrice } if req.Priority == "budget" && unique[i].product.PricePerSqft != unique[j].product.PricePerSqft { return unique[i].product.PricePerSqft < unique[j].product.PricePerSqft } return unique[i].product.MainImageURL != "" && unique[j].product.MainImageURL == "" }) selected := diversifyQuestionnaireResults(unique, req.Limit, brandOnly) skus := make([]string, 0, len(selected)) for _, r := range selected { skus = append(skus, r.product.SKU) } specCounts := repository.GetSpecCounts(db, skus) recs := make([]QuestionnaireRecommendation, 0, len(selected)) for _, r := range selected { key := r.product.Brand + "|" + r.product.StyleName + "|" + r.product.SeriesName recs = append(recs, QuestionnaireRecommendation{ Product: r.product, Score: r.score, Reasons: fallbackReasons(r.reasons, r.product), SpecCount: specCounts[key], }) } _, _, answeredWeight := scoreQuestionnaireProduct(req, model.Product{}) writeJSON(w, http.StatusOK, QuestionnaireResponse{ Recommendations: recs, TotalCandidates: len(scored), UniqueCandidates: len(unique), AnsweredWeights: roundOne(answeredWeight), }) } } func normalizeQuestionnaireRequest(req *QuestionnaireRequest) { req.Rooms = cleanList(req.Rooms) req.Styles = cleanList(req.Styles) req.Looks = cleanList(req.Looks) req.ColorTones = cleanList(req.ColorTones) req.Brands = cleanList(req.Brands) req.Performance = cleanList(req.Performance) req.Finishes = cleanList(req.Finishes) req.SizePreference = strings.TrimSpace(req.SizePreference) req.Priority = strings.TrimSpace(req.Priority) req.BrandMode = strings.TrimSpace(req.BrandMode) } func scoreQuestionnaireProduct(req QuestionnaireRequest, p model.Product) (float64, []string, float64) { weights := map[string]float64{ "color": 20, "style": 20, "look": 15, "budget": 15, "room": 10, "performance": 10, "brand": 7, "finish": 5, "size": 3, } adjustWeights(weights, req.Priority) totalWeight := 0.0 weighted := 0.0 reasons := []string{} add := func(name string, answered bool, score float64, reason string) { if !answered { return } w := weights[name] totalWeight += w weighted += clampScore(score) * w if score >= 78 && reason != "" { reasons = append(reasons, reason) } } add("color", len(req.ColorTones) > 0, scoreExactOrRelated(p.ColorTone, req.ColorTones, colorRelatedGroups()), fmt.Sprintf("颜色接近 %s", p.ColorTone)) add("style", len(req.Styles) > 0, scoreStyles(p, req.Styles), "风格偏好匹配") add("look", len(req.Looks) > 0, scoreLooks(p, req.Looks), "外观类型匹配") add("budget", req.Budget.Min > 0 || req.Budget.Max > 0, scoreBudget(p.PricePerSqft, req.Budget), priceReason(p)) add("room", len(req.Rooms) > 0, scoreRooms(p, req.Rooms), "适合选择的使用空间") add("performance", len(req.Performance) > 0, scorePerformance(p, req.Performance), "性能偏好匹配") add("brand", len(req.Brands) > 0 && req.BrandMode != "only", scoreExactOrRelated(p.Brand, req.Brands, nil), fmt.Sprintf("匹配品牌 %s", p.Brand)) add("finish", len(req.Finishes) > 0, scoreExactOrRelated(p.Finish, req.Finishes, [][]string{{"Matte", "Textured"}, {"Distressed/Embossed", "Wire Brushed"}}), fmt.Sprintf("表面质感为 %s", p.Finish)) add("size", req.SizePreference != "", scoreSize(p, req.SizePreference), "规格视觉匹配") if totalWeight == 0 { return 0, reasons, 0 } score := weighted / totalWeight if p.MainImageURL != "" || p.RoomImageURL != "" { score += 1.5 } if p.IsOfficialPrice { score += 1 } return clampScore(score), compactReasons(reasons), totalWeight } func adjustWeights(weights map[string]float64, priority string) { switch priority { case "style": weights["style"] += 8 weights["color"] += 4 case "budget": weights["budget"] += 12 case "durability": weights["performance"] += 10 weights["room"] += 4 case "brand": weights["brand"] += 12 case "color": weights["color"] += 12 } } func recommendableProduct(p model.Product) bool { status := strings.ToLower(strings.TrimSpace(p.Status)) if strings.Contains(status, "discontinued") || strings.Contains(status, "inactive") || strings.Contains(status, "下架") { return false } return p.SKU != "" && p.StyleName != "" } func neutralProductScore(p model.Product) float64 { score := 60.0 if p.MainImageURL != "" || p.RoomImageURL != "" { score += 8 } if p.PricePerSqft > 0 { score += 5 } if p.IsOfficialPrice { score += 3 } return score } func scoreExactOrRelated(value string, selected []string, related [][]string) float64 { if value == "" || len(selected) == 0 { return 50 } for _, s := range selected { if strings.EqualFold(value, s) { return 100 } } for _, group := range related { if containsFold(group, value) { for _, s := range selected { if containsFold(group, s) { return 70 } } } } return 15 } func colorRelatedGroups() [][]string { return [][]string{ {"Natural", "Warm Yellow", "Taupe"}, {"Light Gray", "Off White", "Taupe"}, {"Dark Brown", "Black Walnut", "Cherry"}, {"Green", "Blue", "Light Gray"}, } } func scoreStyles(p model.Product, styles []string) float64 { best := 0.0 for _, style := range styles { best = math.Max(best, scoreStyle(p, style)) } return best } func scoreStyle(p model.Product, style string) float64 { text := productText(p) switch style { case "modern": return tagScore(p, []string{"Light Gray", "Off White", "Natural"}, []string{"Maple", "Ash", "Porcelain"}, []string{"modern", "clean", "minimal", "airy", "sophisticated"}, text) case "warm_natural": return tagScore(p, []string{"Natural", "Warm Yellow", "Taupe"}, []string{"Oak", "Hickory", "Pine", "Ash"}, []string{"warm", "natural", "wood", "honey"}, text) case "classic": return tagScore(p, []string{"Cherry", "Dark Brown", "Black Walnut", "Natural"}, []string{"Oak", "Walnut", "Maple", "Hickory"}, []string{"classic", "traditional", "timeless"}, text) case "rustic": score := tagScore(p, []string{"Natural", "Dark Brown", "Taupe"}, []string{"Hickory", "Oak", "Pine"}, []string{"rustic", "scraped", "weathered", "reclaimed", "vintage", "barn"}, text) if p.Finish == "Distressed/Embossed" || p.Finish == "Wire Brushed" { score += 20 } return clampScore(score) case "luxury_stone": score := tagScore(p, []string{"Off White", "Light Gray", "Natural"}, []string{"Porcelain", "Stone Look"}, []string{"marble", "travertine", "polished", "stone", "luxe"}, text) if p.Category == "Tile/Stone" { score += 25 } if p.Finish == "Gloss" { score += 10 } return clampScore(score) case "industrial": return tagScore(p, []string{"Light Gray", "Black Walnut", "Dark Brown"}, []string{"Porcelain", "Stone Look"}, []string{"concrete", "slate", "smoke", "graphite", "cement", "industrial"}, text) case "coastal": return tagScore(p, []string{"Off White", "Light Gray", "Natural"}, []string{"Oak", "Maple", "Ash"}, []string{"beach", "coastal", "sand", "airy", "white"}, text) } return 50 } func tagScore(p model.Product, colors, materials, keywords []string, text string) float64 { score := 15.0 if containsFold(colors, p.ColorTone) { score += 30 } if containsFold(materials, p.Material) || materialFamilyHit(p.Material, materials) { score += 25 } for _, kw := range keywords { if strings.Contains(text, kw) { score += 15 break } } if p.Finish == "Matte" { score += 8 } return clampScore(score) } func scoreLooks(p model.Product, looks []string) float64 { best := 0.0 text := productText(p) for _, look := range looks { score := 0.0 switch look { case "wood_look": if isWoodLike(p, text) { score = 100 } else if p.Category == "SPC-LVP" || p.Category == "Laminate" { score = 70 } else { score = 20 } case "stone_tile": if p.Category == "Tile/Stone" || strings.Contains(text, "stone") || strings.Contains(text, "marble") || strings.Contains(text, "travertine") { score = 100 } else { score = 15 } case "real_hardwood": if p.Category == "Solid Hardwood" || p.Category == "Engineered Hardwood" { score = 100 } else if isWoodLike(p, text) { score = 55 } else { score = 10 } case "light_clean": score = scoreExactOrRelated(p.ColorTone, []string{"Off White", "Light Gray", "Natural"}, colorRelatedGroups()) case "dark_rich": score = scoreExactOrRelated(p.ColorTone, []string{"Dark Brown", "Black Walnut", "Cherry"}, colorRelatedGroups()) } best = math.Max(best, score) } return best } func scoreBudget(price float64, budget QuestionnaireBudget) float64 { if price <= 0 { return 50 } min, max := budget.Min, budget.Max if min > 0 && price < min { return clampScore(100 - ((min-price)/math.Max(min, 1))*80) } if max > 0 && price > max { return clampScore(100 - ((price-max)/math.Max(max, 1))*90) } return 100 } func scoreRooms(p model.Product, rooms []string) float64 { best := 0.0 for _, room := range rooms { score := 50.0 switch room { case "bathroom", "kitchen", "basement", "commercial": score = scorePerformance(p, []string{"waterproof", "easy_clean"}) case "living_room", "bedroom": score = math.Max(scoreStyles(p, []string{"warm_natural", "modern"}), 60) case "rental": score = scorePerformance(p, []string{"scratch_resistant", "easy_clean"}) } best = math.Max(best, score) } return best } func scorePerformance(p model.Product, performance []string) float64 { best := 0.0 text := productText(p) for _, perf := range performance { score := 20.0 switch perf { case "waterproof": if p.Category == "Tile/Stone" || p.Category == "SPC-LVP" || containsAnyFold(p.Material, []string{"porcelain", "vinyl", "rigid core", "spc"}) || strings.Contains(text, "water") || strings.Contains(text, "moisture") { score = 100 } case "scratch_resistant": if p.Category == "Tile/Stone" || p.Category == "Laminate" || p.Category == "SPC-LVP" || containsAnyFold(text, []string{"scratch", "durable", "superguard", "wear"}) { score = 95 } case "easy_clean": if p.Category == "Tile/Stone" || p.Category == "SPC-LVP" || p.Finish == "Matte" || containsAnyFold(text, []string{"clean", "maintenance"}) { score = 90 } case "pet_kid": score = math.Max(scorePerformance(p, []string{"waterproof"}), scorePerformance(p, []string{"scratch_resistant"})) case "comfort_quiet": if p.Brand == "COREtec" || p.Category == "SPC-LVP" || containsAnyFold(text, []string{"quiet", "comfort", "soft", "underlayment"}) { score = 90 } case "eco": if containsAnyFold(text, []string{"recycled", "eco", "green", "low voc"}) { score = 90 } else { score = 45 } } best = math.Max(best, score) } return best } func scoreSize(p model.Product, pref string) float64 { switch pref { case "wide_plank": if p.WidthIn >= 7 && p.LengthIn >= 48 { return 100 } if p.WidthIn >= 5 { return 70 } case "standard_plank": if p.WidthIn >= 4 && p.WidthIn < 7 && p.LengthIn >= 36 { return 100 } return 55 case "large_tile": if p.Category == "Tile/Stone" && p.WidthIn >= 12 && p.LengthIn >= 24 { return 100 } if p.Category == "Tile/Stone" { return 65 } case "small_tile": if p.Category == "Tile/Stone" && (p.WidthIn <= 6 || p.LengthIn <= 12) { return 100 } if p.Category == "Tile/Stone" { return 60 } } return 20 } func dedupeQuestionnaireResults(results []questionnaireScoredProduct) []questionnaireScoredProduct { best := map[string]questionnaireScoredProduct{} for _, r := range results { key := strings.ToLower(r.product.Brand + "|" + r.product.SeriesName + "|" + r.product.StyleName + "|" + r.product.ColorTone) if old, ok := best[key]; !ok || r.score > old.score || (r.score == old.score && r.product.MainImageURL != "" && old.product.MainImageURL == "") { best[key] = r } } out := make([]questionnaireScoredProduct, 0, len(best)) for _, r := range best { out = append(out, r) } return out } func diversifyQuestionnaireResults(results []questionnaireScoredProduct, limit int, brandOnly bool) []questionnaireScoredProduct { if len(results) <= limit { return results } maxPerBrand := 4 if brandOnly { maxPerBrand = limit } brandCounts := map[string]int{} selected := make([]questionnaireScoredProduct, 0, limit) for _, r := range results { if brandCounts[r.product.Brand] >= maxPerBrand { continue } selected = append(selected, r) brandCounts[r.product.Brand]++ if len(selected) == limit { return selected } } for _, r := range results { exists := false for _, s := range selected { if s.product.SKU == r.product.SKU { exists = true break } } if !exists { selected = append(selected, r) } if len(selected) == limit { break } } return selected } func fallbackReasons(reasons []string, p model.Product) []string { reasons = compactReasons(reasons) if len(reasons) > 0 { return reasons } out := []string{} if p.ColorTone != "" { out = append(out, "颜色为 "+p.ColorTone) } if p.Material != "" { out = append(out, "材质为 "+p.Material) } if p.PricePerSqft > 0 { out = append(out, priceReason(p)) } return compactReasons(out) } func compactReasons(reasons []string) []string { seen := map[string]bool{} out := []string{} for _, r := range reasons { r = strings.TrimSpace(r) if r == "" || seen[r] { continue } seen[r] = true out = append(out, r) if len(out) >= 4 { break } } return out } func priceReason(p model.Product) string { if p.PricePerSqft <= 0 { return "" } return fmt.Sprintf("价格约 $%.2f/sqft", p.PricePerSqft) } func productText(p model.Product) string { return strings.ToLower(strings.Join([]string{ p.Brand, p.SeriesName, p.StyleName, p.Category, p.Material, p.ColorTone, p.Finish, p.Description, }, " ")) } func isWoodLike(p model.Product, text string) bool { if p.Category == "Solid Hardwood" || p.Category == "Engineered Hardwood" || p.Category == "Laminate" || p.Category == "SPC-LVP" { if materialFamilyHit(p.Material, []string{"Oak", "Maple", "Hickory", "Walnut", "Ash", "Pine", "Elm", "Teak", "Birch", "Luxury Vinyl", "Rigid Core"}) { return true } } return containsAnyFold(text, []string{"oak", "maple", "hickory", "walnut", "ash", "pine", "wood", "plank"}) } func materialFamilyHit(material string, choices []string) bool { m := strings.ToLower(material) for _, choice := range choices { c := strings.ToLower(choice) if m == c || strings.Contains(m, c) { return true } } return false } func queryOptionCounts(db *sql.DB, col string, limit int) []map[string]any { rows, err := db.Query("SELECT "+col+", COUNT(*) FROM products WHERE "+col+" != '' GROUP BY "+col+" ORDER BY COUNT(*) DESC, "+col+" LIMIT $1", limit) if err != nil { return nil } defer rows.Close() out := []map[string]any{} for rows.Next() { var value string var count int if err := rows.Scan(&value, &count); err == nil { out = append(out, map[string]any{"value": value, "count": count}) } } return out } func queryPriceStats(db *sql.DB) map[string]float64 { rows, err := db.Query(` SELECT COALESCE(MIN(price_per_sqft), 0), COALESCE(PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY price_per_sqft), 0), COALESCE(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_sqft), 0), COALESCE(PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY price_per_sqft), 0), COALESCE(MAX(price_per_sqft), 0) FROM products WHERE price_per_sqft > 0 `) if err != nil { return map[string]float64{} } defer rows.Close() stats := map[string]float64{} if rows.Next() { var min, p25, median, p75, max float64 if err := rows.Scan(&min, &p25, &median, &p75, &max); err == nil { stats["min"] = roundOne(min) stats["p25"] = roundOne(p25) stats["median"] = roundOne(median) stats["p75"] = roundOne(p75) stats["max"] = roundOne(max) } } return stats } func cleanList(values []string) []string { out := []string{} seen := map[string]bool{} for _, v := range values { v = strings.TrimSpace(v) if v == "" || seen[v] { continue } seen[v] = true out = append(out, v) } return out } func toSet(values []string) map[string]bool { out := map[string]bool{} for _, v := range values { out[v] = true } return out } func containsFold(values []string, target string) bool { for _, v := range values { if strings.EqualFold(v, target) { return true } } return false } func containsAnyFold(value string, needles []string) bool { v := strings.ToLower(value) for _, n := range needles { if strings.Contains(v, strings.ToLower(n)) { return true } } return false } func clampScore(v float64) float64 { if v < 0 { return 0 } if v > 100 { return 100 } return v } func roundOne(v float64) float64 { return math.Round(v*10) / 10 }