Compare commits

..

12 Commits

Author SHA1 Message Date
be38d1b679 Merge pull request 'dev' (#2) from dev into main
Reviewed-on: #2
2026-07-27 08:12:45 +00:00
dindang
455a06e799 Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	README.md
2026-07-27 16:12:17 +08:00
dindang
1bd5fa9d4c 冲突解决 2026-07-27 16:12:02 +08:00
dindang
3fa3561bb2 问卷 2026-07-27 16:11:15 +08:00
dindang
90fd37742f v2 2026-07-27 16:11:15 +08:00
dindang
4b5f5eb736 v2 2026-07-27 16:11:15 +08:00
dindang
1524a3215c feat(floor): 优化地板样式数据结构和AI换地板功能
- 添加 FloorOption 结构体字段支持尺寸、描述和变体信息
- 优化 loadFloorOptions 函数,实现按品牌和花色名称去重并附带多尺寸变体
- 区分木材、瓷砖、乙烯基和层压板产品,并分类返回
- 更新室内铺装纹理选项,完善图案描述和名称本地化
- 丰富Floor Options API响应数据,包含尺寸、变体、描述等字段
- 增强AI换地板功能,支持尺寸变体SKU,自动调整物理尺寸比例
- 完善地板替换的AI提示词,增加材质锁定、尺寸说明和纹理一致性要求
- 改进地板识别蒙版生成逻辑,确保精准分割地板区域
- Redis任务状态查询接口增加耗时统计字段,提供更细粒度进度信息
- 更新go.mod依赖,新增redis和gorm相关包支持
2026-07-27 16:11:15 +08:00
dindang
1e1ffb23f3 问卷 2026-07-27 15:34:32 +08:00
dindang
202e36027a v2 2026-07-27 15:07:12 +08:00
dindang
33515992b6 v2 2026-07-27 14:39:45 +08:00
dindang
5e35532dbb feat(floor): 优化地板样式数据结构和AI换地板功能
- 添加 FloorOption 结构体字段支持尺寸、描述和变体信息
- 优化 loadFloorOptions 函数,实现按品牌和花色名称去重并附带多尺寸变体
- 区分木材、瓷砖、乙烯基和层压板产品,并分类返回
- 更新室内铺装纹理选项,完善图案描述和名称本地化
- 丰富Floor Options API响应数据,包含尺寸、变体、描述等字段
- 增强AI换地板功能,支持尺寸变体SKU,自动调整物理尺寸比例
- 完善地板替换的AI提示词,增加材质锁定、尺寸说明和纹理一致性要求
- 改进地板识别蒙版生成逻辑,确保精准分割地板区域
- Redis任务状态查询接口增加耗时统计字段,提供更细粒度进度信息
- 更新go.mod依赖,新增redis和gorm相关包支持
2026-07-22 15:23:40 +08:00
dindang
a756c8c464 Update README with current project structure and features
- Add api/ package, model/response.go, logger, queue docs
- Document environment variables (WORKER_COUNT, LOG_DIR, LOG_LEVEL)
- Add cache strategy and async queue sections
- Update project structure tree
- Add GORM + raw SQL hybrid approach doc

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-16 10:40:06 +08:00
23 changed files with 2865 additions and 266 deletions

25
.env.example Normal file
View File

@ -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

3
.gitignore vendored
View File

@ -28,3 +28,6 @@ __MACOSX/
# Claude
.claude/
# Env
.env

View File

@ -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_floorsSPC/LVP产品\n- wood_patterns / tile_patterns铺设方式\n- rooms房间类型",
"summary": "获取地板样式选项(从产品库动态加载,含尺寸变体",
"description": "从产品数据库动态加载地板样式选项。每个样式对应一个真实产品SKU含 width_in/length_in/description/variants 等字段。\n\n材质分类按 material 字段自动归类):\n- wood_floorsOak/Ash/Hickory/Maple 等木材\n- tile_floorsPorcelain/Stone Look/Glass + Laminate\n- vinyl_floorsLuxury 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 }
}
}
}
}
}
}
}

90
clip_server/server.py Normal file
View File

@ -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)

View File

@ -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))
}

View File

@ -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

7
go.mod
View File

@ -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
)

23
go.sum
View File

@ -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=

440
internal/api/index.html Normal file
View File

