dev #2

Merged
dindang merged 11 commits from dev into main 2026-07-27 08:12:46 +00:00
17 changed files with 949 additions and 225 deletions
Showing only changes of commit 5e35532dbb - Show all commits

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 }
}
}
}
}
}
}
}
@ -2328,4 +2451,4 @@
}
}
}
}
}

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

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=

View File

@ -0,0 +1,13 @@
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))
}

View File

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

View File

@ -63,6 +63,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

@ -18,15 +18,19 @@ import (
// ── Floor options ──────────────────────────────────────────
type FloorOption struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Material string `json:"material"`
ColorTone string `json:"color_tone"`
Finish string `json:"finish"`
SizeLabel string `json:"size_label"`
ImageURL string `json:"image_url"`
SKU string `json:"sku"`
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Material string `json:"material"`
ColorTone string `json:"color_tone"`
Finish string `json:"finish"`
SizeLabel string `json:"size_label"`
WidthIn float64 `json:"width_in"`
LengthIn float64 `json:"length_in"`
ImageURL string `json:"image_url"`
SKU string `json:"sku"`
Description string `json:"description"`
Variants []FloorOption `json:"variants,omitempty"`
}
type PatternOption struct {
@ -42,29 +46,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: "planks at 90° forming continuous zigzag V-lines from bottom-left to top-right of the image"},
{Code: 4, Name: "鱼骨拼 Chevron", Category: "wood", Pattern: "planks with 45° 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° to the image frame, grout lines run diagonally at 60° and 150° 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,43 +92,109 @@ 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 3000`)
if err != nil {
return nil, err
}
// Dedup by brand+style_name, take first as representative
seen := map[string]bool{}
defer rows.Close()
prods, err := repository.ScanProducts(rows)
if err != nil {
return nil, err
}
type key struct{ brand, style string }
seen := map[key]int{} // key → index in out slice
var out []FloorOption
for _, p := range prods {
key := p.Brand + "|" + p.StyleName
if seen[key] {
continue
k := key{p.Brand, p.StyleName}
if idx, ok := seen[k]; ok {
// Add as variant to the representative entry
out[idx].Variants = append(out[idx].Variants, FloorOption{
SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn,
SKU: p.SKU,
})
} else {
seen[k] = len(out)
out = append(out, FloorOption{
ID: p.SKU, Name: p.StyleName, Category: p.Category,
Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish,
SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn,
ImageURL: p.MainImageURL, SKU: p.SKU, Description: p.Description,
})
}
seen[key] = true
out = append(out, FloorOption{
ID: p.SKU, Name: p.StyleName, Category: p.Category,
Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish,
SizeLabel: p.SizeLabel, ImageURL: p.MainImageURL, SKU: p.SKU,
})
}
return out, nil
}
func isTileProduct(p FloorOption) bool {
m := strings.ToLower(p.Material)
return strings.Contains(m, "porcelain") || strings.Contains(m, "ceramic") ||
strings.Contains(m, "stone look") || m == "glass"
}
func isVinylProduct(p FloorOption) bool {
m := strings.ToLower(p.Material)
return m == "luxury vinyl" || strings.Contains(m, "rigid core") || m == "vinyl"
}
func isLaminateProduct(p FloorOption) bool {
return strings.EqualFold(p.Material, "laminate")
}
func isWoodProduct(p FloorOption) bool {
return !isTileProduct(p) && !isVinylProduct(p) && !isLaminateProduct(p)
}
func findFloorBySKU(db *sql.DB, sku string) *FloorOption {
p, err := repository.GetProductBySKU(db, sku)
if err != nil {
return nil
}
return &FloorOption{
f := &FloorOption{
ID: p.SKU, Name: p.StyleName, Category: p.Category,
Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish,
SizeLabel: p.SizeLabel, ImageURL: p.MainImageURL, SKU: p.SKU,
SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn,
ImageURL: p.MainImageURL, SKU: p.SKU, Description: p.Description,
}
// Fetch size variants (same style, different dimensions)
variants, _ := repository.GetVariantsByStyle(db, sku)
for _, vp := range variants {
f.Variants = append(f.Variants, FloorOption{
SizeLabel: vp.SizeLabel, WidthIn: vp.WidthIn, LengthIn: vp.LengthIn,
SKU: vp.SKU,
})
}
// Canonical reference image = smallest size variant (by width)
// All sizes of the same style use the same reference image to prevent color drift
canonicalSKU, canonicalImage, canonicalWidth, canonicalLength := p.SKU, p.MainImageURL, f.WidthIn, f.LengthIn
rows, _ := db.Query(`SELECT sku, main_image_url, COALESCE(width_in,0), COALESCE(length_in,0) FROM products
WHERE style_name = $1 AND brand = $2
ORDER BY COALESCE(width_in,999), COALESCE(length_in,999) LIMIT 1`,
p.StyleName, p.Brand)
if rows != nil {
defer rows.Close()
if rows.Next() {
rows.Scan(&canonicalSKU, &canonicalImage, &canonicalWidth, &canonicalLength)
}
}
f.ImageURL = canonicalImage
// Build dynamic size instruction if selected size differs from reference image size
if (canonicalWidth > 0 && canonicalLength > 0) && (f.WidthIn > 0 && f.LengthIn > 0) {
if canonicalWidth != f.WidthIn || canonicalLength != f.LengthIn {
f.Description = fmt.Sprintf(
`SIZE MAPPING: The reference image shows %.1f"×%.1f" planks. Generate %.1f"×%.1f" planks — same exact material, only scale the board size proportionally. | %s`,
canonicalWidth, canonicalLength, f.WidthIn, f.LengthIn, f.Description,
)
}
}
return f
}
// ── Public accessors for worker ────────────────────────────
@ -145,6 +215,54 @@ func BuildFloorPromptPublic(f FloorOption, p PatternOption, room *RoomOption) st
return buildFloorPrompt(f, p, room)
}
// 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) string {
floorPrompt := buildFloorPrompt(f, p, room)
roomCtx := ""
if room != nil && room.Code != 0 {
roomCtx = fmt.Sprintf(" This is a %s.", room.Name)
}
return fmt.Sprintf(
`FIRST identify the floor: Look at this room photo.%s
The floor is the flat horizontal walking surface NOT walls, NOT baseboards, NOT furniture, NOT stairs.
You will replace ONLY the floor area. Every other pixel must remain bit-identical to the input photo.
%s
Return ONLY the completed image. No text.`,
roomCtx,
floorPrompt,
)
}
// buildPatternConstraint returns installation rules specific to each pattern code.
func buildPatternConstraint(code int) string {
switch code {
case 1:
return `PATTERN RULES — Straight Lay (wood): Planks run from the bottom-left of the image toward the top-right, at roughly 45° to the image frame. Long edges of each plank point toward the upper-right corner. Joints staggered 6" minimum — random pattern. 1/8" expansion gap.`
case 2:
return `PATTERN RULES — Horizontal Lay (wood): Planks run horizontally — left to right, parallel to the bottom edge of the image. Long edges of each plank are horizontal across the photo. Joints staggered 6" minimum — random pattern. 1/8" expansion gap.`
case 3:
return `PATTERN RULES — Herringbone (wood): Planks at 90° forming continuous zigzag. Each plank end meets neighbor at 90°. V-lines run from bottom-left to top-right of the image. Joints tight — continuous unbroken zigzag.`
case 4:
return `PATTERN RULES — Chevron (wood): Planks with 45° mitered ends form continuous V. V-points aligned in a straight line running bottom-left to top-right across the image. Joint lines are straight and continuous. NOT herringbone — mitered joints, not staggered.`
case 5:
return `PATTERN RULES — Straight Grid (tile): Tiles aligned in rows and columns. Grout lines run horizontally (parallel to bottom edge of image) and vertically (perpendicular to bottom edge). NO offset between rows. Grout ~1/8".`
case 6:
return `PATTERN RULES — Running Bond (tile): Each row offset 50% from previous — classic brickwork. Grout lines parallel to bottom edge of image are continuous; grout lines perpendicular to bottom edge are staggered. Grout ~1/8".`
case 7:
return `PATTERN RULES — 1/3 Offset (tile): Each row offset exactly 33% of tile length. Every third row aligns. Rows run parallel to bottom edge of image. Grout ~1/8". NOT 50% bond — offset is exactly 1/3.`
case 8:
return `PATTERN RULES — Hexagonal (tile): Six-sided tiles interlocked in honeycomb mesh. Each tile touches 6 neighbors. Flat edges of hexagons are horizontal (parallel to bottom edge of image). Grout follows hexagonal edges — network pattern, not grid.`
case 9:
return `PATTERN RULES — Diagonal (tile): Tiles rotated 60° relative to the image frame — all tiles at a steep diagonal, diamond orientation. Grout lines run diagonally across the photo at 60° and 150° to the bottom edge. None parallel or perpendicular to image edges. Triangle cuts at all walls. Grout ~1/8".`
default:
return ""
}
}
// ── Floor Generate Handler (Redis queue) ──────────────────
func SetQueue(enqueue func(ctx context.Context, jobID string, payload map[string]string) error) {
@ -256,14 +374,18 @@ func FloorStatus(w http.ResponseWriter, r *http.Request) {
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")
allWood := append(woodFloors, woodFloors2...)
allTile := append(tileFloors, laminateFloors...)
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)
}
}
writeJSON(w, 200, map[string]any{
"wood_floors": allWood,
@ -279,10 +401,25 @@ func FloorOptions(db *sql.DB) http.HandlerFunc {
// ── 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 := `You are a precise floor segmentation tool. Create a pure black-and-white mask for this room photo.
STRICT DEFINITIONS:
- WHITE (#FFFFFF): ONLY the flat, horizontal walking surface (the floor). Include the entire visible floor area up to the exact edge where it meets walls, baseboards, cabinets, or stairs.
- BLACK (#000000): EVERYTHING that is not flat horizontal floor walls, baseboards/skirting boards, stairs/stair risers (they are NOT flat floor), furniture and furniture legs, doors and door frames, windows, cabinets, appliances, rugs/carpets, ceiling, decorations, people, pets.
EDGE RULES:
- At the wall-floor boundary: cut exactly along the junction. The baseboard/skirting board is BLACK, the floor is WHITE.
- Furniture legs resting on the floor: the legs are BLACK. The floor visible BETWEEN and AROUND legs is WHITE.
- Stairs: each step's horizontal tread AND vertical riser are BLACK (stairs are not a continuous flat plane).
- Rugs/carpets on the floor: BLACK (they are not the permanent floor).
SELF-CHECK before output:
- Did you include any wall area? fix it.
- Did you miss any floor area between furniture legs? fill it WHITE.
- Did you paint any stair surface WHITE? make it BLACK.
- Are all edges sharp and precise at boundaries? verify.
Output ONLY the mask image. No text, no explanation.`
if room != nil && room.Code != 0 {
base += fmt.Sprintf("\nContext: This is a %s.", room.Name)
}
@ -295,8 +432,7 @@ func buildFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption
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 {
if isTileProduct(floor) || isLaminateProduct(floor) {
matType = "tile"
}
@ -305,12 +441,127 @@ func buildFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption
materialDesc += fmt.Sprintf(", %s size", floor.SizeLabel)
}
// Include product description if available for richer visual detail
var descriptionText string
if floor.Description != "" {
descriptionText = fmt.Sprintf("\nVisual appearance: %s", floor.Description)
}
productLock := ""
if floor.ImageURL != "" {
productLock = fmt.Sprintf(
`The reference image is the EXACT product material not style inspiration, not a mood board.
LOCK THESE PROPERTIES from the reference image: base color, color temperature, grain/vein pattern,
knot density, surface texture, gloss level, material character.
FORBIDDEN: color shift, different wood species or stone type, new grain pattern, different stain,
gloss change, "luxury upgrade" or aesthetic reinterpretation.`,
)
}
// Size instruction: only change board proportion, never the material itself
sizeInstruction := ""
if len(floor.Variants) > 0 {
var variantList []string
for _, v := range floor.Variants {
marker := ""
if v.SKU == floor.SKU {
marker = " ← SELECTED"
}
if v.SizeLabel != "" {
variantList = append(variantList, v.SizeLabel+marker)
} else if v.WidthIn > 0 && v.LengthIn > 0 {
variantList = append(variantList, fmt.Sprintf(`%.1f"×%.1f"`, v.WidthIn, v.LengthIn)+marker)
}
}
if len(variantList) > 0 {
sizeInstruction = fmt.Sprintf(
"\nSIZE: This product comes in multiple sizes: %s.\nONLY change board width and seam spacing. The material texture, color, and grain are from the reference image — identical in every size. Wider boards use the SAME texture source, just with fewer seams. Do NOT reinterpret the wood to look \"more premium\" for wider planks.",
strings.Join(variantList, ", "),
)
}
} else if floor.SizeLabel != "" {
sizeInstruction = fmt.Sprintf(
"\nSIZE: Each piece is %s. Only adjust board proportion and seam spacing. The material texture is from the reference image — do not change it.",
floor.SizeLabel,
)
} else if floor.WidthIn > 0 && floor.LengthIn > 0 {
sizeInstruction = fmt.Sprintf(
`\nSIZE: Each piece is %.1f" × %.1f". Only adjust board proportion and seam spacing. The material texture is from the reference image — do not change it.`,
floor.WidthIn, floor.LengthIn,
)
}
patternInstruction := buildPatternConstraint(pattern.Code)
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,
`You are a flooring product visualization renderer for a commercial catalog. Your sole function is to replace the floor surface in interior photos with a specific flooring SKU. This is product-accurate catalog photography NOT interior design or artistic interpretation.
STRICT OUTPUT RULES:
1. Respond with ONLY the completed PNG image. No text, no explanation, no JSON wrapper.
2. The output must have the exact same pixel dimensions as the input image.
Do not crop, pad, upscale, downscale, rotate, or alter the image geometry in any way.
3. Do not apply global filtering, sharpening, blurring, tone-mapping, contrast adjustment,
or style transfer. The only permitted change is the floor surface.
FLOOR SURFACE DEFINITION:
- "Floor" the flat horizontal walking surface. The visible ground plane inside the room.
DO NOT bleed onto: walls, baseboards/skirting boards, stairs, stair risers, furniture,
chair legs, table legs, cabinet bases, doors, door frames, window glass or frames,
trim, pipes, conduit, rugs, carpets, ceiling, appliances, decorations.
- Stairs are NOT floor each tread and riser must remain completely unchanged.
- If a surface is not the flat horizontal floor, PRESERVE IT EXACTLY.
FLOOR REPLACEMENT RULES:
4. Apply the new floor ONLY to the floor surface. Every pixel outside must be bit-identical to input.
5. Preserve the original room lighting, shadows, and highlights ON the new floor.
6. ADD realistic contact shadows: chair wheels, table legs, cabinet bases MUST cast tight dark shadows
directly at the contact point sharpest there, softening outward. This shows physical weight.
7. Maintain clean but physically installed-looking floor edges not a Photoshop cutout.
8. The new floor must look physically installed and photographed not rendered, not composited.
FIDELITY RULES:
9. The output must be a photorealistic edit of the input photograph.
Do not generate new content, synthesise areas, invent objects, or alter composition.
10. Preserve camera sensor noise, natural vignette, and original white balance on all non-floor surfaces.
AMBIGUITY RULE:
11. If a pixel could be floor OR furniture/wall/baseboard preserve the original unchanged.
Precision over completeness.
FLOOR SPECIFICATION:
- Product: %s (%s %s)
- Pattern: %s %s
The pattern changes layout only. The material comes from the reference image identical regardless of pattern.
%s
- Material details: %s%s%s%s
%s
TEXTURE FIDELITY:
- Every plank/tile must have UNIQUE grain, vein, or tonal variation no visible texture repetition.
- Natural imperfections: minor scratches, micro-wear near chair wheels, colour variance between pieces.
- Seams and joints: subtle dust in gaps, micro-shadow at edges, slight edge wear.
- Polished surfaces: reflections UNEVEN and angle-dependent not uniform glossy sheen.
- Do NOT invent sunbeams, light rays, or window reflections not in the original.
NEGATIVE CONSTRAINTS the floor must NOT contain:
CGI look, 3D render, plastic texture, repeating patterns, uniform gloss, invented lights,
HDR tone-mapping, "luxury showroom" aesthetic, sterile clean seams, floating furniture,
color shift, different wood species, new grain, different stain, aesthetic reinterpretation.
REQUIREMENTS SUMMARY:
Only the flat horizontal floor changes all other pixels preserved bit-identical.
Contact shadows under all furniture sharp at contact, softening outward.
Original lighting and shadows match on new floor.
Output geometry identical to input same dimensions, no cropping.
Scene composition unchanged no new objects, no synthesised content.
Material color, grain, gloss, species match the reference image exactly.
Return ONLY the completed image. No text.`,
floor.Name, matType, materialDesc,
pattern.Name, pattern.Pattern, patternInstruction,
materialDesc,
roomCtx, descriptionText, sizeInstruction, productLock,
)
}

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

@ -1,31 +1,32 @@
package model
type Product struct {
Brand string `json:"brand"`
GroupName string `json:"group_name"`
SKU string `json:"sku"`
SeriesName string `json:"series_name"`
StyleName string `json:"style_name"`
Category string `json:"category"`
Material string `json:"material"`
ColorTone string `json:"color_tone"`
Finish string `json:"finish"`
PricePerSqft float64 `json:"price_per_sqft"`
PriceTier string `json:"price_tier"`
PriceSource string `json:"price_source"`
MainImageURL string `json:"main_image_url"`
RoomImageURL string `json:"room_image_url"`
SourceURL string `json:"source_url"`
Description string `json:"description"`
Status string `json:"status"`
CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"`
WoodSpecies string `json:"wood_species"`
SizeLabel string `json:"size_label"`
WidthIn float64 `json:"width_in"`
LengthIn float64 `json:"length_in"`
Variants []Product `json:"variants,omitempty"`
Specs []ProductSpec `json:"specs,omitempty"`
IsFavorited bool `json:"is_favorited"`
Brand string `json:"brand"`
GroupName string `json:"group_name"`
SKU string `json:"sku"`
SeriesName string `json:"series_name"`
StyleName string `json:"style_name"`
Category string `json:"category"`
Material string `json:"material"`
ColorTone string `json:"color_tone"`
Finish string `json:"finish"`
PricePerSqft float64 `json:"price_per_sqft"`
PriceTier string `json:"price_tier"`
PriceSource string `json:"price_source"`
IsOfficialPrice bool `json:"is_official_price"`
MainImageURL string `json:"main_image_url"`
RoomImageURL string `json:"room_image_url"`
SourceURL string `json:"source_url"`
Description string `json:"description"`
Status string `json:"status"`
CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"`
WoodSpecies string `json:"wood_species"`
SizeLabel string `json:"size_label"`
WidthIn float64 `json:"width_in"`
LengthIn float64 `json:"length_in"`
Variants []Product `json:"variants,omitempty"`
Specs []ProductSpec `json:"specs,omitempty"`
IsFavorited bool `json:"is_favorited"`
}
// ProductSpec is a single size/price variant of a product style.

View File

@ -24,6 +24,8 @@ 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 referenceImageInstruction = "The next image is a reference for the floor material style. Match its color, texture, pattern, and overall appearance exactly when generating the new floor. The generated floor should look like this material."
var supportedAspectRatios = []struct {
value string
width int
@ -51,6 +53,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 +288,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 +310,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 +479,42 @@ 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)
}

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/redis/go-redis/v9"
@ -17,12 +18,14 @@ const (
// JobStatus represents the state of a generation job.
type JobStatus struct {
JobID string `json:"job_id"`
Status string `json:"status"` // pending | processing | done | error
Step string `json:"step"`
Message string `json:"message"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
JobID string `json:"job_id"`
Status string `json:"status"` // pending | processing | done | error
Step string `json:"step"`
Message string `json:"message"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ElapsedMs int64 `json:"elapsed_ms,omitempty"`
TimingsMs map[string]int64 `json:"timings_ms,omitempty"`
}
// NewRedisClient creates a Redis client and pings to verify connectivity.
@ -42,7 +45,7 @@ func NewRedisClient(addr, password string) (*redis.Client, error) {
func EnqueueJob(ctx context.Context, rdb *redis.Client, jobID string) error {
pipe := rdb.Pipeline()
pipe.RPush(ctx, queueKey, jobID)
pipe.HSet(ctx, jobKey(jobID), "status", "pending")
pipe.HSet(ctx, jobKey(jobID), "status", "pending", "queued_at", nowMillis(), "updated_at", nowMillis())
pipe.Expire(ctx, jobKey(jobID), jobTTL)
_, err := pipe.Exec(ctx)
return err
@ -62,22 +65,35 @@ func DequeueJob(ctx context.Context, rdb *redis.Client) (string, error) {
// SetJobStatus updates the job status field.
func SetJobStatus(ctx context.Context, rdb *redis.Client, jobID, status string) error {
return rdb.HSet(ctx, jobKey(jobID), "status", status).Err()
fields := []any{"status", status, "updated_at", nowMillis()}
if status == "processing" {
fields = append(fields, "started_at", nowMillis())
}
return rdb.HSet(ctx, jobKey(jobID), fields...).Err()
}
// SetJobProgress updates the step and message for a job.
func SetJobProgress(ctx context.Context, rdb *redis.Client, jobID, step, msg string) error {
return rdb.HSet(ctx, jobKey(jobID), "step", step, "message", msg).Err()
return rdb.HSet(ctx, jobKey(jobID), "step", step, "message", msg, "updated_at", nowMillis()).Err()
}
// SetJobTiming stores a single step duration and the total elapsed time so far.
func SetJobTiming(ctx context.Context, rdb *redis.Client, jobID, step string, duration, elapsed time.Duration) error {
return rdb.HSet(ctx, jobKey(jobID),
step+"_ms", duration.Milliseconds(),
"elapsed_ms", elapsed.Milliseconds(),
"updated_at", nowMillis(),
).Err()
}
// SetJobResult marks a job as done with the result image URL.
func SetJobResult(ctx context.Context, rdb *redis.Client, jobID, url string) error {
return rdb.HSet(ctx, jobKey(jobID), "status", "done", "step", "done", "message", "Complete", "result", url).Err()
return rdb.HSet(ctx, jobKey(jobID), "status", "done", "step", "done", "message", "Complete", "result", url, "updated_at", nowMillis()).Err()
}
// SetJobError marks a job as errored with an error message.
func SetJobError(ctx context.Context, rdb *redis.Client, jobID, errMsg string) error {
return rdb.HSet(ctx, jobKey(jobID), "status", "error", "step", "error", "message", errMsg, "error", errMsg).Err()
return rdb.HSet(ctx, jobKey(jobID), "status", "error", "step", "error", "message", errMsg, "error", errMsg, "updated_at", nowMillis()).Err()
}
// GetJobStatus retrieves the full status of a job.
@ -90,17 +106,27 @@ func GetJobStatus(ctx context.Context, rdb *redis.Client, jobID string) (*JobSta
return nil, fmt.Errorf("job not found")
}
js := &JobStatus{
JobID: jobID,
Status: m["status"],
Step: m["step"],
Message: m["message"],
Result: m["result"],
Error: m["error"],
JobID: jobID,
Status: m["status"],
Step: m["step"],
Message: m["message"],
Result: m["result"],
Error: m["error"],
ElapsedMs: parseInt64(m["elapsed_ms"]),
TimingsMs: map[string]int64{},
}
for _, step := range []string{"check", "mask", "inpaint", "cleanup", "total"} {
if value := parseInt64(m[step+"_ms"]); value > 0 {
js.TimingsMs[step] = value
}
}
if len(js.TimingsMs) == 0 {
js.TimingsMs = nil
}
return js, nil
}
// StoreJobPayload saves the job's input parameters (floor_id, pattern_code, room_code, input_path).
// StoreJobPayload saves the job's input parameters and render options.
func StoreJobPayload(ctx context.Context, rdb *redis.Client, jobID string, payload map[string]string) error {
data, _ := json.Marshal(payload)
return rdb.HSet(ctx, jobKey(jobID), "payload", string(data)).Err()
@ -120,3 +146,12 @@ func GetJobPayload(ctx context.Context, rdb *redis.Client, jobID string) (map[st
func jobKey(id string) string {
return jobKeyPrefix + id
}
func nowMillis() int64 {
return time.Now().UnixMilli()
}
func parseInt64(raw string) int64 {
value, _ := strconv.ParseInt(raw, 10, 64)
return value
}

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,7 +65,7 @@ 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)
@ -67,17 +74,27 @@ func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Clien
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,55 +103,94 @@ 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+2 combined: identify floor + replace in one call (no separate mask)
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)
refImagePath := downloadReferenceImage(jobCtx, floor.ImageURL, jobID)
imageSize := floorImageSize()
logger.Info("[%s] TIMER generate request image_size=%s input=%s output=%s ref=%s",
jobID, imageSize, workPath, outputPath, refImagePath)
floorPrompt := handler.BuildCombinedFloorPrompt(*floor, *pattern, room)
_, 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
}
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}},
@ -150,6 +206,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 +268,47 @@ 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 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
}
@ -54,12 +55,12 @@ func nf(v float64) interface{} {
type FilterParams struct {
SeriesName, Category, Search, Brand, Material, ColorTone, Finish, WoodSpecies string
Page, Limit int
Page, Limit int
}
type FilterOptions struct {
Categories []string `json:"categories"`
SeriesNames []string `json:"series_names"`
Categories []string `json:"categories"`
SeriesNames []string `json:"series_names"`
Brands []model.BrandInfo `json:"brands"`
}
@ -71,8 +72,12 @@ func QueryAllProducts(d *sql.DB) ([]model.Product, error) {
}
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 +106,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 +127,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 +158,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 +211,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 +284,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()

44
main.go
View File

@ -11,6 +11,8 @@ import (
"strconv"
"time"
"github.com/joho/godotenv"
"floorvisualizer/internal/api"
"floorvisualizer/internal/handler"
"floorvisualizer/internal/logger"
@ -37,6 +39,8 @@ func init() {
}
func main() {
godotenv.Load()
logDir := "logs"
if v := os.Getenv("LOG_DIR"); v != "" {
logDir = v
@ -85,6 +89,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 +124,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 +168,31 @@ 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()
}
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")
}

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