From 3fa3561bb2311df143c84984e0a4bcfd4a80e498 Mon Sep 17 00:00:00 2001 From: dindang Date: Mon, 27 Jul 2026 15:34:32 +0800 Subject: [PATCH] =?UTF-8?q?=E9=97=AE=E5=8D=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 25 + clip_server/server.py | 90 +++ internal/api/index.html | 440 +++++++++++++ internal/api/index_handler.go | 10 + internal/api/index_html.go | 3 + internal/api/questionnaire.html | 359 +++++++++++ internal/api/router.go | 5 + internal/handler/questionnaire_handler.go | 713 ++++++++++++++++++++++ internal/repository/product_repo.go | 9 + 9 files changed, 1654 insertions(+) create mode 100644 .env.example create mode 100644 clip_server/server.py create mode 100644 internal/api/index.html create mode 100644 internal/api/questionnaire.html create mode 100644 internal/handler/questionnaire_handler.go 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 + + + +
+

FloorVisualizer AI 换地板 Demo

+

上传房间照片,选择真实产品、铺设方式和可选效果,生成写实换地板预览

+
+ +
+
+
+

第一步:上传房间照片

+
+ + + 点击或拖拽上传图片
建议使用能清楚看到地面的室内照片
+
+
+ +
+

房间类型

+
+
+ +
+

地板类型

+
+
木地板
Hardwood
+
瓷砖
Tile / Stone
+
乙烯基地板
Luxury Vinyl / SPC
+
特殊测试
7款 · 含AI描述
+
+
+ +
+

铺设方式

+
+
+ + + +
+

选择地板款式

+
+
+
+ +
+

材质参考方式

+
+
仅图片
参考图
+
仅JSON
材质描述
+
图片+JSON
两者都用
+
+
+ +
+

可选效果

+
+ + +
+
+ + +
+
+
+ +
+
+
+

生成失败原因

+

+    
+
+ +
+

生成结果

+
上传图片并选择地板选项后
点击生成预览图查看效果
+ 生成结果 +
+ 生成图 +
原图
+
+
原图生成图
+
+
+
+ + + + 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