@ -0,0 +1,440 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>FloorVisualizer Demo</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",Arial,sans-serif;background:#1a1a2e;color:#eee;min-height:100vh}
header{background:#16213e;padding:20px;text-align:center;border-bottom:2px solid #e94560}
header h1{font-size:24px;color:#e94560}
header p{font-size:14px;color:#aaa;margin-top:6px}
.container{max-width:1280px;margin:0 auto;padding:20px;display:grid;grid-template-columns:1fr 1fr;gap:20px}
.panel{background:#16213e;border-radius:12px;padding:22px;margin-bottom:16px}
.panel h2{font-size:18px;margin-bottom:16px;color:#e94560}
.upload-zone{border:2px dashed #444;border-radius:10px;padding:32px;min-height:220px;display:flex;align-items:center;justify-content:center;text-align:center;cursor:pointer;transition:.2s}
.upload-zone:hover,.upload-zone.dragover{border-color:#e94560;background:rgba(233,68,96,.06)}
.upload-zone img{max-width:100%;max-height:320px;border-radius:8px}
.upload-zone span{color:#888;font-size:14px;line-height:1.7}
.options{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
.option{padding:12px;border:2px solid #333;border-radius:8px;cursor:pointer;text-align:center;transition:.2s;user-select:none;min-height:64px;display:flex;flex-direction:column;align-items:center;justify-content:center}
.option:hover{border-color:#e94560}
.option.selected{border-color:#e94560;background:rgba(233,68,96,.15)}
.option.disabled{opacity:.45;cursor:not-allowed}
.option .name{font-size:14px;font-weight:700;line-height:1.35}
.option .meta{font-size:12px;color:#aaa;margin-top:4px;line-height:1.35}
.floor-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;max-height:430px;overflow:auto;padding-right:4px}
.floor-card{border:2px solid #333;border-radius:8px;padding:10px;cursor:pointer;transition:.2s;background:#11172d}
.floor-card:hover{border-color:#e94560}
.floor-card.selected{border-color:#e94560;background:rgba(233,68,96,.14)}
.floor-card img{width:100%;height:70px;object-fit:cover;border-radius:6px;background:#222;margin-bottom:8px}
.floor-card .title{font-size:13px;font-weight:700;line-height:1.3}
.floor-card .desc{font-size:11px;color:#aaa;margin-top:4px;line-height:1.35}
.toolbar{display:flex;gap:10px;margin-bottom:10px}
.toolbar input{flex:1;background:#0f3460;border:1px solid #34456c;border-radius:8px;color:#eee;padding:10px}
.toggle-row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.toggle{border:2px dashed #444;border-radius:8px;padding:12px;cursor:pointer;background:#11172d}
.toggle.selected{border-color:#e94560;background:rgba(233,68,96,.14)}
.toggle.disabled{opacity:.45;cursor:not-allowed}
.toggle input{margin-right:8px}
.btn{width:100%;padding:14px;margin-top:4px;border:0;border-radius:8px;background:#e94560;color:#fff;font-size:16px;font-weight:700;cursor:pointer}
.btn:hover{background:#ff647b}
.btn:disabled{background:#555;cursor:not-allowed}
.spinner{display:none;width:18px;height:18px;border:2px solid #fff;border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;vertical-align:middle;margin-right:8px}
.loading .spinner{display:inline-block}
.loading .btn-text{display:none}
@keyframes spin{to{transform:rotate(360deg)}}
.result-panel{display:flex;flex-direction:column;min-height:720px}
.placeholder{flex:1;min-height:360px;border:1px dashed #333;border-radius:8px;display:flex;align-items:center;justify-content:center;text-align:center;color:#777;line-height:1.8}
.result-img{display:none;width:100%;border-radius:8px;margin-top:12px}
.compare{position:relative;width:100%;overflow:hidden;border-radius:8px;margin-top:12px;display:none;user-select:none;background:#111;cursor:ew-resize}
.compare img.base{display:block;width:100%;height:auto}
.clip{position:absolute;left:0;top:0;width:50%;height:100%;overflow:hidden}
.clip img{display:block;height:100%;object-fit:cover;object-position:left top;max-width:none}
.handle{position:absolute;top:0;bottom:0;left:50%;transform:translateX(-50%);width:4px;background:#e94560;z-index:3;pointer-events:none}
.knob{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:42px;height:42px;border-radius:50%;background:#e94560;display:grid;place-items:center;box-shadow:0 2px 16px rgba(0,0,0,.5)}
.labels{position:absolute;left:0;right:0;bottom:14px;display:flex;justify-content:space-between;padding:0 16px;pointer-events:none;z-index:2}
.labels span{background:rgba(0,0,0,.76);color:#fff;padding:4px 12px;border-radius:4px;font-size:12px;font-weight:700}
.status-box{display:none;background:#0f3460;border-radius:8px;padding:16px;margin-top:12px}
.step-label{font-size:13px;color:#e94560;font-weight:700;margin-bottom:6px}
.step-msg{font-size:14px;color:#eee;line-height:1.5;white-space:pre-wrap}
.progress{height:6px;background:#333;border-radius:3px;margin-top:12px;overflow:hidden}
.fill{height:100%;width:0;background:linear-gradient(90deg,#e94560,#f0a500);transition:width .35s}
.error-box{display:none;margin-top:12px;border:1px solid #90323d;background:#2b1220;color:#ffb4bd;border-radius:8px;padding:14px}
.error-box h3{font-size:14px;margin-bottom:8px;color:#ff6b81}
.error-box pre{white-space:pre-wrap;word-break:break-word;font-family:Consolas,monospace;font-size:12px;line-height:1.5;color:#ffd6dc}
.small{font-size:12px;color:#888;margin-top:8px}
@media(max-width:900px){.container{grid-template-columns:1fr;padding:14px}.floor-grid{grid-template-columns:1fr}.options,.toggle-row{grid-template-columns:1fr}}
</style>
</head>
<body>
<header>
<h1>FloorVisualizer AI 换地板 Demo</h1>
<p>上传房间照片,选择真实产品、铺设方式和可选效果,生成写实换地板预览</p>
</header>
<div class="container">
<div>
<div class="panel">
<h2>第一步:上传房间照片</h2>
<div class="upload-zone" id="uploadZone">
<input id="fileInput" type="file" accept="image/*" hidden>
<img id="previewImg" alt="" style="display:none">
<span id="uploadHint">点击或拖拽上传图片<br>建议使用能清楚看到地面的室内照片</span>
</div>
</div>
<div class="panel">
<h2>房间类型</h2>
<div class="options" id="room可选效果"></div>
</div>
<div class="panel">
<h2>地板类型</h2>
<div class="options" id="type可选效果">
<div class="option selected" data-type="wood"><div class="name">木地板</div><div class="meta">Hardwood</div></div>
<div class="option" data-type="tile"><div class="name">瓷砖</div><div class="meta">Tile / Stone</div></div>
<div class="option" data-type="vinyl"><div class="name">乙烯基地板</div><div class="meta">Luxury Vinyl / SPC</div></div>
<div class="option" data-type="special"><div class="name">特殊测试</div><div class="meta">7款 · 含AI描述</div></div>
</div>
</div>
<div class="panel">
<h2>铺设方式</h2>
<div class="options" id="pattern可选效果"></div>
</div>
<div class="panel" id="sizePanel" style="display:none">
<h2>尺寸</h2>
<div class="options" id="size可选效果"></div>
</div>
<div class="panel">
<h2 id="floorTitle">选择地板款式</h2>
<div class="toolbar"><input id="floorSearch" placeholder="搜索款式、颜色、材质、SKU"></div>
<div class="floor-grid" id="floor可选效果"></div>
</div>
<div class="panel">
<h2>材质参考方式</h2>
<div class="options" id="materialSourceOptions">
<div class="option selected" data-source="image"><div class="name">仅图片</div><div class="meta">参考图</div></div>
<div class="option" data-source="json"><div class="name">仅JSON</div><div class="meta">材质描述</div></div>
<div class="option" data-source="both"><div class="name">图片+JSON</div><div class="meta">两者都用</div></div>
</div>
</div>
<div class="panel">
<h2>可选效果</h2>
<div class="toggle-row">
<label class="toggle" id="groutToggle"><input id="grout" type="checkbox">瓷砖勾缝线<div class="small">仅瓷砖可用</div></label>
<label class="toggle" id="baseboardToggle"><input id="baseboard" type="checkbox">白色踢脚线<div class="small">沿墙地交界生成</div></label>
</div>
</div>
<button class="btn" id="generateBtn" disabled><span class="spinner"></span><span class="btn-text">生成预览图</span></button>
<div class="status-box" id="statusBox">
<div class="step-label" id="stepLabel"></div>
<div class="step-msg" id="stepMsg"></div>
<div class="live-timer" id="liveTimer" style="display:none;color:#f0a500;font-size:13px;margin-top:8px;font-weight:700"></div>
<div class="progress"><div class="fill" id="progressFill"></div></div>
</div>
<div class="error-box" id="errorBox">
<h3>生成失败原因</h3>
<pre id="errorDetail"></pre>
</div>
</div>
<div class="panel result-panel">
<h2>生成结果</h2>
<div class="placeholder" id="resultPlaceholder">上传图片并选择地板选项后<br>点击生成预览图查看效果</div>
<img class="result-img" id="resultImg" alt="生成结果">
<div class="compare" id="compare">
<img class="base" id="afterImg" alt="生成图">
<div class="clip" id="clip"><img id="beforeImg" alt="原图"></div>
<div class="handle" id="handle"><div class="knob"></div></div>
<div class="labels"><span>原图</span><span>生成图</span></div>
</div>
</div>
</div>
<script>
const $=id=>document.getElementById(id);
const state={options:null,type:'wood',file:null,originalUrl:null,room:null,pattern:null,floor:null,timer:null,lastStatus:null};
function show错误(title, detail){
$('errorBox').style.display='block';
$('errorDetail').textContent=(title||'生成失败')+(detail?'\n\n'+detail:'');
$('resultPlaceholder').style.display='flex';
$('resultPlaceholder').innerHTML='生成失败';
}
function clear错误(){ $('errorBox').style.display='none'; $('errorDetail').textContent=''; }
function setProgress(step,msg,width){
$('statusBox').style.display='block';
$('stepLabel').textContent=step;
$('stepMsg').textContent=msg||'';
$('progressFill').style.width=width;
}
function apiData(json){ return json && json.data ? json.data : json; }
function floorList(){
if(!state.options)return[];
if(state.type==='tile')return state.options.tile_floors||[];
if(state.type==='vinyl')return state.options.vinyl_floors||[];
if(state.type==='special')return state.options.special_floors||[];
return state.options.wood_floors||[];
}
function patternList(){
if(!state.options)return[];
return state.type==='tile' ? (state.options.tile_patterns||[]) : (state.options.wood_patterns||[]);
}
function selectIn(container,item,el,key){
[...container.querySelectorAll(key)].forEach(x=>x.classList.remove('selected'));
el.classList.add('selected');
return item;
}
function checkReady(){
$('generateBtn').disabled=!(state.file&&state.pattern&&state.floor);
}
// Material source selector
state.materialSource='image';
$('materialSourceOptions').onclick=e=>{
const opt=e.target.closest('.option');
if(!opt)return;
[...$('materialSourceOptions').querySelectorAll('.option')].forEach(x=>x.classList.remove('selected'));
opt.classList.add('selected');
state.materialSource=opt.dataset.source;
};
function render尺寸s(floor){
const el=$('size可选效果'); el.innerHTML=''; state.sizeVariant=null;
const variants=floor.variants||[];
$('sizePanel').style.display='block';
// Collect all sizes: main entry first, then variants
const mainLabel=floor.size_label||'默认尺寸';
const all=[{sku:floor.sku,label:mainLabel},...variants.map(v=>({sku:v.sku,label:v.size_label||v.sku}))];
all.forEach((v,i)=>{
const div=document.createElement('div');
div.className='option';
div.innerHTML=`<div class="name">${v.label}</div><div class="meta">${v.sku}</div>`;
div.onclick=()=>{state.sizeVariant=selectIn(el,v,div,'.option')};
el.appendChild(div);
if(i===0){div.click();}
});
}
function cardText(f){
return [f.name,f.color_tone,f.finish,f.material,f.sku].filter(Boolean).join(' ');
}
function renderRooms(){
const el=$('room可选效果'); el.innerHTML='';
(state.options.rooms||[]).forEach((r,i)=>{
const div=document.createElement('div');
div.className='option';
div.innerHTML=`<div class="name">${r.name}</div><div class="meta">code ${r.code}</div>`;
div.onclick=()=>{state.room=selectIn(el,r,div,'.option')};
el.appendChild(div);
if(i===0)div.click();
});
}
function render铺设方式s(){
const el=$('pattern可选效果'); el.innerHTML=''; state.pattern=null;
patternList().forEach((p,i)=>{
const div=document.createElement('div');
div.className='option';
div.innerHTML=`<div class="name">${p.name}</div><div class="meta">code ${p.code}</div>`;
div.onclick=()=>{state.pattern=selectIn(el,p,div,'.option');checkReady()};
el.appendChild(div);
if(i===0)div.click();
});
}
function renderFloors(){
const el=$('floor可选效果'); el.innerHTML=''; state.floor=null;
const q=$('floorSearch').value.trim().toLowerCase();
const list=floorList().filter(f=>!q||cardText(f).toLowerCase().includes(q)).slice(0,200);
if(list.length===0){el.innerHTML='<div class="small">没有匹配的地板款式</div>';checkReady();return}
list.forEach(f=>{
const div=document.createElement('div');
div.className='floor-card';
const img=f.image_url ? `<img src="${f.image_url}" alt="">` : '<img alt="">';
div.innerHTML=`${img}<div class="title">${f.name||f.sku}</div><div class="desc">${[f.color_tone,f.finish,f.material,f.sku].filter(Boolean).join(' · ')}</div>`;
div.onclick=()=>{state.floor=selectIn(el,f,div,'.floor-card');render尺寸s(f);checkReady()};
el.appendChild(div);
});
el.querySelector('.floor-card')?.click();
}
function setType(type){
state.type=type;
[...$('type可选效果').querySelectorAll('.option')].forEach(x=>x.classList.toggle('selected',x.dataset.type===type));
$('floorTitle').textContent=type==='tile'?'选择Tile / Stone款式':type==='vinyl'?'选择Luxury Vinyl款式':type==='special'?'AI Test地板 (含AI描述)':'选择Hardwood款式';
const groutDisabled=type!=='tile'&&type!=='vinyl'&&type!=='special';
$('grout').disabled=groutDisabled;
$('groutToggle').classList.toggle('disabled',groutDisabled);
if(groutDisabled){$('grout').checked=false;$('groutToggle').classList.remove('selected')}
render铺设方式s(); renderFloors(); checkReady();
}
async function load可选效果(){
setProgress('加载选项','正在读取数据库中的地板款式...', '8%');
const res=await fetch('/floor/options');
const text=await res.text();
let json; try{json=JSON.parse(text)}catch{throw new 错误('选项接口返回非 JSON\n'+text)}
if(!res.ok||json.code!==200)throw new 错误(json.error||text);
state.options=apiData(json);
renderRooms(); setType('wood');
$('statusBox').style.display='none';
}
function handleFile(file){
state.file=file; state.originalUrl=URL.createObjectURL(file);
$('previewImg').src=state.originalUrl;
$('previewImg').style.display='block';
$('uploadHint').style.display='none';
$('compare').style.display='none';
$('resultImg').style.display='none';
$('resultPlaceholder').style.display='flex';
$('resultPlaceholder').innerHTML='已选择图片,等待生成';
checkReady();
}
async function readJSON(res){
const text=await res.text();
try{return {json:JSON.parse(text),raw:text}}catch{return {json:null,raw:text}}
}
function progressInfo(data){
if(data.step==='check')return ['第一步:内容审核','20%'];
if(data.step==='mask')return ['第二步:生成地板蒙版','45%'];
if(data.step==='inpaint')return ['第三步:铺设地板','75%'];
if(data.step==='generate')return ['第二步AI生成地板','75%'];
if(data.step==='done')return ['完成','100%'];
if(data.step==='error')return ['错误','100%'];
return ['处理中','35%'];
}
function formatMs(ms){
if(!ms)return '';
return ms>=60000 ? (ms/60000).toFixed(0)+'m'+(ms%60000/1000).toFixed(0)+'s' : ms>=1000 ? (ms/1000).toFixed(1)+'s' : ms+'ms';
}
function formatTimings(data){
const timings=data.timings_ms||{};
const stepNames={check:'内容审核',mask:'蒙版生成',inpaint:'地板铺设',generate:'AI生成',cleanup:'清理',total:'总耗时'};
const parts=[];
for(const key of ['check','mask','inpaint','generate','total']){
if(timings[key])parts.push(`${stepNames[key]||key}: ${formatMs(timings[key])}`);
}
return parts.length ? '\n⏱ '+parts.join(' | ') : '';
}
// Live elapsed timer
function startLiveTimer(){
if(state.liveInterval)clearInterval(state.liveInterval);
state.liveStart=Date.now();
state.liveInterval=setInterval(()=>{
const elapsed=Date.now()-state.liveStart;
const el=$('liveTimer'); if(!el)return;
el.textContent='⏱️ 已运行: '+formatMs(elapsed);
el.style.display='block';
},1000);
}
async function poll(jobId){
const res=await fetch('/floor/status?job_id='+encodeURIComponent(jobId));
const {json,raw}=await readJSON(res);
const data=apiData(json);
state.lastStatus=data;
if(!res.ok||!json)throw new 错误(`状态接口错误 HTTP ${res.status}\n${raw}`);
const [label,width]=progressInfo(data);
setProgress(label,`job_id: ${jobId}\n状态: ${data.status||''}\n步骤: ${data.step||''}\n消息: ${data.message||''}${formatTimings(data)}`,width);
if(data.status==='done'||data.status==='error'){
if(state.liveInterval)clearInterval(state.liveInterval);
}
if(data.status==='error'||data.step==='error'){
throw new 错误(`${data.error||data.message||'未知错误'}\n\n原始状态响应:\n${JSON.stringify(json,null,2)}`);
}
if(data.status==='done'&&data.result){
clearInterval(state.timer); state.timer=null;
$('generateBtn').classList.remove('loading');
$('generateBtn').disabled=false;
const url=data.result+'?t='+Date.now();
$('resultPlaceholder').style.display='none';
$('resultImg').src=url; $('resultImg').style.display='block';
setupCompare(state.originalUrl,url);
}
}
function setupCompare(before,after){
$('beforeImg').src=before; $('afterImg').src=after;
$('compare').style.display='block';
requestAnimationFrame(()=>$('beforeImg').style.width=$('compare').offsetWidth+'px');
}
function updateCompare(clientX){
const rect=$('compare').getBoundingClientRect();
let pct=(clientX-rect.left)/rect.width*100;
pct=Math.max(2,Math.min(98,pct));
$('clip').style.width=pct+'%';
$('handle').style.left=pct+'%';
}
$('uploadZone').onclick=()=>$('fileInput').click();
$('fileInput').onchange=()=>{if($('fileInput').files[0])handleFile($('fileInput').files[0])};
$('uploadZone').ondragover=e=>{e.preventDefault();$('uploadZone').classList.add('dragover')};
$('uploadZone').ondragleave=()=>$('uploadZone').classList.remove('dragover');
$('uploadZone').ondrop=e=>{e.preventDefault();$('uploadZone').classList.remove('dragover');if(e.dataTransfer.files[0])handleFile(e.dataTransfer.files[0])};
$('type可选效果').onclick=e=>{const opt=e.target.closest('.option');if(opt)setType(opt.dataset.type)};
$('floorSearch').oninput=renderFloors;
$('grout').onchange=()=>$('groutToggle').classList.toggle('selected',$('grout').checked);
$('baseboard').onchange=()=>$('baseboardToggle').classList.toggle('selected',$('baseboard').checked);
$('groutToggle').onclick=e=>{if(e.target.tagName!=='INPUT'&&!$('grout').disabled){$('grout').checked=!$('grout').checked;$('grout').onchange()}};
$('baseboardToggle').onclick=e=>{if(e.target.tagName!=='INPUT'){$('baseboard').checked=!$('baseboard').checked;$('baseboard').onchange()}};
$('generateBtn').onclick=async()=>{
if(!state.file||!state.floor||!state.pattern)return;
clearInterval(state.timer); clear错误();
$('generateBtn').classList.add('loading');
$('generateBtn').disabled=true;
$('compare').style.display='none';
$('resultImg').style.display='none';
$('resultPlaceholder').style.display='flex';
$('resultPlaceholder').innerHTML='正在提交任务...';
setProgress('提交任务','正在上传图片...', '10%');
startLiveTimer();
try{
const floorId=(state.sizeVariant&&state.sizeVariant.sku)||state.floor.id||state.floor.sku||'';
const patternCode=state.pattern.code||'';
const roomCode=state.room?state.room.code:0;
const fd=new FormData();
fd.append('image',state.file);
fd.append('floor_id',floorId);
fd.append('pattern_code',patternCode);
fd.append('room_code',roomCode);
fd.append('grout_lines',$('grout').checked?'true':'false');
fd.append('white_baseboard',$('baseboard').checked?'true':'false');
fd.append('material_source',state.materialSource||'image');
const submitted=`提交参数:\nfloor_id: ${floorId}\npattern_code: ${patternCode}\nroom_code: ${roomCode}\ngrout_lines: ${$('grout').checked}\nwhite_baseboard: ${$('baseboard').checked}`;
setProgress('提交任务',submitted, '10%');
const res=await fetch('/floor/generate',{method:'POST',body:fd});
const {json,raw}=await readJSON(res);
if(!res.ok||!json||json.code!==200){
throw new 错误(`${json?.error||'提交失败'}\n\n${submitted}\n\nHTTP ${res.status}\n${raw}`);
}
const jobId=json.data.job_id;
await poll(jobId);
state.timer=setInterval(()=>poll(jobId).catch(err=>{
clearInterval(state.timer); state.timer=null;
$('generateBtn').classList.remove('loading');
$('generateBtn').disabled=false;
show错误(err.message, state.lastStatus ? JSON.stringify(state.lastStatus,null,2) : '');
}),2500);
}catch(err){
if(state.liveInterval){clearInterval(state.liveInterval);$('liveTimer').style.display='none'}
$('generateBtn').classList.remove('loading');
$('generateBtn').disabled=false;
$('statusBox').style.display='none';
show错误(err.message,'');
}
};
let dragging=false;
$('compare').addEventListener('mousedown',e=>{dragging=true;updateCompare(e.clientX);e.preventDefault()});
window.addEventListener('mousemove',e=>{if(dragging)updateCompare(e.clientX)});
window.addEventListener('mouseup',()=>dragging=false);
$('compare').addEventListener('touchstart',e=>{dragging=true;updateCompare(e.touches[0].clientX);e.preventDefault()},{passive:false});
window.addEventListener('touchmove',e=>{if(dragging)updateCompare(e.touches[0].clientX)},{passive:false});
window.addEventListener('touchend',()=>dragging=false);
window.addEventListener('resize',()=>{if($('compare').style.display==='block')$('beforeImg').style.width=$('compare').offsetWidth+'px'});
load可选效果().catch(err=>show错误('选项加载失败',err.message));
</script>
</body>
</html>

View File

@ -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))
}

View File

@ -0,0 +1,9 @@
package api
import _ "embed"
//go:embed index.html
var indexHTML string
//go:embed questionnaire.html
var questionnaireHTML string

View File

@ -0,0 +1,359 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>地板偏好问卷 - FloorVisualizer</title>
<style>
*{box-sizing:border-box}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",Arial,sans-serif;background:#f5f3ee;color:#24211d}
button,input,select{font:inherit}
.shell{min-height:100vh;display:grid;grid-template-columns:390px 1fr}
.sidebar{background:#faf9f6;border-right:1px solid #ddd6c8;padding:24px;position:sticky;top:0;height:100vh;overflow:auto}
.brand{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:22px}
.brand h1{font-size:20px;line-height:1.2;margin:0;color:#1e3832}
.brand a{color:#51645f;text-decoration:none;font-size:13px}
.progress{height:8px;background:#e4ded1;border-radius:999px;overflow:hidden;margin:10px 0 20px}
.progress span{display:block;height:100%;width:0;background:#2f6f64;transition:width .2s}
.group{padding:18px 0;border-top:1px solid #e3dccf}
.group:first-of-type{border-top:0}
.group h2{font-size:15px;margin:0 0 12px;color:#2d2a25}
.group .optional{font-size:12px;color:#867d70;font-weight:400;margin-left:6px}
.chips{display:flex;flex-wrap:wrap;gap:8px}
.chip{border:1px solid #cfc5b4;background:#fff;padding:8px 10px;border-radius:8px;color:#39342d;cursor:pointer;min-height:38px}
.chip:hover{border-color:#2f6f64}
.chip.selected{border-color:#2f6f64;background:#dfeee9;color:#183b34}
.chip.swatch{display:flex;align-items:center;gap:8px}
.dot{width:15px;height:15px;border-radius:50%;border:1px solid rgba(0,0,0,.18);flex:0 0 auto}
.row{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.field{display:flex;flex-direction:column;gap:6px}
.field label{font-size:12px;color:#6f675d}
.field input,.field select{width:100%;border:1px solid #cfc5b4;background:#fff;border-radius:8px;padding:10px;color:#24211d}
.actions{display:flex;gap:10px;position:sticky;bottom:0;background:linear-gradient(180deg,rgba(250,249,246,0),#faf9f6 20%);padding-top:18px}
.primary,.secondary{border:0;border-radius:8px;padding:12px 14px;cursor:pointer;font-weight:700}
.primary{background:#2f6f64;color:white;flex:1}
.primary:hover{background:#24594f}
.primary:disabled{background:#9aa5a1;cursor:not-allowed}
.secondary{background:#e9e2d6;color:#3f3931}
.main{padding:28px;overflow:auto}
.summary{display:grid;grid-template-columns:1fr auto;gap:20px;align-items:end;margin-bottom:22px}
.summary h2{font-size:24px;margin:0 0 8px;color:#1d332f}
.summary p{margin:0;color:#6b6258;line-height:1.5}
.stats{display:flex;gap:12px;flex-wrap:wrap;justify-content:flex-end}
.stat{background:#fff;border:1px solid #ddd6c8;border-radius:8px;padding:10px 12px;min-width:110px}
.stat strong{display:block;font-size:18px;color:#2f6f64}
.stat span{font-size:12px;color:#777064}
.empty{min-height:420px;border:1px dashed #cfc5b4;border-radius:8px;background:#fffaf2;display:flex;align-items:center;justify-content:center;text-align:center;color:#6b6258;line-height:1.7;padding:24px}
.results{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
.card{background:#fff;border:1px solid #ddd6c8;border-radius:8px;display:grid;grid-template-columns:160px 1fr;min-height:176px;overflow:hidden}
.media{background:#e6e0d4;position:relative}
.media img{width:100%;height:100%;object-fit:cover;display:block}
.rank{position:absolute;top:10px;left:10px;background:#1e3832;color:white;border-radius:999px;padding:4px 8px;font-size:12px;font-weight:700}
.score{position:absolute;right:10px;bottom:10px;background:#d99c35;color:#20170b;border-radius:999px;padding:4px 8px;font-size:12px;font-weight:800}
.content{padding:14px;display:flex;flex-direction:column;gap:8px}
.meta{display:flex;flex-wrap:wrap;gap:6px}
.pill{background:#eee8dc;border-radius:999px;padding:4px 8px;font-size:12px;color:#5d5449}
.title{font-size:16px;font-weight:800;line-height:1.3;color:#25211b}
.sub{font-size:13px;color:#6f675d;line-height:1.35}
.reasons{display:flex;flex-wrap:wrap;gap:6px;margin-top:auto}
.reason{border:1px solid #cbded8;background:#eef8f5;color:#24594f;border-radius:999px;padding:4px 8px;font-size:12px}
.links{display:flex;gap:8px;margin-top:2px}
.links a{color:#2f6f64;font-size:13px;text-decoration:none;font-weight:700}
.loading{opacity:.7;pointer-events:none}
.notice{display:none;margin-bottom:14px;padding:12px 14px;border:1px solid #d9b978;background:#fff4d8;border-radius:8px;color:#5c4518}
@media(max-width:1100px){.shell{grid-template-columns:1fr}.sidebar{position:relative;height:auto}.summary{grid-template-columns:1fr}.stats{justify-content:flex-start}.results{grid-template-columns:1fr}}
@media(max-width:620px){.sidebar,.main{padding:18px}.card{grid-template-columns:1fr}.media{height:210px}.row{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="brand">
<h1>地板偏好问卷</h1>
<a href="/">返回 Demo</a>
</div>
<div class="progress"><span id="progress"></span></div>
<section class="group" data-required="rooms">
<h2>铺设空间</h2>
<div class="chips" data-key="rooms" data-multi="true">
<button class="chip" data-value="living_room">客厅</button>
<button class="chip" data-value="bedroom">卧室</button>
<button class="chip" data-value="kitchen">厨房</button>
<button class="chip" data-value="bathroom">卫生间</button>
<button class="chip" data-value="basement">地下室</button>
<button class="chip" data-value="rental">出租房/高频使用</button>
<button class="chip" data-value="commercial">商业空间</button>
</div>
</section>
<section class="group" data-required="styles">
<h2>整体风格</h2>
<div class="chips" data-key="styles" data-multi="true" data-max="2">
<button class="chip" data-value="modern">现代简约</button>
<button class="chip" data-value="warm_natural">温暖自然</button>
<button class="chip" data-value="classic">经典传统</button>
<button class="chip" data-value="rustic">乡村/复古</button>
<button class="chip" data-value="luxury_stone">轻奢石纹</button>
<button class="chip" data-value="industrial">工业冷调</button>
<button class="chip" data-value="coastal">明亮海岸风</button>
</div>
</section>
<section class="group" data-required="looks">
<h2>外观类型</h2>
<div class="chips" data-key="looks" data-multi="true">
<button class="chip" data-value="wood_look">木纹</button>
<button class="chip" data-value="stone_tile">石纹/瓷砖感</button>
<button class="chip" data-value="real_hardwood">真实硬木</button>
<button class="chip" data-value="light_clean">浅色干净</button>
<button class="chip" data-value="dark_rich">深色稳重</button>
</div>
</section>
<section class="group" data-required="color_tones">
<h2>颜色倾向</h2>
<div class="chips" data-key="color_tones" data-multi="true">
<button class="chip swatch" data-value="Natural"><span class="dot" style="background:#b98b58"></span>自然木色</button>
<button class="chip swatch" data-value="Light Gray"><span class="dot" style="background:#b9bbb8"></span>浅灰</button>
<button class="chip swatch" data-value="Off White"><span class="dot" style="background:#eee8da"></span>米白</button>
<button class="chip swatch" data-value="Dark Brown"><span class="dot" style="background:#5a3928"></span>深棕</button>
<button class="chip swatch" data-value="Taupe"><span class="dot" style="background:#9b8d7a"></span>灰褐</button>
<button class="chip swatch" data-value="Cherry"><span class="dot" style="background:#8d3f31"></span>樱桃色</button>
<button class="chip swatch" data-value="Black Walnut"><span class="dot" style="background:#31241d"></span>黑胡桃</button>
</div>
</section>
<section class="group" data-required="budget">
<h2>预算范围</h2>
<div class="chips" data-key="budgetPreset">
<button class="chip" data-min="0" data-max="5">&lt; $5/sqft</button>
<button class="chip" data-min="5" data-max="8">$5-$8</button>
<button class="chip" data-min="8" data-max="12">$8-$12</button>
<button class="chip" data-min="12" data-max="20">$12-$20</button>
<button class="chip" data-min="20" data-max="0">$20+</button>
</div>
<div class="row" style="margin-top:10px">
<div class="field"><label>最低</label><input id="budgetMin" type="number" min="0" step="0.5" placeholder="0"></div>
<div class="field"><label>最高</label><input id="budgetMax" type="number" min="0" step="0.5" placeholder="不限"></div>
</div>
</section>
<section class="group">
<h2>性能偏好<span class="optional">可跳过</span></h2>
<div class="chips" data-key="performance" data-multi="true">
<button class="chip" data-value="waterproof">防水/防潮</button>
<button class="chip" data-value="scratch_resistant">耐磨耐刮</button>
<button class="chip" data-value="easy_clean">容易清洁</button>
<button class="chip" data-value="pet_kid">适合宠物/孩子</button>
<button class="chip" data-value="comfort_quiet">脚感舒适/安静</button>
<button class="chip" data-value="eco">环保倾向</button>
</div>
</section>
<section class="group">
<h2>表面质感<span class="optional">可跳过</span></h2>
<div class="chips" data-key="finishes" data-multi="true">
<button class="chip" data-value="Matte">哑光</button>
<button class="chip" data-value="Gloss">亮面</button>
<button class="chip" data-value="Distressed/Embossed">复古做旧</button>
<button class="chip" data-value="Wire Brushed">拉丝纹理</button>
<button class="chip" data-value="Textured">触感纹理</button>
</div>
</section>
<section class="group">
<h2>规格视觉<span class="optional">可跳过</span></h2>
<div class="chips" data-key="size_preference">
<button class="chip" data-value="wide_plank">宽板木纹</button>
<button class="chip" data-value="standard_plank">标准木板</button>
<button class="chip" data-value="large_tile">大规格石材</button>
<button class="chip" data-value="small_tile">小砖/马赛克</button>
</div>
</section>
<section class="group">
<h2>品牌<span class="optional">可跳过</span></h2>
<div class="chips" id="brandChips" data-key="brands" data-multi="true"></div>
<div class="field" style="margin-top:10px">
<label>品牌处理方式</label>
<select id="brandMode">
<option value="boost">偏好加分</option>
<option value="only">只看所选品牌</option>
</select>
</div>
</section>
<section class="group">
<h2>优先级<span class="optional">可跳过</span></h2>
<div class="chips" data-key="priority">
<button class="chip" data-value="style">风格最重要</button>
<button class="chip" data-value="budget">价格最重要</button>
<button class="chip" data-value="durability">耐用最重要</button>
<button class="chip" data-value="brand">品牌最重要</button>
<button class="chip" data-value="color">颜色最重要</button>
</div>
</section>
<div class="actions">
<button class="secondary" id="resetBtn">重置</button>
<button class="primary" id="submitBtn">推荐 Top 10</button>
</div>
</aside>
<main class="main">
<div class="summary">
<div>
<h2>问卷推荐结果</h2>
<p id="summaryText">回答左侧核心问题后,系统会对产品库打分、合并同款不同尺寸,并返回最匹配的 10 款。</p>
</div>
<div class="stats">
<div class="stat"><strong id="totalStat">-</strong><span>候选 SKU</span></div>
<div class="stat"><strong id="uniqueStat">-</strong><span>去重款式</span></div>
<div class="stat"><strong id="answeredStat">0%</strong><span>问卷完成度</span></div>
</div>
</div>
<div class="notice" id="notice"></div>
<div class="empty" id="empty">请选择你的空间、风格、外观、颜色和预算。</div>
<div class="results" id="results"></div>
</main>
</div>
<script>
const state={rooms:[],styles:[],looks:[],color_tones:[],performance:[],finishes:[],brands:[],size_preference:'',priority:'',budget:{min:0,max:0}};
const required=['rooms','styles','looks','color_tones','budget'];
const $=id=>document.getElementById(id);
function selectedValues(key){return state[key]||[]}
function setProgress(){
let done=0;
for(const key of required){
if(key==='budget'){ if(state.budget.min>0||state.budget.max>0) done++; }
else if((state[key]||[]).length) done++;
}
$('progress').style.width=(done/required.length*100)+'%';
$('answeredStat').textContent=Math.round(done/required.length*100)+'%';
}
function bindChips(root=document){
root.querySelectorAll('.chips').forEach(group=>{
group.addEventListener('click',e=>{
const chip=e.target.closest('.chip');
if(!chip)return;
const key=group.dataset.key;
if(key==='budgetPreset'){
group.querySelectorAll('.chip').forEach(c=>c.classList.remove('selected'));
chip.classList.add('selected');
state.budget={min:Number(chip.dataset.min||0),max:Number(chip.dataset.max||0)};
$('budgetMin').value=state.budget.min||'';
$('budgetMax').value=state.budget.max||'';
setProgress();
return;
}
const multi=group.dataset.multi==='true';
const max=Number(group.dataset.max||0);
const value=chip.dataset.value;
if(multi){
const list=selectedValues(key);
const i=list.indexOf(value);
if(i>=0){list.splice(i,1);chip.classList.remove('selected');}
else{
if(max&&list.length>=max){
const removed=list.shift();
group.querySelector(`.chip[data-value="${CSS.escape(removed)}"]`)?.classList.remove('selected');
}
list.push(value);chip.classList.add('selected');
}
}else{
group.querySelectorAll('.chip').forEach(c=>c.classList.remove('selected'));
if(state[key]===value){state[key]='';}
else{state[key]=value;chip.classList.add('selected');}
}
setProgress();
});
});
}
function budgetInputs(){
state.budget={min:Number($('budgetMin').value||0),max:Number($('budgetMax').value||0)};
document.querySelectorAll('[data-key="budgetPreset"] .chip').forEach(c=>c.classList.remove('selected'));
setProgress();
}
function resetAll(){
for(const k of ['rooms','styles','looks','color_tones','performance','finishes','brands'])state[k]=[];
state.size_preference='';state.priority='';state.budget={min:0,max:0};
document.querySelectorAll('.chip').forEach(c=>c.classList.remove('selected'));
$('budgetMin').value='';$('budgetMax').value='';$('brandMode').value='boost';
$('results').innerHTML='';$('empty').style.display='flex';$('notice').style.display='none';
$('totalStat').textContent='-';$('uniqueStat').textContent='-';setProgress();
}
async function loadOptions(){
try{
const res=await fetch('/questionnaire/options');
const json=await res.json();
const data=json.data||json;
const brands=(data.brands||[]).slice(0,12);
$('brandChips').innerHTML=brands.map(b=>`<button class="chip" data-value="${escapeHtml(b.value)}">${escapeHtml(b.value)} <span class="optional">${b.count}</span></button>`).join('');
bindChips($('brandChips').parentElement);
}catch(e){
$('brandChips').innerHTML='<button class="chip" data-value="Shaw Floors">Shaw Floors</button><button class="chip" data-value="COREtec">COREtec</button><button class="chip" data-value="Bruce">Bruce</button>';
bindChips($('brandChips').parentElement);
}
}
async function recommend(){
budgetInputs();
const payload={...state,brand_mode:$('brandMode').value,limit:10};
$('submitBtn').disabled=true;
$('submitBtn').textContent='推荐中...';
document.body.classList.add('loading');
$('notice').style.display='none';
try{
const res=await fetch('/questionnaire/recommend',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const json=await res.json();
if(!res.ok)throw new Error(json.error||'推荐失败');
renderResults(json.data||json);
}catch(e){
$('notice').style.display='block';
$('notice').textContent=e.message;
}finally{
$('submitBtn').disabled=false;
$('submitBtn').textContent='推荐 Top 10';
document.body.classList.remove('loading');
}
}
function renderResults(data){
const recs=data.recommendations||[];
$('totalStat').textContent=data.total_candidates||0;
$('uniqueStat').textContent=data.unique_candidates||0;
$('empty').style.display=recs.length?'none':'flex';
$('empty').textContent=recs.length?'':'没有找到匹配产品,可以放宽品牌或预算条件。';
$('summaryText').textContent=recs.length?`已按你的偏好筛出 ${recs.length} 款推荐,分数来自风格、颜色、预算、材质、性能和品牌等维度。`:'没有找到匹配产品。';
$('results').innerHTML=recs.map((r,i)=>cardHtml(r,i)).join('');
}
function cardHtml(r,i){
const p=r.product||{};
const img=p.room_image_url||p.main_image_url||'';
const price=p.price_per_sqft?`$${Number(p.price_per_sqft).toFixed(2)}/sqft`:'价格待确认';
const specs=r.spec_count>1?`${r.spec_count} 个规格`:(p.size_label||'规格待确认');
const reasons=(r.reasons||[]).map(x=>`<span class="reason">${escapeHtml(x)}</span>`).join('');
return `<article class="card">
<div class="media">${img?`<img src="${escapeHtml(img)}" alt="">`:''}<span class="rank">#${i+1}</span><span class="score">${Math.round(r.score)}分</span></div>
<div class="content">
<div class="meta"><span class="pill">${escapeHtml(p.brand||'')}</span><span class="pill">${escapeHtml(p.category||'')}</span><span class="pill">${escapeHtml(price)}</span></div>
<div class="title">${escapeHtml(p.style_name||p.series_name||p.sku)}</div>
<div class="sub">${escapeHtml([p.series_name,p.color_tone,p.material,p.finish,specs].filter(Boolean).join(' · '))}</div>
<div class="reasons">${reasons}</div>
<div class="links">${p.sku?`<a href="/products/${encodeURIComponent(p.sku)}" target="_blank">查看详情</a>`:''}${p.source_url?`<a href="${escapeHtml(p.source_url)}" target="_blank">品牌页面</a>`:''}</div>
</div>
</article>`;
}
function escapeHtml(s){return String(s??'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]))}
bindChips();
$('budgetMin').addEventListener('input',budgetInputs);
$('budgetMax').addEventListener('input',budgetInputs);
$('resetBtn').addEventListener('click',resetAll);
$('submitBtn').addEventListener('click',recommend);
loadOptions();
setProgress();
</script>
</body>
</html>

View File

@ -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"))))

View File

@ -3,6 +3,7 @@ package handler
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
@ -15,7 +16,7 @@ import (
"floorvisualizer/internal/repository"
)
// ── Floor options ──────────────────────────────────────────
// 閳光偓閳光偓 Floor options 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
type FloorOption struct {
ID string `json:"id"`
@ -25,8 +26,13 @@ type FloorOption struct {
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
}
seen[key] = true
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, ImageURL: p.MainImageURL, SKU: p.SKU,
SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn,
ImageURL: p.MainImageURL, SKU: p.SKU, Description: p.Description,
})
}
}
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
@ -220,6 +324,9 @@ func FloorGenerate(client *openrouter.Client, db *sql.DB) http.HandlerFunc {
"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,18 +359,25 @@ 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,
@ -271,50 +385,192 @@ func FloorOptions(db *sql.DB) http.HandlerFunc {
"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"
}
}

View File

@ -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],
})

View File

@ -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
}

View File

@ -13,6 +13,7 @@ type Product struct {
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"`

View File

@ -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
}

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/redis/go-redis/v9"
@ -23,6 +24,8 @@ type JobStatus struct {
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.
@ -96,11 +112,21 @@ func GetJobStatus(ctx context.Context, rdb *redis.Client, jobID string) (*JobSta
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
}

View File

@ -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
}

View File

@ -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
}
@ -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()

98
main.go
View File

@ -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)
}
}

View File

@ -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 '',