FloorVisualizer/internal/handler/questionnaire_handler.go

714 lines
20 KiB
Go
Raw Normal View History

2026-07-27 07:34:32 +00:00
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
}