diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..66470e8
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,25 @@
+# ─── 服务器 ───
+PORT=8099
+
+# ─── 数据库 ───
+DATABASE_URL=postgres://postgres:your_password@localhost:5432/floorvisualizer?sslmode=disable
+
+# ─── Redis ───
+REDIS_ADDR=localhost:6379
+
+# ─── JWT ───
+JWT_SECRET=change-this-to-a-strong-secret
+
+# ─── AI ───
+OPENROUTER_API_KEY=sk-or-v1-your-openrouter-key-here
+
+# ─── 代理(国内环境用,留空则直连) ───
+PROXY_URL=http://127.0.0.1:7897
+# DISABLE_PROXY=true
+
+# ─── 队列 ───
+WORKER_COUNT=5
+
+# ─── 日志 ───
+LOG_DIR=logs
+LOG_LEVEL=INFO
diff --git a/clip_server/server.py b/clip_server/server.py
new file mode 100644
index 0000000..57c3bdb
--- /dev/null
+++ b/clip_server/server.py
@@ -0,0 +1,90 @@
+"""CLIP-based indoor room classifier. Runs ViT-B/32 on CPU, ~100ms per image."""
+
+import io
+import logging
+import os
+
+import open_clip
+import torch
+from PIL import Image
+from flask import Flask, jsonify, request
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+log = logging.getLogger("clip-server")
+app = Flask(__name__)
+app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 # 50 MB max upload
+
+# Load once at startup
+MODEL_NAME = os.getenv("CLIP_MODEL", "ViT-B-32")
+PRETRAINED = os.getenv("CLIP_PRETRAINED", "laion2b_s34b_b79k")
+DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
+
+log.info(f"Loading CLIP model: {MODEL_NAME} pretrained={PRETRAINED} device={DEVICE}")
+model, _, preprocess = open_clip.create_model_and_transforms(MODEL_NAME, pretrained=PRETRAINED)
+model = model.to(DEVICE).eval()
+tokenizer = open_clip.get_tokenizer(MODEL_NAME)
+
+# Text prompts for indoor/outdoor classification
+PROMPTS = [
+ "a photo of an indoor room interior with floor visible",
+ "a photo of an outdoor scene, landscape, street, or non-room image",
+]
+
+
+@app.route("/health", methods=["GET"])
+def health():
+ return jsonify({"status": "ok", "model": MODEL_NAME, "device": DEVICE})
+
+
+@app.route("/check", methods=["POST"])
+def check():
+ """Classify whether an image is an indoor room photo suitable for floor replacement.
+ Expects raw image bytes as the request body. Returns {score: 0-10, passed: bool}."""
+ if not request.data:
+ return jsonify({"error": "no image data"}), 400
+
+ try:
+ img = Image.open(io.BytesIO(request.data)).convert("RGB")
+ except Exception as e:
+ return jsonify({"error": f"bad image: {e}"}), 400
+
+ # Multiple prompt pairs for robust ensemble (indoor vs outdoor)
+ prompt_pairs = [
+ ("a photo of an indoor room interior with floor", "a photo of an outdoor scene"),
+ ("interior room photograph, real estate photo", "exterior photograph, outdoor photo"),
+ ("an indoor space with visible flooring", "an outdoor space, landscape, street view"),
+ ]
+
+ image_input = preprocess(img).unsqueeze(0).to(DEVICE)
+ wins = 0
+ gap_sum = 0.0
+ for indoor_text, outdoor_text in prompt_pairs:
+ text_input = tokenizer([indoor_text, outdoor_text]).to(DEVICE)
+ with torch.no_grad():
+ image_features = model.encode_image(image_input)
+ text_features = model.encode_text(text_input)
+ image_features /= image_features.norm(dim=-1, keepdim=True)
+ text_features /= text_features.norm(dim=-1, keepdim=True)
+ raw = (image_features @ text_features.T)[0] # cosine similarities
+ indoor_sim = float(raw[0].item())
+ outdoor_sim = float(raw[1].item())
+ if indoor_sim > outdoor_sim:
+ wins += 1
+ gap_sum += indoor_sim - outdoor_sim # positive = indoor wins
+
+ indoor_wins = wins >= 2 # majority vote (2 out of 3)
+ avg_gap = gap_sum / len(prompt_pairs)
+ # Map avg_gap to 1-10: gap ~0.05 = score 6, gap ~0.15+ = score 10
+ score_1_10 = max(1, min(10, int(5 + avg_gap * 40)))
+ passed = indoor_wins
+
+ log.info(
+ f"wins={wins}/3 avg_gap={avg_gap:.3f} score_1_10={score_1_10} passed={passed}"
+ )
+ return jsonify({"score": score_1_10, "passed": passed})
+
+
+if __name__ == "__main__":
+ port = int(os.getenv("CLIP_PORT", "5100"))
+ log.info(f"CLIP server listening on :{port}")
+ app.run(host="0.0.0.0", port=port, debug=False)
diff --git a/internal/api/index.html b/internal/api/index.html
new file mode 100644
index 0000000..bc93fe8
--- /dev/null
+++ b/internal/api/index.html
@@ -0,0 +1,440 @@
+
+
+
+
+
+FloorVisualizer Demo
+
+
+
+
+
+
+
+
+
第一步:上传房间照片
+
+
+
![]()
+
点击或拖拽上传图片
建议使用能清楚看到地面的室内照片
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
生成结果
+
上传图片并选择地板选项后
点击生成预览图查看效果
+
![生成结果]()
+
+
![生成图]()
+
+
+
原图生成图
+
+
+
+
+
+
+
diff --git a/internal/api/index_handler.go b/internal/api/index_handler.go
index 67df1db..02d6e64 100644
--- a/internal/api/index_handler.go
+++ b/internal/api/index_handler.go
@@ -11,3 +11,13 @@ func Index(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(indexHTML))
}
+
+func Questionnaire(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/questionnaire" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(questionnaireHTML))
+}
diff --git a/internal/api/index_html.go b/internal/api/index_html.go
index bc888e3..f700edb 100644
--- a/internal/api/index_html.go
+++ b/internal/api/index_html.go
@@ -4,3 +4,6 @@ import _ "embed"
//go:embed index.html
var indexHTML string
+
+//go:embed questionnaire.html
+var questionnaireHTML string
diff --git a/internal/api/questionnaire.html b/internal/api/questionnaire.html
new file mode 100644
index 0000000..fa8a64d
--- /dev/null
+++ b/internal/api/questionnaire.html
@@ -0,0 +1,359 @@
+
+
+
+
+
+地板偏好问卷 - FloorVisualizer
+
+
+
+
+
+
+
+
+
+
问卷推荐结果
+
回答左侧核心问题后,系统会对产品库打分、合并同款不同尺寸,并返回最匹配的 10 款。
+
+
+
-候选 SKU
+
-去重款式
+
0%问卷完成度
+
+
+
+ 请选择你的空间、风格、外观、颜色和预算。
+
+
+
+
+
+
+
diff --git a/internal/api/router.go b/internal/api/router.go
index 7d942f8..238ac88 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -32,6 +32,8 @@ func RegisterRoutes(mux *http.ServeMux, client *openrouter.Client, db *sql.DB, a
productOpts = handler.ProductOptions(db)
filterOpts = handler.FilterOptions(db)
recommend = handler.Recommend(db)
+ questionnaireRec = handler.QuestionnaireRecommend(db)
+ questionnaireOpt = handler.QuestionnaireOptions(db)
floorGenerate = handler.FloorGenerate(client, db)
floorOptions = handler.FloorOptions(db)
articleRecommend = handler.ArticleRecommend(db)
@@ -56,6 +58,9 @@ func RegisterRoutes(mux *http.ServeMux, client *openrouter.Client, db *sql.DB, a
mux.HandleFunc("/product-options", productOpts)
mux.HandleFunc("/filter-options", filterOpts)
mux.HandleFunc("/recommend/api", recommend)
+ mux.HandleFunc("/questionnaire", Questionnaire)
+ mux.HandleFunc("/questionnaire/options", questionnaireOpt)
+ mux.HandleFunc("/questionnaire/recommend", questionnaireRec)
mux.HandleFunc("/calculator/calc", handler.Calc)
mux.HandleFunc("/floor/generate", floorGenerate)
mux.HandleFunc("/floor/status", handler.FloorStatus)
diff --git a/internal/handler/questionnaire_handler.go b/internal/handler/questionnaire_handler.go
new file mode 100644
index 0000000..8ac90db
--- /dev/null
+++ b/internal/handler/questionnaire_handler.go
@@ -0,0 +1,713 @@
+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
+}
diff --git a/internal/repository/product_repo.go b/internal/repository/product_repo.go
index da41f44..c0f22dc 100644
--- a/internal/repository/product_repo.go
+++ b/internal/repository/product_repo.go
@@ -71,6 +71,15 @@ func QueryAllProducts(d *sql.DB) ([]model.Product, error) {
return prods, err
}
+func QueryAllProductRows(d *sql.DB) ([]model.Product, error) {
+ rows, err := d.Query("SELECT " + selectCols + " FROM products ORDER BY id")
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return ScanProducts(rows)
+}
+
func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, error) {
if p.Limit <= 0 {
p.Limit = 20