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/.gitignore b/.gitignore
index 2762182..80303d6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,6 @@ __MACOSX/
# Claude
.claude/
+
+# Env
+.env
diff --git a/apifox-import.json b/apifox-import.json
index ed8fb28..804db40 100644
--- a/apifox-import.json
+++ b/apifox-import.json
@@ -232,6 +232,10 @@
"spec_count": {
"type": "integer",
"description": "该花色的规格数量(同品牌同花色的SKU数,含自身)。>1 表示有多种尺寸可选"
+ },
+ "is_official_price": {
+ "type": "boolean",
+ "description": "是否为官方定价。true=品牌官网标价,false=市场估价或未找到"
}
}
},
@@ -407,6 +411,10 @@
"is_favorited": {
"type": "boolean",
"description": "当前用户是否已收藏。未登录时始终为 false"
+ },
+ "is_official_price": {
+ "type": "boolean",
+ "description": "是否为官方定价。true=品牌官网标价,false=市场估价或未找到"
}
}
},
@@ -616,7 +624,7 @@
},
"FloorStyleOption": {
"type": "object",
- "description": "Floor style loaded from product database",
+ "description": "Floor style loaded from product database. Each entry = one unique style (dedup by brand+style_name). Variants list different sizes.",
"properties": {
"id": {
"type": "string",
@@ -628,23 +636,31 @@
},
"name": {
"type": "string",
- "description": "Style name"
+ "description": "Style name (花色名称)"
},
"material": {
"type": "string",
- "description": "Material (used in AI prompt)"
+ "description": "Material (材质,如 Oak/Porcelain/Luxury Vinyl)"
},
"color_tone": {
"type": "string",
- "description": "Color tone (used in AI prompt)"
+ "description": "Color tone (色调,如 Light Gray/Natural Oak/Dark Brown)"
},
"finish": {
"type": "string",
- "description": "Finish (used in AI prompt)"
+ "description": "Finish (表面处理,如 Matte/Gloss/Textured)"
},
"size_label": {
"type": "string",
- "description": "Size label, e.g. 6 x 48 in"
+ "description": "Size label, e.g. 6 x 48 in. 多尺寸时为首个尺寸"
+ },
+ "width_in": {
+ "type": "number",
+ "description": "Width in inches. Used in AI prompt for physical scale"
+ },
+ "length_in": {
+ "type": "number",
+ "description": "Length in inches. Used in AI prompt for physical scale"
},
"image_url": {
"type": "string",
@@ -652,7 +668,24 @@
},
"category": {
"type": "string",
- "description": "Product category"
+ "description": "Product category (Solid Hardwood / Engineered Hardwood / Tile/Stone / SPC-LVP / Laminate)"
+ },
+ "description": {
+ "type": "string",
+ "description": "Product description. Used in AI prompt for richer visual detail"
+ },
+ "variants": {
+ "type": "array",
+ "description": "Size variants of the same style (same brand+style_name, different SKU/size). Empty array if only one size.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "sku": { "type": "string", "description": "Variant SKU" },
+ "size_label": { "type": "string", "description": "Variant size label" },
+ "width_in": { "type": "number" },
+ "length_in": { "type": "number" }
+ }
+ }
}
}
},
@@ -664,21 +697,21 @@
"items": {
"$ref": "#/components/schemas/FloorStyleOption"
},
- "description": "Hardwood + Engineered Wood products"
+ "description": "Wood products (Oak/Ash/Hickory/Maple etc.) — classified by material field"
},
"tile_floors": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FloorStyleOption"
},
- "description": "Wood-Look Tile + Laminate products"
+ "description": "Tile/Stone (Porcelain/Stone Look/Glass) + Laminate products"
},
"vinyl_floors": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FloorStyleOption"
},
- "description": "SPC/LVP products"
+ "description": "Vinyl products (Luxury Vinyl/Rigid Core)"
},
"wood_patterns": {
"type": "array",
@@ -1817,8 +1850,8 @@
"tags": [
"地板更换"
],
- "summary": "获取地板样式选项(从产品库动态加载)",
- "description": "从产品数据库(3600+产品)动态加载地板样式选项。\n\n每个样式对应一个真实产品SKU,/floor/generate 的 floor_id 参数必须传真实SKU。\n\n分类说明:\n- wood_floors:实木+实木复合产品\n- tile_floors:瓷砖仿木纹+强化复合产品\n- vinyl_floors:SPC/LVP产品\n- wood_patterns / tile_patterns:铺设方式\n- rooms:房间类型",
+ "summary": "获取地板样式选项(从产品库动态加载,含尺寸变体)",
+ "description": "从产品数据库动态加载地板样式选项。每个样式对应一个真实产品SKU,含 width_in/length_in/description/variants 等字段。\n\n材质分类(按 material 字段自动归类):\n- wood_floors:Oak/Ash/Hickory/Maple 等木材\n- tile_floors:Porcelain/Stone Look/Glass + Laminate\n- vinyl_floors:Luxury Vinyl/Rigid Core\n\n前端应使用 variants 渲染尺寸选择面板。",
"responses": {
"200": {
"description": "成功",
@@ -1842,59 +1875,95 @@
"wood_floors": [],
"tile_floors": [],
"vinyl_floors": [],
+ "wood_floors": [
+ {
+ "id": "CB218LG",
+ "sku": "CB218LG",
+ "name": "Cherry",
+ "material": "Oak",
+ "color_tone": "Natural Oak",
+ "finish": "Matte",
+ "size_label": "2.25 x 84 in",
+ "width_in": 2.25,
+ "length_in": 84.0,
+ "image_url": "https://...",
+ "category": "Solid Hardwood",
+ "description": "Natural cherry oak with subtle grain",
+ "variants": [
+ { "sku": "CB4218", "size_label": "4 x 84 in", "width_in": 4.0, "length_in": 84.0 },
+ { "sku": "CB5218", "size_label": "5 x 84 in", "width_in": 5.0, "length_in": 84.0 }
+ ]
+ }
+ ],
+ "tile_floors": [],
+ "vinyl_floors": [],
"wood_patterns": [
{
"code": 1,
- "name": "Straight Lay",
- "pattern": "straight/parallel planks...",
+ "name": "直铺(工字拼)Straight Lay",
+ "pattern": "straight/parallel planks running from front to back with staggered joints",
"category": "wood"
},
{
"code": 2,
- "name": "Horizontal Lay",
- "pattern": "horizontal planks...",
+ "name": "横铺 Horizontal Lay",
+ "pattern": "horizontal planks running side to side across the width of the room",
"category": "wood"
},
{
"code": 3,
- "name": "Herringbone",
- "pattern": "herringbone/zigzag pattern...",
+ "name": "人字拼 Herringbone",
+ "pattern": "herringbone/zigzag pattern with rectangular planks arranged in a broken V shape",
"category": "wood"
},
{
"code": 4,
- "name": "Chevron",
- "pattern": "chevron pattern...",
+ "name": "鱼骨拼 Chevron",
+ "pattern": "chevron pattern with planks cut at an angle forming a continuous V shape",
"category": "wood"
}
],
"tile_patterns": [
{
"code": 5,
- "name": "Straight Grid",
- "pattern": "straight grid layout...",
+ "name": "直铺(网格对缝)Straight Grid",
+ "pattern": "straight grid layout with tiles perfectly aligned in rows and columns, clean orthogonal grout lines",
"category": "tile"
},
{
"code": 6,
- "name": "Running Bond",
- "pattern": "running bond/brick pattern...",
+ "name": "工字铺(1/2错缝)Running Bond",
+ "pattern": "running bond/brick pattern with each row offset by 50%, staggered brickwork appearance",
+ "category": "tile"
+ },
+ {
+ "code": 7,
+ "name": "三七错铺 1/3 Offset",
+ "pattern": "tiles offset by 1/3 of their length in each row, subtle stepped pattern",
+ "category": "tile"
+ },
+ {
+ "code": 8,
+ "name": "六边形 Hexagonal",
+ "pattern": "hexagonal/honeycomb tiles fitted together in a seamless honeycomb mesh",
+ "category": "tile"
+ },
+ {
+ "code": 9,
+ "name": "斜铺(45度对角)Diagonal",
+ "pattern": "tiles laid at a 45-degree diagonal angle to the walls, diamond orientation",
"category": "tile"
}
],
"rooms": [
- {
- "code": 0,
- "name": "Auto Detect"
- },
- {
- "code": 1,
- "name": "Living Room"
- },
- {
- "code": 2,
- "name": "Bedroom"
- }
+ { "code": 0, "name": "自动识别" },
+ { "code": 1, "name": "客厅" },
+ { "code": 2, "name": "卧室" },
+ { "code": 3, "name": "厨房" },
+ { "code": 4, "name": "餐厅" },
+ { "code": 5, "name": "浴室" },
+ { "code": 6, "name": "书房" },
+ { "code": 7, "name": "走廊" }
]
}
}
@@ -1909,8 +1978,8 @@
"tags": [
"地板更换"
],
- "summary": "AI换地板(使用真实产品数据)",
- "description": "上传房间照片 + 地板选项,加入 Redis 异步队列,返回 job_id。3 个 worker 并发处理。用 GET /floor/status?job_id=xxx 查询进度",
+ "summary": "AI换地板(使用真实产品数据,含尺寸/变体)",
+ "description": "上传房间照片 + 地板选项,加入 Redis 异步队列,返回 job_id。用 GET /floor/status?job_id=xxx 查询进度。\n\nfloor_id 支持有尺寸变体的产品——选择变体对应 SKU 即可,AI 会根据 width_in/length_in 生成对应物理尺寸的地板。",
"requestBody": {
"content": {
"multipart/form-data": {
@@ -1940,6 +2009,16 @@
"type": "integer",
"description": "房间类型编码(0-7),见文档顶部数字编码对照表。0 或省略表示自动检测",
"example": 1
+ },
+ "grout_lines": {
+ "type": "boolean",
+ "description": "Optional. Tile floors only. true adds realistic visible grout lines between tiles.",
+ "example": false
+ },
+ "white_baseboard": {
+ "type": "boolean",
+ "description": "Optional. true adds or refreshes a realistic white baseboard/skirting board along visible wall-floor edges.",
+ "example": false
}
}
}
@@ -2204,8 +2283,8 @@
"tags": [
"地板更换"
],
- "summary": "查询生成进度(HTTP 轮询)",
- "description": "Redis 异步队列任务状态查询。status: pending | processing | done | error",
+ "summary": "查询生成进度(HTTP 轮询,含耗时统计)",
+ "description": "Redis 异步队列任务状态查询。status: pending | processing | done | error。\n\n返回 timings_ms 字段包含每步耗时:check/mask/inpaint/cleanup/total。elapsed_ms 为总运行时间。",
"parameters": [
{
"name": "job_id",
@@ -2219,7 +2298,51 @@
],
"responses": {
"200": {
- "description": "SSE 事件流"
+ "description": "Job status with timing",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "code": { "type": "integer", "example": 200 },
+ "data": {
+ "type": "object",
+ "properties": {
+ "job_id": { "type": "string" },
+ "status": { "type": "string", "description": "pending | processing | done | error" },
+ "step": { "type": "string", "description": "check | mask | inpaint | done | error" },
+ "message": { "type": "string" },
+ "result": { "type": "string", "description": "Generated image path, e.g. /outputs/result_xxx.png" },
+ "error": { "type": "string" },
+ "elapsed_ms": { "type": "integer", "description": "Total elapsed milliseconds since job start" },
+ "timings_ms": {
+ "type": "object",
+ "description": "Per-step duration in milliseconds",
+ "properties": {
+ "check": { "type": "integer" },
+ "mask": { "type": "integer" },
+ "inpaint": { "type": "integer" },
+ "total": { "type": "integer" }
+ }
+ }
+ }
+ }
+ }
+ },
+ "example": {
+ "code": 200,
+ "data": {
+ "job_id": "1784530075874083600",
+ "status": "done",
+ "step": "done",
+ "message": "Done",
+ "result": "/outputs/result_1784530075874083600.png",
+ "elapsed_ms": 100340,
+ "timings_ms": { "check": 5148, "mask": 25971, "inpaint": 69218, "total": 100340 }
+ }
+ }
+ }
+ }
}
}
}
@@ -2328,4 +2451,4 @@
}
}
}
-}
\ No newline at end of file
+}
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/cmd/import/main.go b/cmd/import/main.go
index a688ace..d2b5777 100644
--- a/cmd/import/main.go
+++ b/cmd/import/main.go
@@ -1,21 +1,52 @@
package main
+
import (
- "encoding/json"; "fmt"; "os"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
"floorvisualizer/internal/model"
"floorvisualizer/internal/repository"
)
+
func main() {
- db, _ := repository.Connect("postgres://postgres:dev123456@localhost:5432/floorvisualizer?sslmode=disable")
+ dsn := repository.DefaultDSN
+ if v := os.Getenv("DATABASE_URL"); v != "" {
+ dsn = v
+ }
+ dataDir := "data/products"
+ if v := os.Getenv("PRODUCT_DATA_DIR"); v != "" {
+ dataDir = v
+ }
+
+ db, err := repository.Connect(dsn)
+ if err != nil {
+ panic(err)
+ }
defer db.Close()
db.Exec("DELETE FROM products")
- files, _ := os.ReadDir("data/products")
+ files, err := os.ReadDir(dataDir)
+ if err != nil {
+ panic(err)
+ }
total := 0
for _, f := range files {
- if f.IsDir() { continue }
- data, _ := os.ReadFile("data/products/" + f.Name())
+ if f.IsDir() || !strings.EqualFold(filepath.Ext(f.Name()), ".json") {
+ continue
+ }
+ data, err := os.ReadFile(filepath.Join(dataDir, f.Name()))
+ if err != nil {
+ panic(err)
+ }
var prods []model.Product
- json.Unmarshal(data, &prods)
- repository.InsertProducts(db, prods)
+ if err := json.Unmarshal(data, &prods); err != nil {
+ panic(fmt.Errorf("parse %s: %w", f.Name(), err))
+ }
+ if err := repository.InsertProducts(db, prods); err != nil {
+ panic(err)
+ }
total += len(prods)
fmt.Printf(" %s: %d\n", f.Name(), len(prods))
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 5ff4426..b362d6d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -7,16 +7,14 @@ services:
- "8099:8099"
volumes:
- floor_outputs:/app/outputs
- - floor_outputs:/app/outputs
- - floor_logs:
- floor_uploads:/app/uploads
+ - floor_uploads:/app/uploads
- floor_logs:/app/logs
environment:
- PORT=8099
- DATABASE_URL=postgres://postgres:floorviz123@db:5432/floorvisualizer?sslmode=disable
- REDIS_ADDR=redis:6379
- JWT_SECRET=change-this-to-a-strong-secret-in-production
- - OPENROUTER_API_KEY=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
+ - OPENROUTER_API_KEY=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
- WORKER_COUNT=5
- LOG_DIR=/app/logs
- LOG_LEVEL=INFO
diff --git a/go.mod b/go.mod
index 07dc8eb..6fab5c8 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,10 @@ go 1.26.4
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/lib/pq v1.12.3
+ github.com/redis/go-redis/v9 v9.21.0
golang.org/x/image v0.43.0
+ gorm.io/driver/postgres v1.6.0
+ gorm.io/gorm v1.31.2
)
require (
@@ -16,11 +19,9 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
- github.com/redis/go-redis/v9 v9.21.0 // indirect
+ github.com/joho/godotenv v1.5.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/text v0.38.0 // indirect
- gorm.io/driver/postgres v1.6.0 // indirect
- gorm.io/gorm v1.31.2 // indirect
)
diff --git a/go.sum b/go.sum
index e413204..f2ac7f9 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,12 @@
+github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
+github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -15,14 +21,25 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
+github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
+github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
@@ -31,11 +48,17 @@ golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
+gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
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
new file mode 100644
index 0000000..02d6e64
--- /dev/null
+++ b/internal/api/index_handler.go
@@ -0,0 +1,23 @@
+package api
+
+import "net/http"
+
+func Index(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ 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
new file mode 100644
index 0000000..f700edb
--- /dev/null
+++ b/internal/api/index_html.go
@@ -0,0 +1,9 @@
+package api
+
+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 7526cfb..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)
@@ -63,6 +68,7 @@ func RegisterRoutes(mux *http.ServeMux, client *openrouter.Client, db *sql.DB, a
mux.HandleFunc("/articles/recommend", articleRecommend)
mux.HandleFunc("/articles", articles)
mux.HandleFunc("/articles/", articlesSub)
+ mux.HandleFunc("/", Index)
// Static files
mux.Handle("/outputs/", http.StripPrefix("/outputs/", http.FileServer(http.Dir("./outputs"))))
diff --git a/internal/handler/floor_handler.go b/internal/handler/floor_handler.go
index 9ada160..ad8d8d0 100644
--- a/internal/handler/floor_handler.go
+++ b/internal/handler/floor_handler.go
@@ -3,6 +3,7 @@ package handler
import (
"context"
"database/sql"
+ "encoding/json"
"fmt"
"io"
"net/http"
@@ -15,18 +16,23 @@ import (
"floorvisualizer/internal/repository"
)
-// ── Floor options ──────────────────────────────────────────
+// 閳光偓閳光偓 Floor options 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
type FloorOption struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Category string `json:"category"`
- Material string `json:"material"`
- ColorTone string `json:"color_tone"`
- Finish string `json:"finish"`
- SizeLabel string `json:"size_label"`
- ImageURL string `json:"image_url"`
- SKU string `json:"sku"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Category string `json:"category"`
+ Material string `json:"material"`
+ ColorTone string `json:"color_tone"`
+ Finish string `json:"finish"`
+ SizeLabel string `json:"size_label"`
+ WidthIn float64 `json:"width_in"`
+ LengthIn float64 `json:"length_in"`
+ ImageURL string `json:"image_url"`
+ SKU string `json:"sku"`
+ Description string `json:"description"`
+ FloorDescription string `json:"floor_description,omitempty"`
+ Variants []FloorOption `json:"variants,omitempty"`
}
type PatternOption struct {
@@ -42,29 +48,29 @@ type RoomOption struct {
}
var woodPatterns = []PatternOption{
- {Code: 1, Name: "Straight Lay", Category: "wood", Pattern: "straight/parallel planks running from front to back with staggered joints"},
- {Code: 2, Name: "Horizontal Lay", Category: "wood", Pattern: "horizontal planks running side to side across the width of the room"},
- {Code: 3, Name: "Herringbone", Category: "wood", Pattern: "herringbone/zigzag pattern with rectangular planks arranged in a broken V shape"},
- {Code: 4, Name: "Chevron", Category: "wood", Pattern: "chevron pattern with planks cut at an angle forming a continuous V shape"},
+ {Code: 1, Name: "直铺(工字拼)Straight Lay", Category: "wood", Pattern: "planks running from bottom-left toward top-right of the image, joints staggered randomly, at least 6 inch offset between rows"},
+ {Code: 2, Name: "横铺 Horizontal Lay", Category: "wood", Pattern: "planks running horizontally left to right, parallel to the bottom edge of the image, joints staggered randomly, at least 6 inch offset"},
+ {Code: 3, Name: "人字铺 Herringbone", Category: "wood", Pattern: "ALL planks must be IDENTICAL width - no piece may appear narrower. Planks at 90 degrees forming continuous zigzag V-lines from bottom-left to top-right of the image. At far distance, planks remain distinct with visible seams - NOT merging into a solid mass."},
+ {Code: 4, Name: "鱼骨铺 Chevron", Category: "wood", Pattern: "planks with 45 degree mitered ends forming continuous V-points aligned from bottom-left to top-right of the image"},
}
var tilePatterns = []PatternOption{
- {Code: 5, Name: "Straight Grid", Category: "tile", Pattern: "straight grid layout with tiles perfectly aligned in rows and columns, clean orthogonal grout lines"},
- {Code: 6, Name: "Running Bond", Category: "tile", Pattern: "running bond/brick pattern with each row offset by 50%, staggered brickwork appearance"},
- {Code: 7, Name: "1/3 Offset", Category: "tile", Pattern: "tiles offset by 1/3 of their length in each row, subtle stepped pattern"},
- {Code: 8, Name: "Hexagonal", Category: "tile", Pattern: "hexagonal/honeycomb tiles fitted together in a seamless honeycomb mesh"},
- {Code: 9, Name: "Diagonal (45deg)", Category: "tile", Pattern: "tiles laid at a 45-degree diagonal angle to the walls, diamond orientation"},
+ {Code: 5, Name: "直铺(网格对缝)Straight Grid", Category: "tile", Pattern: "tiles aligned in rows (parallel to bottom edge) and columns (perpendicular to bottom edge), all grout lines continuous, no offset"},
+ {Code: 6, Name: "工字铺(1/2错缝)Running Bond", Category: "tile", Pattern: "rows parallel to bottom edge of image, each row offset 50% from previous, horizontal grout lines continuous, vertical grout lines staggered"},
+ {Code: 7, Name: "三七错铺 1/3 Offset", Category: "tile", Pattern: "rows parallel to bottom edge of image, each row offset exactly 33% of tile length, every third row aligns"},
+ {Code: 8, Name: "六边形 Hexagonal", Category: "tile", Pattern: "six-sided tiles interlocked in honeycomb mesh, flat edges horizontal (parallel to bottom edge of image), grout follows hexagonal edges"},
+ {Code: 9, Name: "斜铺(60度对角)Diagonal", Category: "tile", Pattern: "tiles rotated 60 degrees to the image frame, grout lines run diagonally at 60 and 150 degrees to the bottom edge, no lines parallel to image edges"},
}
var roomOptions = []RoomOption{
- {Code: 0, Name: "Auto Detect"},
- {Code: 1, Name: "Living Room"},
- {Code: 2, Name: "Bedroom"},
- {Code: 3, Name: "Kitchen"},
- {Code: 4, Name: "Dining Room"},
- {Code: 5, Name: "Bathroom"},
- {Code: 6, Name: "Study Room"},
- {Code: 7, Name: "Hallway"},
+ {Code: 0, Name: "自动识别"},
+ {Code: 1, Name: "客厅"},
+ {Code: 2, Name: "卧室"},
+ {Code: 3, Name: "厨房"},
+ {Code: 4, Name: "餐厅"},
+ {Code: 5, Name: "浴室"},
+ {Code: 6, Name: "书房"},
+ {Code: 7, Name: "走廊"},
}
func findPatternByCode(code int) *PatternOption {
@@ -88,46 +94,112 @@ func findRoomByCode(code int) *RoomOption {
return nil
}
-// loadFloorOptions loads floor options from the database, one per style_name per brand.
-func loadFloorOptions(db *sql.DB, category string) ([]FloorOption, error) {
- prods, _, err := repository.QueryFilteredProducts(db, repository.FilterParams{
- Category: category,
- Limit: 200,
- })
+// loadFloorOptions loads all products, deduplicated by brand+style_name. Variants
+// (different sizes of the same style) are attached to the representative entry.
+func loadFloorOptions(db *sql.DB) ([]FloorOption, error) {
+ // Use raw query without DISTINCT ON so all size variants are returned
+ rows, err := db.Query(`SELECT brand, group_name, sku, series_name, style_name,
+ category, material, color_tone, finish, price_per_sqft, price_tier, price_source,
+ is_official_price, main_image_url, room_image_url, source_url, description, status,
+ COALESCE(coverage_sqft_per_box,0), COALESCE(wood_species,''),
+ size_label, COALESCE(width_in,0), COALESCE(length_in,0)
+ FROM products ORDER BY brand, style_name, id LIMIT 5000`)
if err != nil {
return nil, err
}
- // Dedup by brand+style_name, take first as representative
- seen := map[string]bool{}
+ defer rows.Close()
+ prods, err := repository.ScanProducts(rows)
+ if err != nil {
+ return nil, err
+ }
+ type key struct{ brand, style string }
+ seen := map[key]int{} // key 閳?index in out slice
var out []FloorOption
for _, p := range prods {
- key := p.Brand + "|" + p.StyleName
- if seen[key] {
- continue
+ k := key{p.Brand, p.StyleName}
+ if idx, ok := seen[k]; ok {
+ // Add as variant to the representative entry
+ out[idx].Variants = append(out[idx].Variants, FloorOption{
+ SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn,
+ SKU: p.SKU,
+ })
+
+ } else {
+ seen[k] = len(out)
+ out = append(out, FloorOption{
+ ID: p.SKU, Name: p.StyleName, Category: p.Category,
+ Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish,
+ SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn,
+ ImageURL: p.MainImageURL, SKU: p.SKU, Description: p.Description,
+ })
}
- seen[key] = true
- out = append(out, FloorOption{
- ID: p.SKU, Name: p.StyleName, Category: p.Category,
- Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish,
- SizeLabel: p.SizeLabel, ImageURL: p.MainImageURL, SKU: p.SKU,
- })
}
return out, nil
}
+func isTileProduct(p FloorOption) bool {
+ m := strings.ToLower(p.Material)
+ return strings.Contains(m, "porcelain") || strings.Contains(m, "ceramic") ||
+ strings.Contains(m, "stone look") || m == "glass"
+}
+func isVinylProduct(p FloorOption) bool {
+ m := strings.ToLower(p.Material)
+ return m == "luxury vinyl" || strings.Contains(m, "rigid core") || m == "vinyl"
+}
+func isLaminateProduct(p FloorOption) bool {
+ return strings.EqualFold(p.Material, "laminate")
+}
+func isWoodProduct(p FloorOption) bool {
+ return !isTileProduct(p) && !isVinylProduct(p) && !isLaminateProduct(p)
+}
+
func findFloorBySKU(db *sql.DB, sku string) *FloorOption {
p, err := repository.GetProductBySKU(db, sku)
if err != nil {
return nil
}
- return &FloorOption{
+ f := &FloorOption{
ID: p.SKU, Name: p.StyleName, Category: p.Category,
Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish,
- SizeLabel: p.SizeLabel, ImageURL: p.MainImageURL, SKU: p.SKU,
+ SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn,
+ ImageURL: p.MainImageURL, SKU: p.SKU, Description: p.Description,
}
+ // Fetch size variants (same style, different dimensions)
+ variants, _ := repository.GetVariantsByStyle(db, sku)
+ for _, vp := range variants {
+ f.Variants = append(f.Variants, FloorOption{
+ SizeLabel: vp.SizeLabel, WidthIn: vp.WidthIn, LengthIn: vp.LengthIn,
+ SKU: vp.SKU,
+ })
+ }
+ // Canonical reference image = smallest size variant (by width)
+ // All sizes of the same style use the same reference image to prevent color drift
+ canonicalSKU, canonicalImage, canonicalWidth, canonicalLength := p.SKU, p.MainImageURL, f.WidthIn, f.LengthIn
+ rows, _ := db.Query(`SELECT sku, main_image_url, COALESCE(width_in,0), COALESCE(length_in,0) FROM products
+ WHERE style_name = $1 AND brand = $2
+ ORDER BY COALESCE(width_in,999), COALESCE(length_in,999) LIMIT 1`,
+ p.StyleName, p.Brand)
+ if rows != nil {
+ defer rows.Close()
+ if rows.Next() {
+ rows.Scan(&canonicalSKU, &canonicalImage, &canonicalWidth, &canonicalLength)
+ }
+ }
+ f.ImageURL = canonicalImage
+
+ // Build dynamic size instruction if selected size differs from reference image size
+ if (canonicalWidth > 0 && canonicalLength > 0) && (f.WidthIn > 0 && f.LengthIn > 0) {
+ if canonicalWidth != f.WidthIn || canonicalLength != f.LengthIn {
+ f.Description = fmt.Sprintf(
+ `尺寸映射:参考图显示 %.1f" x %.1f" 的板/砖;本次生成 %.1f" x %.1f" 的板/砖。材质必须完全相同,只按比例调整板/砖尺寸。 %s`,
+ canonicalWidth, canonicalLength, f.WidthIn, f.LengthIn, f.Description,
+ )
+ }
+ }
+ return f
}
-// ── Public accessors for worker ────────────────────────────
+// 閳光偓閳光偓 Public accessors for worker 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
func FindFloorBySKUPublic(db *sql.DB, sku string) *FloorOption { return findFloorBySKU(db, sku) }
func FindPatternByCodePublic(code string) *PatternOption {
@@ -142,10 +214,42 @@ func FindRoomByCodePublic(code string) *RoomOption {
}
func BuildMaskPromptPublic(room *RoomOption) string { return buildMaskPrompt(room) }
func BuildFloorPromptPublic(f FloorOption, p PatternOption, room *RoomOption) string {
- return buildFloorPrompt(f, p, room)
+ return buildCompactFloorPrompt(f, p, room)
}
-// ── Floor Generate Handler (Redis queue) ──────────────────
+// BuildCombinedFloorPrompt builds a single prompt that identifies the floor area AND replaces it.
+// No separate mask generation step 閳?the AI does both in one pass.
+func BuildCombinedFloorPrompt(f FloorOption, p PatternOption, room *RoomOption, groutLines, whiteBaseboard bool) string {
+ return buildCompactCombinedFloorPrompt(f, p, room, groutLines, whiteBaseboard)
+}
+
+// buildPatternConstraint returns installation rules specific to each pattern code.
+func buildPatternConstraint(code int) string {
+ switch code {
+ case 1:
+ return "铺法规则:木地板直铺,板材连续铺设,接缝随机错开至少 6 英寸。"
+ case 2:
+ return "铺法规则:木地板横铺,板材沿房间主方向铺设,接缝随机错开至少 6 英寸。"
+ case 3:
+ return "铺法规则:人字拼,板材 90 度相接形成连续折线,每片板宽一致,接缝清楚。"
+ case 4:
+ return "铺法规则:鱼骨拼,板材 45 度斜切端形成连续 V 形,必须是斜切对接。"
+ case 5:
+ return "铺法规则:瓷砖直铺网格对缝,砖块成行成列对齐,无错缝,砖缝约 1/8 英寸。"
+ case 6:
+ return "铺法规则:瓷砖工字铺,相邻行错开砖长的 50%,行向砖缝连续,竖向接缝错开。"
+ case 7:
+ return "铺法规则:瓷砖三七错铺,相邻行错开砖长的 33%,每三行重新对齐,不是 50% 工字铺。"
+ case 8:
+ return "铺法规则:六边形瓷砖,六边形砖互锁成蜂窝网,砖缝沿六边形边缘,不是方格网。"
+ case 9:
+ return "铺法规则:瓷砖斜铺,砖块相对墙边形成菱形或三角切砖效果,砖缝约 1/8 英寸。"
+ default:
+ return ""
+ }
+}
+
+// 閳光偓閳光偓 Floor Generate Handler (Redis queue) 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
func SetQueue(enqueue func(ctx context.Context, jobID string, payload map[string]string) error) {
generateQueue = enqueue
@@ -216,10 +320,13 @@ func FloorGenerate(client *openrouter.Client, db *sql.DB) http.HandlerFunc {
}
// Store job payload then enqueue
payload := map[string]string{
- "input_path": inputPath,
- "floor_id": r.FormValue("floor_id"),
- "pattern_code": r.FormValue("pattern_code"),
- "room_code": r.FormValue("room_code"),
+ "input_path": inputPath,
+ "floor_id": r.FormValue("floor_id"),
+ "pattern_code": r.FormValue("pattern_code"),
+ "room_code": r.FormValue("room_code"),
+ "grout_lines": boolFormValue(r, "grout_lines"),
+ "white_baseboard": boolFormValue(r, "white_baseboard"),
+ "material_source": r.FormValue("material_source"),
}
if err := generateQueue(r.Context(), jobID, payload); err != nil {
writeErr(w, 500, "Failed to enqueue job")
@@ -230,9 +337,9 @@ func FloorGenerate(client *openrouter.Client, db *sql.DB) http.HandlerFunc {
}
}
-// ── Progress ───────────────────────────────────────────────
+// 閳光偓閳光偓 Progress 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
-// ── Floor Status Query ─────────────────────────────────
+// 閳光偓閳光偓 Floor Status Query 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
func FloorStatus(w http.ResponseWriter, r *http.Request) {
jobID := r.URL.Query().Get("job_id")
@@ -252,69 +359,218 @@ func FloorStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, js)
}
-// ── Options ────────────────────────────────────────────────
+// 閳光偓閳光偓 Options 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
func FloorOptions(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- woodFloors, _ := loadFloorOptions(db, "Hardwood") // non-critical: returns empty on error
- woodFloors2, _ := loadFloorOptions(db, "Engineered Wood")
- tileFloors, _ := loadFloorOptions(db, "Wood-Look Tile")
- vinylFloors, _ := loadFloorOptions(db, "SPC/LVP")
- laminateFloors, _ := loadFloorOptions(db, "Laminate")
+ all, _ := loadFloorOptions(db)
+ var allWood, allTile, vinylFloors []FloorOption
+ for _, f := range all {
+ switch {
+ case isVinylProduct(f):
+ vinylFloors = append(vinylFloors, f)
+ case isTileProduct(f) || isLaminateProduct(f):
+ allTile = append(allTile, f)
+ default:
+ allWood = append(allWood, f)
+ }
+ }
- allWood := append(woodFloors, woodFloors2...)
- allTile := append(tileFloors, laminateFloors...)
+ // Load special test products with floor descriptions
+ specialFloors := loadSpecialFloors(db)
writeJSON(w, 200, map[string]any{
- "wood_floors": allWood,
- "wood_patterns": woodPatterns,
- "tile_floors": allTile,
- "tile_patterns": tilePatterns,
- "vinyl_floors": vinylFloors,
- "rooms": roomOptions,
+ "wood_floors": allWood,
+ "wood_patterns": woodPatterns,
+ "tile_floors": allTile,
+ "tile_patterns": tilePatterns,
+ "vinyl_floors": vinylFloors,
+ "special_floors": specialFloors,
+ "rooms": roomOptions,
})
}
}
-// ── Prompts ────────────────────────────────────────────────
+// materialIndex caches SKU 閳?material description from MaterialAssets JSON files.
+var materialIndex map[string]string
+
+// LoadMaterialIndex loads the material description index from a JSON file.
+func LoadMaterialIndex(path string) error {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal(data, &materialIndex)
+}
+
+// LookupMaterialDesc returns the material description for a given SKU from the loaded index.
+func LookupMaterialDesc(sku string) string {
+ if materialIndex == nil {
+ return ""
+ }
+ return materialIndex[sku]
+}
+
+// loadSpecialFloors loads the 7 special test products with pre-generated floor descriptions.
+func loadSpecialFloors(db *sql.DB) []FloorOption {
+ data, err := os.ReadFile("outputs/special_7_products.json")
+ if err != nil {
+ return nil
+ }
+ var raw []struct {
+ Brand string `json:"brand"`
+ Style string `json:"style"`
+ Material string `json:"material"`
+ Description string `json:"description"`
+ ImageURL string `json:"image_url"`
+ }
+ if json.Unmarshal(data, &raw) != nil {
+ return nil
+ }
+ var out []FloorOption
+ for _, r := range raw {
+ // Find any real SKU for this style to get DB variants and sizes
+ var sku string
+ db.QueryRow(`SELECT sku FROM products WHERE style_name=$1 AND brand=$2 LIMIT 1`,
+ r.Style, r.Brand).Scan(&sku)
+ if sku == "" {
+ continue
+ }
+ f := findFloorBySKU(db, sku)
+ if f == nil {
+ continue
+ }
+ f.FloorDescription = r.Description
+ // Keep f.ImageURL from DB (canonical smallest) 閳?do NOT override with local path
+ out = append(out, *f)
+ }
+ return out
+}
+
+// 閳光偓閳光偓 Prompts 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
func buildMaskPrompt(room *RoomOption) string {
- base := `Create a pure black-and-white mask image for this room photo.
-WHITE (#FFFFFF) areas: ONLY the flat horizontal floor.
-BLACK (#000000) areas — everything else: stairs, baseboards, walls, furniture, doors, windows, ceiling, decorations.
-Output ONLY the mask image. No text.`
+ base := "你是精确的地面分割工具。请为这张室内照片生成纯黑白遮罩图。\n\n" +
+ "严格定义:\n" +
+ "- 白色(#FFFFFF):只表示平整、水平、可行走的地面。包含所有可见地面,一直到墙面、踢脚线、柜体或楼梯边缘。\n" +
+ "- 黑色(#000000):所有非水平地面的内容,包括墙面、踢脚线、楼梯、家具、家具腿、门窗、柜体、电器、地毯、天花板、人物、宠物。\n\n" +
+ "边缘规则:\n" +
+ "- 墙地交界处必须沿交界线精确切分;踢脚线为黑色,地面为白色。\n" +
+ "- 家具腿和椅轮为黑色;家具腿之间和周围真实可见的地面为白色。\n" +
+ "- 楼梯和地毯都标为黑色。\n\n" +
+ "只输出遮罩图片,不要文字说明。"
if room != nil && room.Code != 0 {
- base += fmt.Sprintf("\nContext: This is a %s.", room.Name)
+ base += fmt.Sprintf("\n房间类型:%s。", room.Name)
}
return base
}
-func buildFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption) string {
- var roomCtx string
+func buildMaterialRules(floor FloorOption) string {
+ if isTileProduct(floor) || isLaminateProduct(floor) {
+ return "整片地面必须是同一个 SKU,基础颜色在全地面保持一致。允许原图阴影让局部变暗,但不能改变地砖色相或材质。瓷砖表面平整,缝隙连续、透视一致;不要生成局部偏绿、偏蓝、偏黄、偏青的色块。反光只能来自原图已有光照,不能新增窗户反光、亮斑或湿地板效果。"
+ }
+ return "地面必须保持同一个 SKU 的颜色、纹理、木纹或板纹和光泽。保留原图已有光照和阴影;不要新增反光、亮斑或阴影。板缝透视一致,纹理自然但不能明显重复。"
+}
+
+func buildCompactCombinedFloorPrompt(f FloorOption, p PatternOption, room *RoomOption, groutLines, whiteBaseboard bool) string {
+ roomName := "自动识别"
if room != nil && room.Code != 0 {
- roomCtx = fmt.Sprintf("\nRoom context: This is a %s.", room.Name)
- }
- matType := "wood plank"
- isTile := floor.Category == "Wood-Look Tile" || floor.Category == "Laminate"
- if isTile {
- matType = "tile"
+ roomName = room.Name
}
- materialDesc := fmt.Sprintf("%s material, %s tone, %s finish", floor.Material, floor.ColorTone, floor.Finish)
- if floor.SizeLabel != "" {
- materialDesc += fmt.Sprintf(", %s size", floor.SizeLabel)
+ var priority []string
+ if whiteBaseboard {
+ priority = append(priority, "最高优先级:必须生成白色踢脚线。\n踢脚线属于可编辑区域,不属于墙面保护区域。\n沿所有可见墙地交界处生成连续白色踢脚线,高约 4-6 英寸。\n踢脚线应为真实白色木质或漆面条带,有轻微厚度和接触阴影。\n如果没有明显、连续的白色踢脚线,结果视为失败。")
+ }
+ if groutLines {
+ priority = append(priority, "必须生成清晰、等距、连续的瓷砖缝。")
}
- return fmt.Sprintf(
- `Replace ONLY the white-masked floor area with %s (%s) %s flooring in a %s pattern.%s
-Material details: %s | Layout: %s
-Style: photorealistic, soft natural matte finish, minimal reflections, no mirror-like gloss, seamless blend with room lighting.`,
- floor.Name, matType, materialDesc, pattern.Name, roomCtx,
- materialDesc, pattern.Pattern,
+ priorityText := ""
+ if len(priority) > 0 {
+ priorityText = strings.Join(priority, "\n\n") + "\n\n"
+ }
+
+ editScope := "只替换地面"
+ if whiteBaseboard {
+ editScope = "只替换地面和白色踢脚线"
+ }
+
+ return fmt.Sprintf("%s你是室内照片编辑工具,不是重新生成图片。\n房间类型:%s。\n\n任务:\n%s。墙面、家具、桌椅、门窗、天花板保持原图不变。\n保持原图尺寸、构图、相机角度、光照、阴影、白平衡不变。\n保持原图房间空间尺度:如果原图是小房间,生成后仍必须是同一间小房间;不要拉远视角,不要扩大房间深度,不要让瓷砖尺寸呈现大空间远视效果。\n\n%s\n\n输出:\n只返回完成后的图片,不要文字、不要 JSON。",
+ priorityText,
+ roomName,
+ editScope,
+ buildCompactFloorPrompt(f, p, room),
)
}
-// ── Content Check ─────────────────────────────────────────
+func buildCompactFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption) string {
+ matType := "木地板"
+ if isTileProduct(floor) || isLaminateProduct(floor) {
+ matType = "瓷砖"
+ }
+
+ materialDesc := fmt.Sprintf("%s,%s色调,%s表面", floor.Material, floor.ColorTone, floor.Finish)
+ if floor.SizeLabel != "" {
+ materialDesc += ",尺寸 " + floor.SizeLabel
+ }
+
+ var details []string
+ if floor.Description != "" {
+ details = append(details, "产品描述:"+floor.Description)
+ }
+ if floor.FloorDescription != "" {
+ details = append(details, "目录材质描述:"+floor.FloorDescription)
+ }
+ if floor.SizeLabel != "" || (floor.WidthIn > 0 && floor.LengthIn > 0) {
+ details = append(details, compactSizeInstruction(floor))
+ }
+ if floor.ImageURL != "" {
+ details = append(details, "产品图是唯一材质参考:锁定基础颜色、纹理、纹路、光泽、材质类型;不要重新美化或改色。")
+ }
+ if guard := compactProductNameGuard(floor); guard != "" {
+ details = append(details, guard)
+ }
+
+ return fmt.Sprintf("地板产品:\nSKU:%s\n产品名:%s(这是商品目录名,不是视觉效果指令)\n类型:%s\n材质:%s\n铺法:%s。%s\n\n材质规则:\n%s\n%s\n\n禁止:\nno wet floor, no puddles, no water stains, no color shift, no green/blue/yellow/cyan patches, no new lights, no new shadows, no CGI, no text, no watermark。",
+ floor.SKU,
+ floor.Name,
+ matType,
+ materialDesc,
+ pattern.Name,
+ pattern.Pattern,
+ buildMaterialRules(floor),
+ strings.Join(details, "\n"),
+ )
+}
+
+func compactSizeInstruction(floor FloorOption) string {
+ if floor.SizeLabel != "" {
+ return "尺寸规则:使用所选尺寸 " + floor.SizeLabel + ";只调整板/砖大小和缝距,不改变材质颜色和纹理。"
+ }
+ if floor.WidthIn > 0 && floor.LengthIn > 0 {
+ return fmt.Sprintf("尺寸规则:每片 %.1f\" x %.1f\";只调整板/砖大小和缝距,不改变材质颜色和纹理。", floor.WidthIn, floor.LengthIn)
+ }
+ return ""
+}
+
+func compactProductNameGuard(floor FloorOption) string {
+ text := strings.ToLower(floor.Name + " " + floor.Description + " " + floor.FloorDescription)
+ for _, word := range []string{"rain", "puddle", "water", "wet", "storm", "mist", "fog", "snow", "ice"} {
+ if strings.Contains(text, word) {
+ return "产品名防误解:不要按产品名生成雨水、积水、水渍、雾气、冰雪、湿地板或彩色斑块;只看产品参考图和结构化材质字段。"
+ }
+ }
+ return ""
+}
+
+func buildFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption) string {
+ return buildCompactFloorPrompt(floor, pattern, room)
+}
+
+func buildProductNameGuard(floor FloorOption) string {
+ return compactProductNameGuard(floor)
+}
func validateContent(client *openrouter.Client, ctx context.Context, imagePath string) (bool, int) {
imgData, err := os.ReadFile(imagePath)
@@ -322,13 +578,12 @@ func validateContent(client *openrouter.Client, ctx context.Context, imagePath s
return false, 0
}
dataURI := "data:image/png;base64," + b64enc(imgData)
+ prompt := "请按 1-10 分评估这张图片是否适合做室内地面替换。\n8-10 分:清晰室内房间,地面可见。\n5-7 分:室内图片,但角度或地面可见度一般。\n1-4 分:室外、宠物特写、物体特写、没有可替换地面,或不适合做地面替换。\n只返回一个数字(1-10)。"
resp, err := client.Chat(ctx, openrouter.ChatRequest{
Model: "google/gemini-2.5-flash",
Messages: []openrouter.Message{{Role: "user", Content: []openrouter.ContentPart{
{Type: "image_url", ImageURL: &openrouter.ImageURL{URL: dataURI}},
- {Type: "text", Text: `Score this image 1-10 for indoor floor replacement suitability.
-8-10: Clear indoor room, floor visible. 5-7: Indoor but poor angle. 1-4: Outdoor/pets/objects/no floor.
-Return ONLY a number (1-10).`},
+ {Type: "text", Text: prompt},
}}},
})
if err != nil || len(resp.Choices) == 0 {
@@ -337,7 +592,6 @@ Return ONLY a number (1-10).`},
score := parseScore(resp.Choices[0].Message.Content)
return score >= 6, score
}
-
func parseScore(raw string) int {
for _, s := range []string{"10", "9", "8", "7", "6", "5", "4", "3", "2", "1"} {
if len(raw) >= len(s) && raw[:len(s)] == s {
@@ -375,3 +629,13 @@ func b64enc(data []byte) string {
}
return b.String()
}
+
+func boolFormValue(r *http.Request, key string) string {
+ value := strings.ToLower(strings.TrimSpace(r.FormValue(key)))
+ switch value {
+ case "1", "true", "yes", "y", "on":
+ return "true"
+ default:
+ return "false"
+ }
+}
diff --git a/internal/handler/product_handler.go b/internal/handler/product_handler.go
index 25a4a42..c351886 100644
--- a/internal/handler/product_handler.go
+++ b/internal/handler/product_handler.go
@@ -26,6 +26,7 @@ type ProductItem struct {
MainImageURL string `json:"main_image_url"`
SizeLabel string `json:"size_label"`
CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"`
+ IsOfficialPrice bool `json:"is_official_price"`
IsFavorited bool `json:"is_favorited"`
SpecCount int `json:"spec_count"`
}
@@ -152,6 +153,7 @@ func Products(db *sql.DB) http.HandlerFunc {
PricePerSqft: p.PricePerSqft, PriceTier: p.PriceTier,
MainImageURL: p.MainImageURL, SizeLabel: p.SizeLabel,
CoverageSqftPerBox: p.CoverageSqftPerBox,
+ IsOfficialPrice: p.IsOfficialPrice,
IsFavorited: favSKUs[p.SKU],
SpecCount: specCounts[p.Brand+"|"+p.StyleName+"|"+p.SeriesName],
})
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/model/product.go b/internal/model/product.go
index c5d3e45..7968124 100644
--- a/internal/model/product.go
+++ b/internal/model/product.go
@@ -1,31 +1,32 @@
package model
type Product struct {
- Brand string `json:"brand"`
- GroupName string `json:"group_name"`
- SKU string `json:"sku"`
- SeriesName string `json:"series_name"`
- StyleName string `json:"style_name"`
- Category string `json:"category"`
- Material string `json:"material"`
- ColorTone string `json:"color_tone"`
- Finish string `json:"finish"`
- PricePerSqft float64 `json:"price_per_sqft"`
- PriceTier string `json:"price_tier"`
- PriceSource string `json:"price_source"`
- MainImageURL string `json:"main_image_url"`
- RoomImageURL string `json:"room_image_url"`
- SourceURL string `json:"source_url"`
- Description string `json:"description"`
- Status string `json:"status"`
- CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"`
- WoodSpecies string `json:"wood_species"`
- SizeLabel string `json:"size_label"`
- WidthIn float64 `json:"width_in"`
- LengthIn float64 `json:"length_in"`
- Variants []Product `json:"variants,omitempty"`
- Specs []ProductSpec `json:"specs,omitempty"`
- IsFavorited bool `json:"is_favorited"`
+ Brand string `json:"brand"`
+ GroupName string `json:"group_name"`
+ SKU string `json:"sku"`
+ SeriesName string `json:"series_name"`
+ StyleName string `json:"style_name"`
+ Category string `json:"category"`
+ Material string `json:"material"`
+ ColorTone string `json:"color_tone"`
+ Finish string `json:"finish"`
+ PricePerSqft float64 `json:"price_per_sqft"`
+ PriceTier string `json:"price_tier"`
+ PriceSource string `json:"price_source"`
+ IsOfficialPrice bool `json:"is_official_price"`
+ MainImageURL string `json:"main_image_url"`
+ RoomImageURL string `json:"room_image_url"`
+ SourceURL string `json:"source_url"`
+ Description string `json:"description"`
+ Status string `json:"status"`
+ CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"`
+ WoodSpecies string `json:"wood_species"`
+ SizeLabel string `json:"size_label"`
+ WidthIn float64 `json:"width_in"`
+ LengthIn float64 `json:"length_in"`
+ Variants []Product `json:"variants,omitempty"`
+ Specs []ProductSpec `json:"specs,omitempty"`
+ IsFavorited bool `json:"is_favorited"`
}
// ProductSpec is a single size/price variant of a product style.
diff --git a/internal/openrouter/image.go b/internal/openrouter/image.go
index f2e872e..b696efe 100644
--- a/internal/openrouter/image.go
+++ b/internal/openrouter/image.go
@@ -7,6 +7,7 @@ import (
stderrors "errors"
"fmt"
"image"
+ "image/color"
_ "image/gif"
_ "image/jpeg"
"image/png"
@@ -22,7 +23,9 @@ import (
const Gemini3ProImagePreviewModel = "google/gemini-3-pro-image-preview"
-const maskImageInstruction = "The next image is a mask. Non-black pixels mark regions to edit; black pixels mark protected regions that must remain unchanged."
+const maskImageInstruction = "下一张图片是遮罩图。非黑色像素表示允许编辑的区域;黑色像素表示必须保持不变的保护区域。"
+
+const referenceImageInstruction = "下一张图片是地板材质参考图。生成新地面时必须严格匹配它的颜色、纹理、图案、光泽和整体材质外观;生成的地面应看起来就是这个材质。"
var supportedAspectRatios = []struct {
value string
@@ -51,6 +54,8 @@ type ImageGenerationRequest struct {
InputMimeType string
MaskImagePath string
MaskMimeType string
+ ReferenceImagePath string // optional: reference image for floor material style
+ ReferenceMimeType string
AspectRatio string
ImageSize string
TopP *float64
@@ -284,7 +289,7 @@ func buildImageGenerationContent(req ImageGenerationRequest) ([]ContentPart, err
return nil, stderrors.New("openrouter: mask image requires input image")
}
- content := make([]ContentPart, 0, 4)
+ content := make([]ContentPart, 0, 6)
if req.InputImagePath != "" {
dataURI, err := imageFileDataURI(req.InputImagePath, req.InputMimeType)
if err != nil {
@@ -306,6 +311,17 @@ func buildImageGenerationContent(req ImageGenerationRequest) ([]ContentPart, err
ImageURL: &ImageURL{URL: dataURI},
})
}
+ if req.ReferenceImagePath != "" {
+ dataURI, err := imageFileDataURI(req.ReferenceImagePath, req.ReferenceMimeType)
+ if err != nil {
+ return nil, err
+ }
+ content = append(content, ContentPart{
+ Type: "image_url",
+ ImageURL: &ImageURL{URL: dataURI},
+ })
+ content = append(content, ContentPart{Type: "text", Text: referenceImageInstruction})
+ }
content = append(content, ContentPart{Type: "text", Text: req.Prompt})
return content, nil
}
@@ -464,3 +480,164 @@ func decodeDataURL(dataURL string) (string, []byte, error) {
return mimeType, data, nil
}
+
+// MaxImageDimension is the maximum width or height for images sent to the AI API.
+const MaxImageDimension = 2048
+
+// ResizeImageToFit reads an image, scales it so the longest side ≤ maxDim pixels,
+// and writes the result as PNG.
+func ResizeImageToFit(inputPath, outputPath string) error {
+ data, err := os.ReadFile(inputPath)
+ if err != nil {
+ return fmt.Errorf("resize: read input: %w", err)
+ }
+ src, _, err := image.Decode(bytes.NewReader(data))
+ if err != nil {
+ return fmt.Errorf("resize: decode: %w", err)
+ }
+ b := src.Bounds()
+ w, h := b.Dx(), b.Dy()
+ longest := w
+ if h > longest {
+ longest = h
+ }
+ dst := src
+ if longest > MaxImageDimension {
+ scale := float64(MaxImageDimension) / float64(longest)
+ newW := int(float64(w)*scale + 0.5)
+ newH := int(float64(h)*scale + 0.5)
+ resized := image.NewRGBA(image.Rect(0, 0, newW, newH))
+ draw.ApproxBiLinear.Scale(resized, resized.Bounds(), src, src.Bounds(), draw.Over, nil)
+ dst = resized
+ }
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, dst); err != nil {
+ return fmt.Errorf("resize: encode png: %w", err)
+ }
+ if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
+ return err
+ }
+ return os.WriteFile(outputPath, buf.Bytes(), 0644)
+}
+
+// RepairNearBlackBorder removes accidental model-added black canvas edges.
+// It only acts when an entire edge row/column is almost uniformly black.
+func RepairNearBlackBorder(path string) error {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("repair border: read image: %w", err)
+ }
+ src, _, err := image.Decode(bytes.NewReader(data))
+ if err != nil {
+ return fmt.Errorf("repair border: decode image: %w", err)
+ }
+ b := src.Bounds()
+ w, h := b.Dx(), b.Dy()
+ if w < 8 || h < 8 {
+ return nil
+ }
+
+ img := image.NewRGBA(image.Rect(0, 0, w, h))
+ for y := 0; y < h; y++ {
+ for x := 0; x < w; x++ {
+ img.Set(x, y, src.At(b.Min.X+x, b.Min.Y+y))
+ }
+ }
+
+ maxBorder := minInt(32, minInt(w, h)/20)
+ if maxBorder < 2 {
+ maxBorder = 2
+ }
+
+ top := 0
+ for top < maxBorder && isNearBlackRow(img, top) {
+ top++
+ }
+ bottom := 0
+ for bottom < maxBorder && isNearBlackRow(img, h-1-bottom) {
+ bottom++
+ }
+ left := 0
+ for left < maxBorder && isNearBlackCol(img, left) {
+ left++
+ }
+ right := 0
+ for right < maxBorder && isNearBlackCol(img, w-1-right) {
+ right++
+ }
+ if top == 0 && bottom == 0 && left == 0 && right == 0 {
+ return nil
+ }
+ if top+bottom >= h || left+right >= w {
+ return nil
+ }
+
+ for y := 0; y < top; y++ {
+ copyRow(img, top, y)
+ }
+ for y := h - bottom; y < h; y++ {
+ copyRow(img, h-bottom-1, y)
+ }
+ for x := 0; x < left; x++ {
+ copyCol(img, left, x)
+ }
+ for x := w - right; x < w; x++ {
+ copyCol(img, w-right-1, x)
+ }
+
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ return fmt.Errorf("repair border: encode png: %w", err)
+ }
+ if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
+ return fmt.Errorf("repair border: write image: %w", err)
+ }
+ return nil
+}
+
+func isNearBlackRow(img *image.RGBA, y int) bool {
+ w := img.Bounds().Dx()
+ black := 0
+ for x := 0; x < w; x++ {
+ if isNearBlack(img.RGBAAt(x, y)) {
+ black++
+ }
+ }
+ return black*100 >= w*96
+}
+
+func isNearBlackCol(img *image.RGBA, x int) bool {
+ h := img.Bounds().Dy()
+ black := 0
+ for y := 0; y < h; y++ {
+ if isNearBlack(img.RGBAAt(x, y)) {
+ black++
+ }
+ }
+ return black*100 >= h*96
+}
+
+func isNearBlack(c color.RGBA) bool {
+ return c.A > 0 && c.R <= 18 && c.G <= 18 && c.B <= 18
+}
+
+func copyRow(img *image.RGBA, srcY, dstY int) {
+ w := img.Bounds().Dx()
+ for x := 0; x < w; x++ {
+ img.SetRGBA(x, dstY, img.RGBAAt(x, srcY))
+ }
+}
+
+func copyCol(img *image.RGBA, srcX, dstX int) {
+ h := img.Bounds().Dy()
+ for y := 0; y < h; y++ {
+ img.SetRGBA(dstX, y, img.RGBAAt(srcX, y))
+ }
+}
+
+func minInt(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/internal/queue/redis.go b/internal/queue/redis.go
index 4036547..f4ad27f 100644
--- a/internal/queue/redis.go
+++ b/internal/queue/redis.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "strconv"
"time"
"github.com/redis/go-redis/v9"
@@ -17,12 +18,14 @@ const (
// JobStatus represents the state of a generation job.
type JobStatus struct {
- JobID string `json:"job_id"`
- Status string `json:"status"` // pending | processing | done | error
- Step string `json:"step"`
- Message string `json:"message"`
- Result string `json:"result,omitempty"`
- Error string `json:"error,omitempty"`
+ JobID string `json:"job_id"`
+ Status string `json:"status"` // pending | processing | done | error
+ Step string `json:"step"`
+ Message string `json:"message"`
+ Result string `json:"result,omitempty"`
+ Error string `json:"error,omitempty"`
+ ElapsedMs int64 `json:"elapsed_ms,omitempty"`
+ TimingsMs map[string]int64 `json:"timings_ms,omitempty"`
}
// NewRedisClient creates a Redis client and pings to verify connectivity.
@@ -42,7 +45,7 @@ func NewRedisClient(addr, password string) (*redis.Client, error) {
func EnqueueJob(ctx context.Context, rdb *redis.Client, jobID string) error {
pipe := rdb.Pipeline()
pipe.RPush(ctx, queueKey, jobID)
- pipe.HSet(ctx, jobKey(jobID), "status", "pending")
+ pipe.HSet(ctx, jobKey(jobID), "status", "pending", "queued_at", nowMillis(), "updated_at", nowMillis())
pipe.Expire(ctx, jobKey(jobID), jobTTL)
_, err := pipe.Exec(ctx)
return err
@@ -62,22 +65,35 @@ func DequeueJob(ctx context.Context, rdb *redis.Client) (string, error) {
// SetJobStatus updates the job status field.
func SetJobStatus(ctx context.Context, rdb *redis.Client, jobID, status string) error {
- return rdb.HSet(ctx, jobKey(jobID), "status", status).Err()
+ fields := []any{"status", status, "updated_at", nowMillis()}
+ if status == "processing" {
+ fields = append(fields, "started_at", nowMillis())
+ }
+ return rdb.HSet(ctx, jobKey(jobID), fields...).Err()
}
// SetJobProgress updates the step and message for a job.
func SetJobProgress(ctx context.Context, rdb *redis.Client, jobID, step, msg string) error {
- return rdb.HSet(ctx, jobKey(jobID), "step", step, "message", msg).Err()
+ return rdb.HSet(ctx, jobKey(jobID), "step", step, "message", msg, "updated_at", nowMillis()).Err()
+}
+
+// SetJobTiming stores a single step duration and the total elapsed time so far.
+func SetJobTiming(ctx context.Context, rdb *redis.Client, jobID, step string, duration, elapsed time.Duration) error {
+ return rdb.HSet(ctx, jobKey(jobID),
+ step+"_ms", duration.Milliseconds(),
+ "elapsed_ms", elapsed.Milliseconds(),
+ "updated_at", nowMillis(),
+ ).Err()
}
// SetJobResult marks a job as done with the result image URL.
func SetJobResult(ctx context.Context, rdb *redis.Client, jobID, url string) error {
- return rdb.HSet(ctx, jobKey(jobID), "status", "done", "step", "done", "message", "Complete", "result", url).Err()
+ return rdb.HSet(ctx, jobKey(jobID), "status", "done", "step", "done", "message", "Complete", "result", url, "updated_at", nowMillis()).Err()
}
// SetJobError marks a job as errored with an error message.
func SetJobError(ctx context.Context, rdb *redis.Client, jobID, errMsg string) error {
- return rdb.HSet(ctx, jobKey(jobID), "status", "error", "step", "error", "message", errMsg, "error", errMsg).Err()
+ return rdb.HSet(ctx, jobKey(jobID), "status", "error", "step", "error", "message", errMsg, "error", errMsg, "updated_at", nowMillis()).Err()
}
// GetJobStatus retrieves the full status of a job.
@@ -90,17 +106,27 @@ func GetJobStatus(ctx context.Context, rdb *redis.Client, jobID string) (*JobSta
return nil, fmt.Errorf("job not found")
}
js := &JobStatus{
- JobID: jobID,
- Status: m["status"],
- Step: m["step"],
- Message: m["message"],
- Result: m["result"],
- Error: m["error"],
+ JobID: jobID,
+ Status: m["status"],
+ Step: m["step"],
+ Message: m["message"],
+ Result: m["result"],
+ Error: m["error"],
+ ElapsedMs: parseInt64(m["elapsed_ms"]),
+ TimingsMs: map[string]int64{},
+ }
+ for _, step := range []string{"check", "mask", "inpaint", "cleanup", "total"} {
+ if value := parseInt64(m[step+"_ms"]); value > 0 {
+ js.TimingsMs[step] = value
+ }
+ }
+ if len(js.TimingsMs) == 0 {
+ js.TimingsMs = nil
}
return js, nil
}
-// StoreJobPayload saves the job's input parameters (floor_id, pattern_code, room_code, input_path).
+// StoreJobPayload saves the job's input parameters and render options.
func StoreJobPayload(ctx context.Context, rdb *redis.Client, jobID string, payload map[string]string) error {
data, _ := json.Marshal(payload)
return rdb.HSet(ctx, jobKey(jobID), "payload", string(data)).Err()
@@ -120,3 +146,12 @@ func GetJobPayload(ctx context.Context, rdb *redis.Client, jobID string) (map[st
func jobKey(id string) string {
return jobKeyPrefix + id
}
+
+func nowMillis() int64 {
+ return time.Now().UnixMilli()
+}
+
+func parseInt64(raw string) int64 {
+ value, _ := strconv.ParseInt(raw, 10, 64)
+ return value
+}
diff --git a/internal/queue/worker.go b/internal/queue/worker.go
index a36e21d..203b7be 100644
--- a/internal/queue/worker.go
+++ b/internal/queue/worker.go
@@ -1,12 +1,17 @@
package queue
import (
+ "bytes"
"context"
"database/sql"
+ "encoding/json"
"floorvisualizer/internal/logger"
"fmt"
+ "io"
+ "net/http"
"os"
"path/filepath"
+ "strings"
"time"
"github.com/redis/go-redis/v9"
@@ -44,7 +49,9 @@ func StartWorkers(ctx context.Context, n int, rdb *redis.Client, client *openrou
}
func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Client, db *sql.DB, jobID string) {
+ totalStart := time.Now()
SetJobStatus(ctx, rdb, jobID, "processing")
+ logger.Info("[%s] TIMER job start", jobID)
// Load payload
payload, err := GetJobPayload(ctx, rdb, jobID)
@@ -58,26 +65,42 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien
patternCode := payload["pattern_code"]
roomCode := payload["room_code"]
- // Look up floor and pattern from DB (same logic as before)
+ // Look up floor and pattern from DB
floor := handler.FindFloorBySKUPublic(db, floorID)
pattern := handler.FindPatternByCodePublic(patternCode)
room := handler.FindRoomByCodePublic(roomCode)
+ groutLines := payloadBool(payload, "grout_lines")
+ whiteBaseboard := payloadBool(payload, "white_baseboard")
+ materialSource := payload["material_source"] // "image", "json", or "both"
+ if materialSource == "" {
+ materialSource = "image"
+ }
if floor == nil || pattern == nil {
SetJobError(ctx, rdb, jobID, "Invalid floor or pattern")
return
}
+ logger.Info("[%s] TIMER options floor_id=%s pattern_code=%s room_code=%s elapsed=%s",
+ jobID, floorID, patternCode, roomCode, time.Since(totalStart).Round(time.Millisecond))
+
+ // Resize input to max 2048px to speed up AI processing
+ workPath := filepath.Join("uploads", fmt.Sprintf("input_%s_resized.png", jobID))
+ if err := openrouter.ResizeImageToFit(inputPath, workPath); err != nil {
+ logger.Warn("[%s] Failed to resize input, using original: %v", jobID, err)
+ workPath = inputPath
+ }
- maskPath := filepath.Join("outputs", fmt.Sprintf("mask_%s.png", jobID))
outputPath := filepath.Join("outputs", fmt.Sprintf("result_%s.png", jobID))
- jobCtx, cancel := context.WithTimeout(ctx, 180*time.Second)
+ jobCtx, cancel := context.WithTimeout(ctx, 600*time.Second)
defer cancel()
// Step 0: content check
SetJobProgress(jobCtx, rdb, jobID, "check", "Checking content...")
- logger.Info("[%s] Content check", jobID)
- passed, score := validateContent(client, jobCtx, inputPath)
+ stepStart := time.Now()
+ logger.Info("[%s] TIMER step=check start elapsed=%s", jobID, time.Since(totalStart).Round(time.Millisecond))
+ passed, score := validateContent(client, jobCtx, workPath)
+ logStepDone(jobCtx, rdb, jobID, "check", stepStart, totalStart, "score=%d passed=%t", score, passed)
if score == 0 {
logger.Warn("[%s] Content check failed (score=0)", jobID)
}
@@ -86,61 +109,118 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien
return
}
- // Step 1: mask
- SetJobProgress(jobCtx, rdb, jobID, "mask", "Generating floor mask...")
- logger.Info("[%s] Mask generation", jobID)
- maskPrompt := handler.BuildMaskPromptPublic(room)
- if _, err := client.RecognizeImage(jobCtx, openrouter.ImageRecognitionRequest{
- Prompt: maskPrompt, InputImagePath: inputPath, OutputPath: maskPath,
- }); err != nil {
- SetJobError(jobCtx, rdb, jobID, "Mask generation failed: "+err.Error())
- return
- }
-
- // Step 2: inpaint
+ // Step 1: generate floor (single combined prompt — AI identifies floor + replaces it)
roomLabel := ""
if room != nil {
roomLabel = " · " + room.Name
}
- SetJobProgress(jobCtx, rdb, jobID, "inpaint", fmt.Sprintf("Applying %s %s%s...", floor.Name, pattern.Name, roomLabel))
- logger.Info("[%s] Inpainting: %s + %s", jobID, floor.Name, pattern.Name)
+ SetJobProgress(jobCtx, rdb, jobID, "generate", fmt.Sprintf("Generating %s %s%s...", floor.Name, pattern.Name, roomLabel))
+ stepStart = time.Now()
+ logger.Info("[%s] TIMER step=generate start floor=%s pattern=%s elapsed=%s",
+ jobID, floor.Name, pattern.Name, time.Since(totalStart).Round(time.Millisecond))
- floorPrompt := handler.BuildFloorPromptPublic(*floor, *pattern, room)
+ // Material source: image / json / both
+ if materialSource == "json" || materialSource == "both" {
+ if desc := handler.LookupMaterialDesc(floor.SKU); desc != "" {
+ if floor.Description != "" {
+ floor.Description = desc + " | " + floor.Description
+ } else {
+ floor.Description = desc
+ }
+ }
+ }
+ refImagePath := ""
+ if materialSource != "json" {
+ refImagePath = downloadReferenceImage(jobCtx, floor.ImageURL, jobID)
+ }
+
+ imageSize := floorImageSize()
+ logger.Info("[%s] TIMER generate request material=%s image_size=%s input=%s output=%s ref=%s",
+ jobID, materialSource, imageSize, workPath, outputPath, refImagePath)
+ floorPrompt := handler.BuildCombinedFloorPrompt(*floor, *pattern, room, groutLines, whiteBaseboard)
_, err = client.GenerateImageToFile(jobCtx, openrouter.ImageGenerationRequest{
- Prompt: floorPrompt, OutputPath: outputPath, InputImagePath: inputPath,
- MaskImagePath: maskPath, ImageSize: "2K",
+ Prompt: floorPrompt, OutputPath: outputPath, InputImagePath: workPath,
+ ImageSize: imageSize, ReferenceImagePath: refImagePath,
})
if err != nil {
+ logStepError(jobCtx, rdb, jobID, "generate", totalStart, stepStart, err)
SetJobError(jobCtx, rdb, jobID, "Floor generation failed: "+err.Error())
return
}
+ if err := openrouter.RepairNearBlackBorder(outputPath); err != nil {
+ logger.Warn("[%s] Failed to repair output border: %v", jobID, err)
+ }
+ logStepDone(jobCtx, rdb, jobID, "generate", stepStart, totalStart, "")
// Clean up intermediate files
+ stepStart = time.Now()
+ logger.Info("[%s] TIMER step=cleanup start elapsed=%s", jobID, time.Since(totalStart).Round(time.Millisecond))
if err := os.Remove(inputPath); err != nil {
logger.Warn("[%s] Failed to remove input: %v", jobID, err)
}
- if err := os.Remove(maskPath); err != nil {
- logger.Warn("[%s] Failed to remove mask: %v", jobID, err)
+ if workPath != inputPath {
+ if err := os.Remove(workPath); err != nil {
+ logger.Warn("[%s] Failed to remove resized input: %v", jobID, err)
+ }
+ }
+ if refImagePath != "" {
+ if err := os.Remove(refImagePath); err != nil {
+ logger.Warn("[%s] Failed to remove reference image: %v", jobID, err)
+ }
}
+ totalDuration := time.Since(totalStart)
+ _ = SetJobTiming(jobCtx, rdb, jobID, "total", totalDuration, totalDuration)
SetJobResult(jobCtx, rdb, jobID, "/"+filepath.ToSlash(outputPath))
- logger.Info("[%s] Done", jobID)
+ logger.Info("[%s] TIMER job done total=%s result=%s", jobID, totalDuration.Round(time.Millisecond), "/"+filepath.ToSlash(outputPath))
+}
+
+func logStepDone(ctx context.Context, rdb *redis.Client, jobID, step string, stepStart, totalStart time.Time, format string, args ...any) {
+ duration := time.Since(stepStart)
+ elapsed := time.Since(totalStart)
+ _ = SetJobTiming(ctx, rdb, jobID, step, duration, elapsed)
+ if format != "" {
+ logger.Info("[%s] TIMER step=%s done duration=%s elapsed=%s %s",
+ jobID, step, duration.Round(time.Millisecond), elapsed.Round(time.Millisecond), fmt.Sprintf(format, args...))
+ return
+ }
+ logger.Info("[%s] TIMER step=%s done duration=%s elapsed=%s",
+ jobID, step, duration.Round(time.Millisecond), elapsed.Round(time.Millisecond))
+}
+
+func logStepError(ctx context.Context, rdb *redis.Client, jobID, step string, totalStart, stepStart time.Time, err error) {
+ duration := time.Since(stepStart)
+ elapsed := time.Since(totalStart)
+ _ = SetJobTiming(ctx, rdb, jobID, step, duration, elapsed)
+ logger.Error("[%s] TIMER step=%s error duration=%s elapsed=%s err=%v",
+ jobID, step, duration.Round(time.Millisecond), elapsed.Round(time.Millisecond), err)
}
// validateContent checks if the image is an indoor room photo.
-func validateContent(client *openrouter.Client, ctx context.Context, imagePath string) (bool, int) {
+// Uses local CLIP ViT-B/32 first, falls back to Gemini 2.5 Flash if CLIP is unavailable.
+func validateContent(geminiClient *openrouter.Client, ctx context.Context, imagePath string) (bool, int) {
imgData, err := os.ReadFile(imagePath)
if err != nil {
return false, 0
}
+ // Try local CLIP server first
+ if score, ok := clipCheck(imgData); ok {
+ logger.Info("Content check via local CLIP: score=%d passed=%t", score, score >= 6)
+ return score >= 6, score
+ }
+ // Fallback to Gemini
+ checkCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
+ defer cancel()
dataURI := "data:image/png;base64," + base64enc(imgData)
- resp, err := client.Chat(ctx, openrouter.ChatRequest{
+ resp, err := geminiClient.Chat(checkCtx, openrouter.ChatRequest{
Model: "google/gemini-2.5-flash",
Messages: []openrouter.Message{{Role: "user", Content: []openrouter.ContentPart{
{Type: "image_url", ImageURL: &openrouter.ImageURL{URL: dataURI}},
- {Type: "text", Text: `Score this image 1-10 for indoor floor replacement suitability.
-8-10: Clear indoor room, floor visible. 5-7: Indoor but poor angle. 1-4: Outdoor/pets/objects/no floor.
-Return ONLY a number (1-10).`},
+ {Type: "text", Text: `请按 1-10 分评估这张图片是否适合做室内地面替换。
+8-10 分:清晰室内房间,地面可见。
+5-7 分:室内图片,但角度或地面可见度一般。
+1-4 分:室外、宠物特写、物体特写、没有可替换地面,或不适合做地面替换。
+只返回一个数字(1-10)。`},
}}},
})
if err != nil || len(resp.Choices) == 0 {
@@ -150,6 +230,31 @@ Return ONLY a number (1-10).`},
return score >= 6, score
}
+// clipCheck sends image bytes to the local CLIP server and returns (score, ok).
+func clipCheck(imgData []byte) (int, bool) {
+ req, err := http.NewRequest(http.MethodPost, "http://127.0.0.1:5100/check", bytes.NewReader(imgData))
+ if err != nil {
+ return 0, false
+ }
+ req.Header.Set("Content-Type", "application/octet-stream")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return 0, false
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ return 0, false
+ }
+ var result struct {
+ Score int `json:"score"`
+ Passed bool `json:"passed"`
+ }
+ if json.NewDecoder(resp.Body).Decode(&result) != nil {
+ return 0, false
+ }
+ return result.Score, true
+}
+
func parseScore(raw string) int {
for _, s := range []string{"10", "9", "8", "7", "6", "5", "4", "3", "2", "1"} {
if len(raw) >= len(s) && raw[:len(s)] == s {
@@ -187,3 +292,56 @@ func base64enc(data []byte) string {
}
return string(b)
}
+
+// downloadReferenceImage downloads the floor material image from the given URL
+// and saves it to a local temp file. Returns the local file path, or empty string on failure.
+func downloadReferenceImage(ctx context.Context, imageURL, jobID string) string {
+ if imageURL == "" {
+ return ""
+ }
+ refPath := filepath.Join("outputs", fmt.Sprintf("ref_%s.png", jobID))
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
+ if err != nil {
+ logger.Warn("[%s] Failed to create reference image request: %v", jobID, err)
+ return ""
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ logger.Warn("[%s] Failed to download reference image from %s: %v", jobID, imageURL, err)
+ return ""
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ logger.Warn("[%s] Reference image download returned HTTP %d from %s", jobID, resp.StatusCode, imageURL)
+ return ""
+ }
+ f, err := os.Create(refPath)
+ if err != nil {
+ logger.Warn("[%s] Failed to create reference image file: %v", jobID, err)
+ return ""
+ }
+ defer f.Close()
+ if _, err := io.Copy(f, resp.Body); err != nil {
+ logger.Warn("[%s] Failed to save reference image: %v", jobID, err)
+ return ""
+ }
+ logger.Info("[%s] Downloaded reference image from %s", jobID, imageURL)
+ return refPath
+}
+
+func payloadBool(payload map[string]string, key string) bool {
+ switch payload[key] {
+ case "1", "true", "yes", "y", "on":
+ return true
+ default:
+ return false
+ }
+}
+
+func floorImageSize() string {
+ value := strings.TrimSpace(os.Getenv("FLOOR_IMAGE_SIZE"))
+ if value == "" {
+ return "2K"
+ }
+ return value
+}
diff --git a/internal/repository/product_repo.go b/internal/repository/product_repo.go
index 4abfe53..c0f22dc 100644
--- a/internal/repository/product_repo.go
+++ b/internal/repository/product_repo.go
@@ -13,28 +13,27 @@ import (
const selectCols = `brand, group_name, sku, series_name, style_name,
category, material, color_tone, finish,
- price_per_sqft, price_tier, price_source,
+ price_per_sqft, price_tier, price_source, is_official_price,
main_image_url, room_image_url, source_url, description, status,
COALESCE(coverage_sqft_per_box,0), COALESCE(wood_species,''),
size_label, COALESCE(width_in,0), COALESCE(length_in,0)`
const insertCols = `brand, group_name, sku, series_name, style_name,
category, material, color_tone, finish,
- price_per_sqft, price_tier, price_source,
+ price_per_sqft, price_tier, price_source, is_official_price,
main_image_url, room_image_url, source_url, description, status,
coverage_sqft_per_box, wood_species,
size_label, width_in, length_in`
-
// ── Import ──────────────────────────────────────────────────
func InsertProducts(d *sql.DB, products []model.Product) error {
for _, p := range products {
_, err := d.Exec(`
- INSERT INTO products (`+insertCols+`) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)
+ INSERT INTO products (`+insertCols+`) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23)
`, p.Brand, p.GroupName, p.SKU, p.SeriesName, p.StyleName,
p.Category, p.Material, p.ColorTone, p.Finish,
- p.PricePerSqft, p.PriceTier, p.PriceSource,
+ p.PricePerSqft, p.PriceTier, p.PriceSource, p.IsOfficialPrice,
p.MainImageURL, p.RoomImageURL, p.SourceURL, p.Description, p.Status,
p.CoverageSqftPerBox, p.WoodSpecies,
p.SizeLabel, nf(p.WidthIn), nf(p.LengthIn))
@@ -46,7 +45,9 @@ func InsertProducts(d *sql.DB, products []model.Product) error {
}
func nf(v float64) interface{} {
- if v == 0 { return nil }
+ if v == 0 {
+ return nil
+ }
return v
}
@@ -54,12 +55,12 @@ func nf(v float64) interface{} {
type FilterParams struct {
SeriesName, Category, Search, Brand, Material, ColorTone, Finish, WoodSpecies string
- Page, Limit int
+ Page, Limit int
}
type FilterOptions struct {
- Categories []string `json:"categories"`
- SeriesNames []string `json:"series_names"`
+ Categories []string `json:"categories"`
+ SeriesNames []string `json:"series_names"`
Brands []model.BrandInfo `json:"brands"`
}
@@ -70,9 +71,22 @@ 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 }
- if p.Page <= 0 { p.Page = 1 }
+ if p.Limit <= 0 {
+ p.Limit = 20
+ }
+ if p.Page <= 0 {
+ p.Page = 1
+ }
var conds []string
var args []interface{}
@@ -101,12 +115,16 @@ func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, err
fmt.Sprintf("brand ILIKE $%d", i+2),
}
conds = append(conds, "("+strings.Join(clauses, " OR ")+")")
- for k := 0; k < 3; k++ { args = append(args, pat) }
+ for k := 0; k < 3; k++ {
+ args = append(args, pat)
+ }
i += 3
}
where := ""
- if len(conds) > 0 { where = "WHERE " + strings.Join(conds, " AND ") }
+ if len(conds) > 0 {
+ where = "WHERE " + strings.Join(conds, " AND ")
+ }
// Count distinct styles (dedup same brand+style_name across different sizes)
var total int
@@ -118,18 +136,29 @@ func QueryFilteredProducts(d *sql.DB, p FilterParams) ([]model.Product, int, err
args = append(args, p.Limit, offset)
rows, err := d.Query(query, args...)
- if err != nil { return nil, 0, err }
+ if err != nil {
+ return nil, 0, err
+ }
defer rows.Close()
- prods, err := scanProducts(rows)
+ prods, err := ScanProducts(rows)
return prods, total, err
}
func GetProductBySKU(d *sql.DB, sku string) (*model.Product, error) {
- var p model.Product
- if err := GDB.Where("sku = ?", sku).First(&p).Error; err != nil {
+ rows, err := d.Query(`SELECT `+selectCols+` FROM products WHERE sku = $1 LIMIT 1`, sku)
+ if err != nil {
return nil, err
}
- return &p, nil
+ defer rows.Close()
+
+ prods, err := ScanProducts(rows)
+ if err != nil {
+ return nil, err
+ }
+ if len(prods) == 0 {
+ return nil, sql.ErrNoRows
+ }
+ return &prods[0], nil
}
// GetVariantsByStyle returns all products with the same brand+style_name but different SKU.
@@ -138,13 +167,14 @@ func GetVariantsByStyle(d *sql.DB, sku string) ([]model.Product, error) {
SELECT `+selectCols+` FROM products
WHERE style_name = (SELECT style_name FROM products WHERE sku = $1)
AND brand = (SELECT brand FROM products WHERE sku = $1)
- AND series_name = (SELECT series_name FROM products WHERE sku = $1)
AND sku != $1
ORDER BY width_in, length_in
`, sku)
- if err != nil { return nil, err }
+ if err != nil {
+ return nil, err
+ }
defer rows.Close()
- return scanProducts(rows)
+ return ScanProducts(rows)
}
func VariantCount(d *sql.DB, sku string) int {
@@ -190,13 +220,22 @@ func GetSpecCounts(d *sql.DB, skus []string) map[string]int {
func GetFilterOptions(d *sql.DB) (*FilterOptions, error) {
opts := &FilterOptions{}
- for _, q := range []struct{ query string; out *[]string }{
+ for _, q := range []struct {
+ query string
+ out *[]string
+ }{
{"SELECT DISTINCT category FROM products WHERE category != '' ORDER BY category", &opts.Categories},
{"SELECT DISTINCT series_name FROM products WHERE series_name != '' ORDER BY series_name", &opts.SeriesNames},
} {
rows, err := d.Query(q.query)
- if err != nil { return nil, err }
- for rows.Next() { var v string; rows.Scan(&v); *q.out = append(*q.out, v) }
+ if err != nil {
+ return nil, err
+ }
+ for rows.Next() {
+ var v string
+ rows.Scan(&v)
+ *q.out = append(*q.out, v)
+ }
rows.Close()
}
opts.Brands = GetBrandInfos(d)
@@ -254,18 +293,20 @@ func GetBrandInfos(d *sql.DB) []model.BrandInfo {
return out
}
-func scanProducts(rows *sql.Rows) ([]model.Product, error) {
+func ScanProducts(rows *sql.Rows) ([]model.Product, error) {
var out []model.Product
for rows.Next() {
var p model.Product
if err := rows.Scan(
&p.Brand, &p.GroupName, &p.SKU, &p.SeriesName, &p.StyleName,
&p.Category, &p.Material, &p.ColorTone, &p.Finish,
- &p.PricePerSqft, &p.PriceTier, &p.PriceSource,
+ &p.PricePerSqft, &p.PriceTier, &p.PriceSource, &p.IsOfficialPrice,
&p.MainImageURL, &p.RoomImageURL, &p.SourceURL, &p.Description, &p.Status,
&p.CoverageSqftPerBox, &p.WoodSpecies,
&p.SizeLabel, &p.WidthIn, &p.LengthIn,
- ); err != nil { return nil, err }
+ ); err != nil {
+ return nil, err
+ }
out = append(out, p)
}
return out, rows.Err()
diff --git a/main.go b/main.go
index a4d2a59..ccc629a 100644
--- a/main.go
+++ b/main.go
@@ -7,10 +7,13 @@ import (
"math/rand"
"net/http"
"os"
+ "os/exec"
"os/signal"
"strconv"
"time"
+ "github.com/joho/godotenv"
+
"floorvisualizer/internal/api"
"floorvisualizer/internal/handler"
"floorvisualizer/internal/logger"
@@ -37,6 +40,21 @@ func init() {
}
func main() {
+ godotenv.Load()
+
+ // Load material description index
+ if err := handler.LoadMaterialIndex("outputs/material_index.json"); err != nil {
+ logger.Warn("Material index not loaded: %v", err)
+ } else {
+ logger.Info("Material index loaded")
+ }
+
+ // Start CLIP server for content check
+ clipCmd := startCLIPServer()
+ if clipCmd != nil {
+ defer stopCLIPServer(clipCmd)
+ }
+
logDir := "logs"
if v := os.Getenv("LOG_DIR"); v != "" {
logDir = v
@@ -85,6 +103,7 @@ func main() {
if v := os.Getenv("REDIS_ADDR"); v != "" {
redisAddr = v
}
+ var cancelWorkers context.CancelFunc
rdb, err := queue.NewRedisClient(redisAddr, "")
if err != nil {
logger.Warn("Redis not available (%v), worker queue disabled", err)
@@ -119,19 +138,11 @@ func main() {
workerCount = n
}
}
- workerCtx, cancelWorkers := context.WithCancel(context.Background())
+ workerCtx, cancel := context.WithCancel(context.Background())
+ cancelWorkers = cancel
defer cancelWorkers()
queue.StartWorkers(workerCtx, workerCount, rdb, client, db)
logger.Info("Worker pool started: %d workers", workerCount)
-
- // Graceful shutdown
- go func() {
- sig := make(chan os.Signal, 1)
- signal.Notify(sig, os.Interrupt)
- <-sig
- logger.Info("Shutting down workers...")
- cancelWorkers()
- }()
}
// ── Hot product cache ────────────────────────────────
@@ -171,6 +182,71 @@ func main() {
if p := os.Getenv("PORT"); p != "" {
port = p
}
+
+ server := &http.Server{
+ Addr: ":" + port,
+ Handler: middleware.Logging(mux),
+ }
+
+ // Graceful shutdown: Ctrl+C stops HTTP server + worker pool
+ go func() {
+ sig := make(chan os.Signal, 1)
+ signal.Notify(sig, os.Interrupt)
+ <-sig
+ logger.Info("Shutting down...")
+ if cancelWorkers != nil {
+ cancelWorkers()
+ }
+ stopCLIPServer(clipCmd)
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := server.Shutdown(shutdownCtx); err != nil {
+ logger.Warn("HTTP server forced shutdown: %v", err)
+ }
+ }()
+
logger.Info("FloorVisualizer -> http://localhost:%s", port)
- log.Fatal(http.ListenAndServe(":"+port, middleware.Logging(mux)))
+ if err := server.ListenAndServe(); err != http.ErrServerClosed {
+ log.Fatal(err)
+ }
+ logger.Info("Server stopped gracefully")
+}
+
+// startCLIPServer launches the Python CLIP server for content checking.
+func startCLIPServer() *exec.Cmd {
+ // Check if CLIP is already running
+ if _, err := http.Get("http://127.0.0.1:5100/health"); err == nil {
+ logger.Info("CLIP server already running on :5100")
+ return nil
+ }
+ pythonPath := `C:\Users\10794\AppData\Local\Programs\Python\Python312\python.exe`
+ cmd := exec.Command(pythonPath, "clip_server/server.py")
+ cmd.Stdout = nil
+ cmd.Stderr = nil
+ if err := cmd.Start(); err != nil {
+ logger.Warn("Failed to start CLIP server: %v (content check will use Gemini fallback)", err)
+ return nil
+ }
+ logger.Info("CLIP server starting on :5100...")
+ // Wait for it to be ready
+ for i := 0; i < 30; i++ {
+ time.Sleep(time.Second)
+ if _, err := http.Get("http://127.0.0.1:5100/health"); err == nil {
+ logger.Info("CLIP server ready")
+ return cmd
+ }
+ }
+ logger.Warn("CLIP server did not become ready in time")
+ return cmd
+}
+
+// stopCLIPServer stops the CLIP Python server.
+func stopCLIPServer(cmd *exec.Cmd) {
+ if cmd == nil || cmd.Process == nil {
+ return
+ }
+ logger.Info("Stopping CLIP server...")
+ if err := cmd.Process.Kill(); err != nil {
+ logger.Warn("Failed to stop CLIP server: %v", err)
+ }
}
diff --git a/schema.sql b/schema.sql
index cd22e3f..bf44a97 100644
--- a/schema.sql
+++ b/schema.sql
@@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS products (
price_per_sqft NUMERIC(10,2) DEFAULT 0,
price_tier TEXT DEFAULT '',
price_source TEXT DEFAULT '',
+ is_official_price BOOLEAN DEFAULT false,
main_image_url TEXT DEFAULT '',
room_image_url TEXT DEFAULT '',
source_url TEXT DEFAULT '',