Initial commit: FloorVisualizer backend

- Go API server with PostgreSQL + Redis
- AI floor replacement (OpenRouter Gemini)
- Product database (10 brands, 3539 products)
- Recommendation engine, calculator, articles
- Redis async queue + worker pool
- Hot product caching, brand view tracking
- JWT auth, favorites, projects
- Docker deployment ready

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
dindang 2026-07-16 09:47:11 +08:00
commit 94895cbc22
50 changed files with 7269 additions and 0 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
.git
.claude
__MACOSX
*.exe
*.md
apifox-import.json
sample_images
*.log
/tmp
/outputs
/uploads

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Binary
*.exe
*.test
# Data (too large, imported separately)
data/
brand_logos.json
sample_images/
# User content
uploads/
outputs/
logs/
static/images/
# Article images (large binary files)
knowledge_images/
地板科普照片*/
地板科普文章*/
# IDE
.vscode/
.idea/
# OS
__MACOSX/
.DS_Store
# Claude
.claude/

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
# ── Build stage ──────────────────────────────────────────
FROM golang:1.23-alpine AS builder
WORKDIR /build
RUN apk add --no-cache git ca-certificates tzdata
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-w -s" -o floorvisualizer .
# ── Run stage ────────────────────────────────────────────
FROM alpine:3.20
WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /build/floorvisualizer .
COPY --from=builder /build/data ./data
COPY --from=builder /build/knowledge_images ./knowledge_images
RUN mkdir -p outputs uploads static
EXPOSE 8099
CMD ["./floorvisualizer"]

176
README.md Normal file
View File

@ -0,0 +1,176 @@
# FloorVisualizer
AI 换地板可视化后端服务 —— 上传房间照片AI 自动识别地面区域并生成换地板效果图。
## 技术栈
- **语言**: Go 1.23+
- **数据库**: PostgreSQL 15
- **缓存/队列**: Redis 7
- **AI**: OpenRouter API (Gemini 3 Pro / Flash)
- **部署**: Docker Compose
## 快速开始
### 本地开发
```bash
# 1. 起依赖(仅 DB + Redis
docker compose -f docker-compose.dev.yml up -d
# 2. 导入产品数据
go run cmd/import/main.go
# 3. 启动服务
go run main.go
```
服务默认运行在 `http://localhost:8099`
### 生产部署
```bash
docker compose up -d --build
```
## 项目结构
```
├── main.go # 入口
├── cmd/
│ ├── import/main.go # 产品数据导入
│ ├── import_articles/main.go # 科普文章导入
│ └── scrape_logos/main.py # 品牌 Logo 刮取
├── internal/
│ ├── handler/ # HTTP 处理器
│ │ ├── router.go # 路由注册
│ │ ├── product_handler.go # 产品列表/详情
│ │ ├── floor_handler.go # AI 换地板
│ │ ├── auth_handler.go # 注册/登录
│ │ ├── user_handler.go # 用户/收藏/项目/头像
│ │ ├── recommend_handler.go # 智能推荐
│ │ ├── calc_handler.go # 面积计算
│ │ ├── article_handler.go # 科普文章
│ │ ├── upload_handler.go # 图片上传
│ │ └── health_handler.go # 健康检查
│ ├── model/ # 数据模型
│ ├── repository/ # 数据库操作
│ ├── service/ # 业务逻辑(推荐算法)
│ ├── middleware/ # 中间件JWT、日志、访问记录
│ ├── queue/ # Redis 队列(异步任务)
│ ├── openrouter/ # OpenRouter API 客户端
│ └── logger/ # 分级日志
├── data/products/ # 产品 JSON 数据3539 条)
├── data/articles.json # 科普文章30 篇)
├── knowledge_images/ # 文章图片
├── Dockerfile
├── docker-compose.yml # 生产部署
├── docker-compose.dev.yml # 本地开发
└── apifox-import.json # API 文档
```
## 环境变量
### 服务配置
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `PORT` | `8099` | HTTP 端口 |
| `DATABASE_URL` | `postgres://postgres:dev123456@localhost:5432/floorvisualizer?sslmode=disable` | 数据库连接 |
| `REDIS_ADDR` | `localhost:6379` | Redis 地址 |
| `JWT_SECRET` | 内置默认值 | JWT 签名密钥(生产务必修改) |
| `OPENROUTER_API_KEY` | 内置默认值 | OpenRouter API Key |
| `PROXY_URL` | `127.0.0.1:7897` | HTTP 代理(留空禁用) |
| `DISABLE_PROXY` | — | 设为 `true` 强制禁用代理 |
### 队列
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `WORKER_COUNT` | `5` | AI 生成并发 worker 数1-20 |
### 日志
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `LOG_DIR` | `logs` | 日志目录 |
| `LOG_LEVEL` | `INFO` | 日志级别DEBUG / INFO / WARN / ERROR |
## API 总览
所有接口统一返回 `{"code": 200, "data": {...}}` 信封格式。
### 认证
- `POST /auth/register` — 注册
- `POST /auth/login` — 登录
### 产品(公开)
- `GET /products` — 列表(多维度筛选+搜索+分页)
- `GET /products/{sku}` — 详情(含规格 specs、收藏状态
- `GET /product-options` — 筛选选项(含品牌 logo、款式数、合集数
### 推荐(公开)
- `GET /recommend/api?sku=xxx` — 跨品牌相似推荐
### 地板更换
- `GET /floor/options` — 地板样式+铺设方式+房间类型
- `POST /floor/generate` — 提交 AI 生成任务 → 返回 `job_id`
- `GET /floor/status?job_id=xxx` — 查询任务进度
### 用户(需 JWT
- `GET /user/me` / `PUT /user/me` — 个人信息
- `POST /user/avatar` — 上传头像
- `GET /user/favorites` / `POST` / `DELETE` — 收藏管理
- `GET /user/projects` / `POST` / `PUT` / `DELETE` — 项目(生成记录)
- `GET /user/frequent-brands` — 常用品牌浏览≥3 或 收藏≥2
### 计算器
- `POST /calculator/calc` — 多房间面积+损耗+费用计算
### 文章(公开)
- `GET /articles` — 科普文章列表
- `GET /articles/{id}` — 文章详情
- `GET /articles/recommend` — 随机推荐
### 其他
- `POST /upload` — 通用图片上传
- `GET /healthz` — 健康检查
## 数字编码
### 铺设方式 (pattern_code)
| Code | 木地板 | 瓷砖 |
|------|--------|------|
| 1 | Straight Lay | — |
| 2 | Horizontal Lay | — |
| 3 | Herringbone | — |
| 4 | Chevron | — |
| 5 | — | Straight Grid |
| 6 | — | Running Bond |
| 7 | — | 1/3 Offset |
| 8 | — | Hexagonal |
| 9 | — | Diagonal (45°) |
### 房间类型 (room_code)
| Code | 房间 |
|------|------|
| 0 | Auto Detect |
| 1 | Living Room |
| 2 | Bedroom |
| 3 | Kitchen |
| 4 | Dining Room |
| 5 | Bathroom |
| 6 | Study Room |
| 7 | Hallway |
## 产品数据
10 个品牌3539 条产品,涵盖 Hardwood / Engineered Wood / Laminate / SPC/LVP / Wood-Look Tile 五大品类。
数据存储在 `data/products/*.json`,首次使用需运行 `go run cmd/import/main.go` 导入 PostgreSQL。
## 日志
- **格式**: `2026-07-10 16:30:01 [INFO] main.go:96 Server started`
- **存储**: `logs/app-YYYY-MM-DD.log`,每天一个文件
- **清理**: 自动删除 30 天前的日志

2331
apifox-import.json Normal file

File diff suppressed because it is too large Load Diff

23
cmd/import/main.go Normal file
View File

@ -0,0 +1,23 @@
package main
import (
"encoding/json"; "fmt"; "os"
"floorvisualizer/internal/model"
"floorvisualizer/internal/repository"
)
func main() {
db, _ := repository.Connect("postgres://postgres:dev123456@localhost:5432/floorvisualizer?sslmode=disable")
defer db.Close()
db.Exec("DELETE FROM products")
files, _ := os.ReadDir("data/products")
total := 0
for _, f := range files {
if f.IsDir() { continue }
data, _ := os.ReadFile("data/products/" + f.Name())
var prods []model.Product
json.Unmarshal(data, &prods)
repository.InsertProducts(db, prods)
total += len(prods)
fmt.Printf(" %s: %d\n", f.Name(), len(prods))
}
fmt.Printf("\nDone! %d products\n", total)
}

View File

@ -0,0 +1,42 @@
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"floorvisualizer/internal/model"
"floorvisualizer/internal/repository"
)
func main() {
db, err := repository.Connect("postgres://postgres:dev123456@localhost:5432/floorvisualizer?sslmode=disable")
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer db.Close()
log.Println("Connected")
db.Exec("DELETE FROM articles")
db.Exec("ALTER SEQUENCE articles_id_seq RESTART WITH 1")
log.Println("Truncated")
data, err := os.ReadFile("data/articles.json")
if err != nil {
log.Fatalf("read json: %v", err)
}
var articles []model.Article
if err := json.Unmarshal(data, &articles); err != nil {
log.Fatalf("parse: %v", err)
}
if err := repository.InsertArticles(db, articles); err != nil {
log.Fatalf("insert: %v", err)
}
var count int
db.QueryRow("SELECT COUNT(*) FROM articles").Scan(&count)
fmt.Printf("Done! %d articles imported\n", count)
}

190
cmd/scrape_logos/main.py Normal file
View File

@ -0,0 +1,190 @@
"""
Scrape brand logos from the web.
Tries Clearbit Logo API first, falls back to scraping the homepage.
Outputs brand_logos.json for Go to read.
"""
import json
import os
import sys
import urllib.request
import urllib.error
import ssl
import re
# ── Brand → domain mapping ──────────────────────────────────
BRANDS = {
"Armstrong": "armstrongflooring.com",
"Bruce": "bruce.com",
"COREtec": "coretecfloors.com",
"Daltile": "daltile.com",
"Karndean": "karndean.com",
"LifeProof": "lifeproof.com",
"Mohawk Flooring": "mohawkflooring.com",
"MSI Surfaces": "msisurfaces.com",
"Pergo": "pergo.com",
"Shaw Floors": "shawfloors.com",
}
OUTPUT_DIR = "static/brand_logos"
OUTPUT_JSON = "brand_logos.json"
ssl_ctx = ssl.create_default_context()
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
def fetch_url(url, timeout=10):
"""Fetch URL and return response data."""
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"})
resp = urllib.request.urlopen(req, timeout=timeout, context=ssl_ctx)
return resp.read(), resp.getcode()
except Exception as e:
print(f" [!] Failed: {e}")
return None, 0
def try_clearbit(domain):
"""Try Clearbit Logo API: https://logo.clearbit.com/{domain}"""
url = f"https://logo.clearbit.com/{domain}"
print(f" Trying Clearbit: {url}")
data, code = fetch_url(url)
if data and code == 200:
return data, "png"
return None, None
def try_homepage_favicon(domain):
"""Try to find logo from homepage HTML (og:image or favicon)."""
url = f"https://{domain}"
print(f" Fetching homepage: {url}")
html_bytes, code = fetch_url(url)
if not html_bytes:
return None, None
html = html_bytes.decode("utf-8", errors="ignore")
# Try og:image first
m = re.search(r'<meta[^>]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)', html)
if m:
img_url = m.group(1)
if img_url.startswith("//"):
img_url = "https:" + img_url
elif img_url.startswith("/"):
img_url = url + img_url
print(f" Found og:image: {img_url}")
data, _ = fetch_url(img_url)
if data:
ext = img_url.split(".")[-1].split("?")[0] or "png"
return data, ext
# Try favicon
m = re.search(r'<link[^>]+rel=["\'](?:icon|shortcut icon)["\'][^>]+href=["\']([^"\']+)', html)
if m:
favicon_url = m.group(1)
if favicon_url.startswith("//"):
favicon_url = "https:" + favicon_url
elif favicon_url.startswith("/"):
favicon_url = url + favicon_url
print(f" Found favicon: {favicon_url}")
data, _ = fetch_url(favicon_url)
if data:
return data, "ico"
# Try /favicon.ico
favicon_url = f"https://{domain}/favicon.ico"
print(f" Trying: {favicon_url}")
data, _ = fetch_url(favicon_url)
if data:
return data, "ico"
return None, None
def try_wikipedia(name):
"""Try to get logo from Wikipedia."""
search_name = name.replace(" ", "_")
# Try the Wikipedia REST API
api_url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{search_name}"
print(f" Trying Wikipedia: {api_url}")
data, code = fetch_url(api_url)
if data and code == 200:
try:
info = json.loads(data)
thumb = info.get("thumbnail", {}).get("source")
if thumb:
print(f" Found Wikipedia thumbnail: {thumb}")
img_data, _ = fetch_url(thumb)
if img_data:
return img_data, "png"
except Exception:
pass
# Try infobox image via action=query
query_url = f"https://en.wikipedia.org/w/api.php?action=query&titles={search_name}&prop=pageimages&format=json&pithumbsize=200"
print(f" Trying Wikipedia query: {query_url}")
data, code = fetch_url(query_url)
if data and code == 200:
try:
result = json.loads(data)
pages = result.get("query", {}).get("pages", {})
for page in pages.values():
thumb = page.get("thumbnail", {}).get("source")
if thumb:
print(f" Found: {thumb}")
img_data, _ = fetch_url(thumb)
if img_data:
return img_data, "png"
except Exception:
pass
return None, None
def main():
os.makedirs(OUTPUT_DIR, exist_ok=True)
results = {}
for brand, domain in BRANDS.items():
print(f"\n{'='*60}")
print(f"Brand: {brand} ({domain})")
img_data = None
ext = "png"
# Strategy 1: Clearbit
img_data, ext = try_clearbit(domain)
# Strategy 2: Homepage og:image / favicon
if not img_data:
img_data, ext = try_homepage_favicon(domain)
# Strategy 3: Wikipedia
if not img_data:
img_data, ext = try_wikipedia(brand)
if img_data and len(img_data) > 100:
filename = f"{brand.lower().replace(' ', '_').replace('.', '')}.{ext}"
filepath = os.path.join(OUTPUT_DIR, filename)
with open(filepath, "wb") as f:
f.write(img_data)
local_url = f"/static/brand_logos/{filename}"
print(f" [OK] Saved: {filepath} ({len(img_data)} bytes)")
results[brand] = local_url
else:
print(f" [FAIL] No logo found for {brand}")
results[brand] = ""
# Write JSON mapping
json_path = os.path.join(os.path.dirname(__file__) or ".", "..", "..", OUTPUT_JSON)
# Go binary runs from project root, output to root
root_json_path = OUTPUT_JSON
with open(root_json_path, "w", encoding="utf-8") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
print(f"\n{'='*60}")
print(f"Logo mapping saved to {root_json_path}")
print(f"Logos saved to {OUTPUT_DIR}/")
print(json.dumps(results, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()

34
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,34 @@
# Development compose — starts only DB + Redis, run app locally
services:
db:
image: postgres:15
container_name: floorviz_dev_db
restart: unless-stopped
ports:
- "5432:5432"
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=dev123456
- POSTGRES_DB=floorvisualizer
volumes:
- floor_dev_db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: floorviz_dev_redis
restart: unless-stopped
ports:
- "6380:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
floor_dev_db:

83
docker-compose.yml Normal file
View File

@ -0,0 +1,83 @@
services:
app:
image: floorvisualizer:latest
container_name: floorvisualizer_app
restart: unless-stopped
ports:
- "8099:8099"
volumes:
- floor_outputs:/app/outputs
- floor_outputs:/app/outputs
- floor_logs:
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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
- WORKER_COUNT=5
- LOG_DIR=/app/logs
- LOG_LEVEL=INFO
- PROXY_URL=
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- floorviz_network
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8099/products?limit=1"]
interval: 30s
timeout: 10s
retries: 3
db:
image: postgres:15
container_name: floorvisualizer_db
restart: unless-stopped
ports:
- "8100:5432"
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=floorviz123
- POSTGRES_DB=floorvisualizer
volumes:
- floor_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- floorviz_network
redis:
image: redis:7-alpine
container_name: floorvisualizer_redis
restart: unless-stopped
ports:
- "8101:6379"
volumes:
- floor_redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- floorviz_network
volumes:
floor_db_data:
floor_redis_data:
floor_outputs:
floor_logs:
floor_uploads:
networks:
floorviz_network:
name: floorviz_network
driver: bridge

26
go.mod Normal file
View File

@ -0,0 +1,26 @@
module floorvisualizer
go 1.26.4
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/lib/pq v1.12.3
golang.org/x/image v0.43.0
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
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
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
)

41
go.sum Normal file
View File

@ -0,0 +1,41 @@
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/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=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
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/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
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=
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=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
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/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=
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/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View File

@ -0,0 +1,75 @@
package handler
import (
"database/sql"
"net/http"
"strconv"
"strings"
"floorvisualizer/internal/repository"
)
func handleArticles(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// /articles/{id} — detail
idStr := strings.TrimPrefix(r.URL.Path, "/articles/")
if idStr != "" && idStr != r.URL.Path {
id, err := strconv.Atoi(idStr)
if err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid article id"})
return
}
article, err := repository.GetArticleByID(db, id)
if err != nil {
writeJSON(w, 404, map[string]string{"error": "article not found"})
return
}
writeJSON(w, 200, article)
return
}
// /articles — list
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
articles, total, err := repository.ListArticles(db, page, limit)
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
effectiveLimit := limit
if effectiveLimit <= 0 {
effectiveLimit = 10
}
totalPages := 0
if total > 0 {
totalPages = (total + effectiveLimit - 1) / effectiveLimit
}
writeJSON(w, 200, map[string]interface{}{
"articles": articles,
"total": total,
"page": page,
"limit": effectiveLimit,
"total_pages": totalPages,
})
}
}
func handleArticleRecommend(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
count := 6
if c, err := strconv.Atoi(r.URL.Query().Get("count")); err == nil && c > 0 && c <= 20 {
count = c
}
articles, err := repository.RandomArticles(db, count)
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 200, map[string]interface{}{
"articles": articles,
"total": len(articles),
})
}
}

View File

@ -0,0 +1,49 @@
package handler
import (
"database/sql"
"encoding/json"
"net/http"
"floorvisualizer/internal/service"
)
func handleRegister(authSvc *service.AuthService, db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErr(w, 405, "Method not allowed")
return
}
var input service.RegisterInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid request body"})
return
}
resp, err := authSvc.Register(db, input)
if err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 201, resp)
}
}
func handleLogin(authSvc *service.AuthService, db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErr(w, 405, "Method not allowed")
return
}
var input service.LoginInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid request body"})
return
}
resp, err := authSvc.Login(db, input)
if err != nil {
writeJSON(w, 401, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 200, resp)
}
}

View File

@ -0,0 +1,230 @@
package handler
import (
"encoding/json"
"math"
"net/http"
)
// ── Calculator models ───────────────────────────────────────
type CalcRoomInput struct {
Name string `json:"name"`
Length float64 `json:"length"`
Width float64 `json:"width"`
Deductions []CalcDeduction `json:"deductions"`
}
type CalcDeduction struct {
Name string `json:"name"`
Shape string `json:"shape"` // rectangle, circle, custom
Length float64 `json:"length"`
Width float64 `json:"width"`
Radius float64 `json:"radius"`
Area float64 `json:"area"` // custom area override
}
type CalcInput struct {
FloorType string `json:"floor_type"` // wood, tile
Rooms []CalcRoomInput `json:"rooms"`
PatternIndex int `json:"pattern_index"`
PieceLength float64 `json:"piece_length"`
PieceWidth float64 `json:"piece_width"`
PiecesPerBox int `json:"pieces_per_box"`
PricePerSqm float64 `json:"price_per_sqm"`
JointWidth float64 `json:"joint_width"`
}
type CalcRoomResult struct {
Name string `json:"name"`
Length float64 `json:"length"`
Width float64 `json:"width"`
Area float64 `json:"area"`
Deductions []CalcDeduction `json:"deductions"`
DeductionTotal float64 `json:"deduction_total"`
NetArea float64 `json:"net_area"`
}
type CalcResult struct {
FloorType string `json:"floor_type"`
Rooms []CalcRoomResult `json:"rooms"`
TotalRoomArea float64 `json:"total_room_area"`
TotalDeduct float64 `json:"total_deduct"`
TotalNetArea float64 `json:"total_net_area"`
WasteRate float64 `json:"waste_rate"`
WasteArea float64 `json:"waste_area"`
TotalArea float64 `json:"total_area"`
PiecesNeeded int `json:"pieces_needed"`
BoxesNeeded *float64 `json:"boxes_needed,omitempty"`
JointWidth float64 `json:"joint_width"`
GroutKg *float64 `json:"grout_kg,omitempty"`
GroutBags *int `json:"grout_bags,omitempty"`
PricePerSqm float64 `json:"price_per_sqm"`
TotalPrice *float64 `json:"total_price,omitempty"`
PatternName string `json:"pattern_name"`
PieceLength float64 `json:"piece_length"`
PieceWidth float64 `json:"piece_width"`
}
var woodPatternsCalc = []struct {
Name string
Waste float64
}{
{"Straight Lay", 0.05},
{"Offset Lay", 0.08},
{"Herringbone", 0.12},
{"Diagonal 45deg", 0.15},
}
var tilePatternsCalc = []struct {
Name string
Waste float64
}{
{"Straight Grid", 0.05},
{"Running Bond", 0.07},
{"1/3 Offset", 0.08},
{"Hexagonal", 0.10},
{"Diagonal 45deg", 0.15},
{"Versailles", 0.20},
}
// ── Handler ─────────────────────────────────────────────────
func handleCalc(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErr(w, 405, "Method not allowed")
return
}
var input CalcInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid JSON: " + err.Error()})
return
}
if len(input.Rooms) == 0 {
writeJSON(w, 400, map[string]string{"error": "at least one room required"})
return
}
if input.PieceLength <= 0 || input.PieceWidth <= 0 {
writeJSON(w, 400, map[string]string{"error": "piece dimensions required"})
return
}
result := calculate(input)
writeJSON(w, 200, result)
}
// ── Calculation ─────────────────────────────────────────────
func calculate(input CalcInput) CalcResult {
patterns := woodPatternsCalc
if input.FloorType == "tile" {
patterns = tilePatternsCalc
}
if input.PatternIndex < 0 || input.PatternIndex >= len(patterns) {
input.PatternIndex = 0
}
wasteRate := patterns[input.PatternIndex].Waste
var roomResults []CalcRoomResult
totalRoomArea := 0.0
totalDeduct := 0.0
for _, room := range input.Rooms {
area := room.Length * room.Width
deductTotal := 0.0
var dedResults []CalcDeduction
for _, d := range room.Deductions {
dedArea := deductionArea(d)
deductTotal += dedArea
d.Area = round2(dedArea)
dedResults = append(dedResults, d)
}
netArea := area - deductTotal
if netArea < 0 { netArea = 0 }
roomResults = append(roomResults, CalcRoomResult{
Name: room.Name, Length: room.Length, Width: room.Width,
Area: round2(area), Deductions: dedResults,
DeductionTotal: round2(deductTotal), NetArea: round2(netArea),
})
totalRoomArea += area
totalDeduct += deductTotal
}
totalNetArea := totalRoomArea - totalDeduct
if totalNetArea < 0 { totalNetArea = 0 }
// Adjust piece for joint
effLength := input.PieceLength
effWidth := input.PieceWidth
if input.FloorType == "tile" && input.JointWidth > 0 {
effLength += input.JointWidth
effWidth += input.JointWidth
}
pieceArea := effLength * effWidth
if pieceArea <= 0 { pieceArea = 1 }
totalPieces := totalNetArea / pieceArea
wasteArea := totalNetArea * wasteRate
totalArea := totalNetArea + wasteArea
piecesNeeded := int(math.Ceil(totalArea / pieceArea))
result := CalcResult{
FloorType: input.FloorType,
Rooms: roomResults,
TotalRoomArea: round2(totalRoomArea),
TotalDeduct: round2(totalDeduct),
TotalNetArea: round2(totalNetArea),
WasteRate: round2(wasteRate * 100),
WasteArea: round2(wasteArea),
TotalArea: round2(totalArea),
PiecesNeeded: piecesNeeded,
JointWidth: input.JointWidth,
PricePerSqm: input.PricePerSqm,
PatternName: patterns[input.PatternIndex].Name,
PieceLength: input.PieceLength,
PieceWidth: input.PieceWidth,
}
// Boxes
if input.PiecesPerBox > 0 {
b := math.Ceil(float64(piecesNeeded) / float64(input.PiecesPerBox))
result.BoxesNeeded = &b
}
// Grout (tile only)
if input.FloorType == "tile" && totalPieces > 0 {
kg := totalNetArea * 0.1
result.GroutKg = &kg
bags := int(math.Ceil(kg / 2.0))
result.GroutBags = &bags
}
// Price
if input.PricePerSqm > 0 {
p := totalArea * input.PricePerSqm
result.TotalPrice = &p
}
return result
}
func deductionArea(d CalcDeduction) float64 {
switch d.Shape {
case "circle":
return math.Pi * d.Radius * d.Radius
case "custom":
return d.Area
default: // rectangle
return d.Length * d.Width
}
}
func round2(v float64) float64 {
return math.Round(v*100) / 100
}

View File

@ -0,0 +1,397 @@
package handler
import (
"context"
"database/sql"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"floorvisualizer/internal/openrouter"
"floorvisualizer/internal/repository"
)
// ── 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"`
}
type PatternOption struct {
Code int `json:"code"`
Name string `json:"name"`
Pattern string `json:"pattern"`
Category string `json:"category"`
}
type RoomOption struct {
Code int `json:"code"`
Name string `json:"name"`
}
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"},
}
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"},
}
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"},
}
func findPatternByCode(code int) *PatternOption {
for _, p := range append(woodPatterns, tilePatterns...) {
if p.Code == code {
return &p
}
}
return nil
}
func findRoomByCode(code int) *RoomOption {
if code == 0 {
return nil
}
for _, r := range roomOptions {
if r.Code == code {
return &r
}
}
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,
})
if err != nil {
return nil, err
}
// Dedup by brand+style_name, take first as representative
seen := map[string]bool{}
var out []FloorOption
for _, p := range prods {
key := p.Brand + "|" + p.StyleName
if seen[key] {
continue
}
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 findFloorBySKU(db *sql.DB, sku string) *FloorOption {
p, err := repository.GetProductBySKU(db, sku)
if err != nil {
return nil
}
return &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,
}
}
// ── Public accessors for worker ────────────────────────────
func FindFloorBySKUPublic(db *sql.DB, sku string) *FloorOption { return findFloorBySKU(db, sku) }
func FindPatternByCodePublic(code string) *PatternOption {
c := 0; fmt.Sscanf(code, "%d", &c); return findPatternByCode(c)
}
func FindRoomByCodePublic(code string) *RoomOption {
c := 0; fmt.Sscanf(code, "%d", &c); return findRoomByCode(c)
}
func BuildMaskPromptPublic(room *RoomOption) string { return buildMaskPrompt(room) }
func BuildFloorPromptPublic(f FloorOption, p PatternOption, room *RoomOption) string { return buildFloorPrompt(f, p, room) }
// ── Floor Generate Handler (Redis queue) ──────────────────
func SetQueue(enqueue func(ctx context.Context, jobID string, payload map[string]string) error) {
generateQueue = enqueue
}
var generateQueue func(ctx context.Context, jobID string, payload map[string]string) error
var queryStatus func(ctx context.Context, jobID string) (*JobStatusResp, error)
func SetStatusQuery(fn func(ctx context.Context, jobID string) (*JobStatusResp, error)) {
queryStatus = fn
}
type JobStatusResp struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Step string `json:"step"`
Message string `json:"message"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
func handleFloorGenerate(client *openrouter.Client, db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErr(w, 405, "Method not allowed")
return
}
if err := r.ParseMultipartForm(20 << 20); err != nil {
writeErr(w, 400, err.Error())
return
}
file, _, err := r.FormFile("image")
if err != nil {
writeErr(w, 400, "No image")
return
}
defer file.Close()
floor := findFloorBySKU(db, r.FormValue("floor_id"))
patternCode := 0
fmt.Sscanf(r.FormValue("pattern_code"), "%d", &patternCode)
pattern := findPatternByCode(patternCode)
if floor == nil || pattern == nil {
writeErr(w, 400, "Invalid options")
return
}
os.MkdirAll("uploads", 0755)
jobID := fmt.Sprintf("%d", time.Now().UnixNano())
inputPath := filepath.Join("uploads", fmt.Sprintf("input_%s.png", jobID))
dst, err := os.Create(inputPath)
if err != nil {
writeErr(w, 500, "Failed to save image")
return
}
if _, err := io.Copy(dst, file); err != nil {
dst.Close()
writeErr(w, 500, "Failed to write image")
return
}
dst.Close()
os.MkdirAll("outputs", 0755)
if generateQueue == nil {
writeErr(w, 500, "Queue not initialized")
return
}
// Store job payload then enqueue
payload := map[string]string{
"input_path": inputPath,
"floor_id": r.FormValue("floor_id"),
"pattern_code": r.FormValue("pattern_code"),
"room_code": r.FormValue("room_code"),
}
if err := generateQueue(r.Context(), jobID, payload); err != nil {
writeErr(w, 500, "Failed to enqueue job")
return
}
_ = payload // payload stored inside generateQueue wrapper
writeJSON(w, 200, map[string]string{"job_id": jobID})
}
}
// ── Progress ───────────────────────────────────────────────
// ── Floor Status Query ─────────────────────────────────
func handleFloorStatus(w http.ResponseWriter, r *http.Request) {
jobID := r.URL.Query().Get("job_id")
if jobID == "" {
writeErr(w, 400, "job_id is required")
return
}
if queryStatus == nil {
writeErr(w, 500, "status query not initialized")
return
}
js, err := queryStatus(r.Context(), jobID)
if err != nil {
writeJSON(w, 404, map[string]string{"error": "job not found"})
return
}
writeJSON(w, 200, js)
}
// ── Options ────────────────────────────────────────────────
func handleFloorOptions(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
woodFloors, _ := loadFloorOptions(db, "Hardwood")
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...)
writeJSON(w, 200, map[string]any{
"wood_floors": allWood,
"wood_patterns": woodPatterns,
"tile_floors": allTile,
"tile_patterns": tilePatterns,
"vinyl_floors": vinylFloors,
"rooms": roomOptions,
})
}
}
// ── 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.`
if room != nil && room.Code != 0 {
base += fmt.Sprintf("\nContext: This is a %s.", room.Name)
}
return base
}
func buildFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption) string {
var roomCtx string
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"
}
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)
}
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,
)
}
// ── Content Check ─────────────────────────────────────────
func handleCheckContent(client *openrouter.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErr(w, 405, "Method not allowed")
return
}
if err := r.ParseMultipartForm(20 << 20); err != nil {
writeJSON(w, 400, map[string]string{"error": err.Error()})
return
}
file, _, _ := r.FormFile("image")
defer file.Close()
os.MkdirAll("uploads", 0755)
tmpPath := filepath.Join("uploads", fmt.Sprintf("check_%d.png", time.Now().UnixNano()))
dst, _ := os.Create(tmpPath)
io.Copy(dst, file)
dst.Close()
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
passed, score := validateContent(client, ctx, tmpPath)
msg := fmt.Sprintf("✅ Indoor photo score: %d/10", score)
if !passed {
msg = fmt.Sprintf("Non-indoor photo score: %d/10", score)
}
writeJSON(w, 200, map[string]any{"passed": passed, "score": score, "message": msg})
}
}
func validateContent(client *openrouter.Client, ctx context.Context, imagePath string) (bool, int) {
imgData, err := os.ReadFile(imagePath)
if err != nil {
return false, 0
}
dataURI := "data:image/png;base64," + b64enc(imgData)
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).`},
}}},
})
if err != nil || len(resp.Choices) == 0 {
return true, 0
}
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 {
v := 0
fmt.Sscanf(s, "%d", &v)
return v
}
}
return 0
}
func b64enc(data []byte) string {
const tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
var b strings.Builder
for i := 0; i < len(data); i += 3 {
b0, b1, b2 := data[i], byte(0), byte(0)
if i+1 < len(data) {
b1 = data[i+1]
}
if i+2 < len(data) {
b2 = data[i+2]
}
b.WriteByte(tbl[b0>>2])
b.WriteByte(tbl[((b0&3)<<4)|(b1>>4)])
if i+1 < len(data) {
b.WriteByte(tbl[((b1&15)<<2)|(b2>>6)])
} else {
b.WriteByte('=')
}
if i+2 < len(data) {
b.WriteByte(tbl[b2&63])
} else {
b.WriteByte('=')
}
}
return b.String()
}

View File

@ -0,0 +1,11 @@
package handler
import (
"net/http"
)
func handleHealthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]string{
"status": "ok",
})
}

View File

@ -0,0 +1,196 @@
package handler
import (
"context"
"database/sql"
"net/http"
"strconv"
"strings"
"floorvisualizer/internal/middleware"
"floorvisualizer/internal/model"
"floorvisualizer/internal/repository"
)
type ProductItem struct {
Brand string `json:"brand"`
SeriesName string `json:"series_name"`
StyleName string `json:"style_name"`
SKU string `json:"sku"`
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"`
MainImageURL string `json:"main_image_url"`
SizeLabel string `json:"size_label"`
CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"`
IsFavorited bool `json:"is_favorited"`
SpecCount int `json:"spec_count"`
}
type ProductListResponse struct {
Products []ProductItem `json:"products"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
FilterOptions *repository.FilterOptions `json:"filter_options"`
}
var productsCache func(ctx context.Context, sku string) (*model.Product, bool)
var productsCacheSet func(ctx context.Context, sku string, product *model.Product) error
var productsTrackView func(ctx context.Context, sku string)
func SetProductsCache(
getter func(ctx context.Context, sku string) (*model.Product, bool),
setter func(ctx context.Context, sku string, product *model.Product) error,
tracker func(ctx context.Context, sku string),
) {
productsCache = getter
productsCacheSet = setter
productsTrackView = tracker
}
func handleProducts(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Detail: /products/{sku}
sku := strings.TrimPrefix(r.URL.Path, "/products/")
if sku != "" && sku != r.URL.Path {
// Track view + check cache
if productsTrackView != nil {
productsTrackView(r.Context(), sku)
}
var product *model.Product
if productsCache != nil {
if p, ok := productsCache(r.Context(), sku); ok {
product = p
}
}
if product == nil {
var err error
product, err = repository.GetProductBySKU(db, sku)
if err != nil {
writeJSON(w, 404, map[string]string{"error": "product not found"})
return
}
if productsCacheSet != nil {
productsCacheSet(r.Context(), sku, product)
}
}
// Attach variants (same style_name, different sizes)
if variants, _ := repository.GetVariantsByStyle(db, sku); len(variants) > 0 {
product.Variants = variants
}
// Check favorite status and build specs
userID := middleware.UserID(r.Context())
favMap, _ := repository.GetFavoritedSKUs(db, userID, []string{sku})
product.IsFavorited = favMap[sku]
// Build specs: main product + variants as size options
allVariants := append([]model.Product{*product}, product.Variants...)
for _, v := range allVariants {
product.Specs = append(product.Specs, model.ProductSpec{
SKU: v.SKU, SizeLabel: v.SizeLabel,
WidthIn: v.WidthIn, LengthIn: v.LengthIn, Finish: v.Finish,
PricePerSqft: v.PricePerSqft, PriceTier: v.PriceTier,
CoverageSqftPerBox: v.CoverageSqftPerBox, MainImageURL: v.MainImageURL,
})
}
writeJSON(w, 200, product)
return
}
if r.Method != http.MethodGet {
writeErr(w, 405, "Method not allowed")
return
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
q := r.URL.Query()
params := repository.FilterParams{
SeriesName: q.Get("series_name"),
Category: q.Get("category"),
Material: q.Get("material"),
ColorTone: q.Get("color_tone"),
Finish: q.Get("finish"),
WoodSpecies: q.Get("wood_species"),
Search: q.Get("search"),
Brand: q.Get("brand"),
Page: page,
Limit: limit,
}
prods, total, err := repository.QueryFilteredProducts(db, params)
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
filterOpts, _ := repository.GetFilterOptions(db)
if filterOpts == nil {
filterOpts = &repository.FilterOptions{}
}
// Collect SKUs for batch favorite check
skus := make([]string, len(prods))
for i, p := range prods {
skus[i] = p.SKU
}
userID := middleware.UserID(r.Context())
favSKUs, _ := repository.GetFavoritedSKUs(db, userID, skus)
specCounts := repository.GetSpecCounts(db, skus)
items := make([]ProductItem, 0, len(prods))
for _, p := range prods {
items = append(items, ProductItem{
Brand: p.Brand, SeriesName: p.SeriesName, StyleName: p.StyleName,
SKU: p.SKU, Category: p.Category, Material: p.Material,
ColorTone: p.ColorTone, Finish: p.Finish,
PricePerSqft: p.PricePerSqft, PriceTier: p.PriceTier,
MainImageURL: p.MainImageURL, SizeLabel: p.SizeLabel,
CoverageSqftPerBox: p.CoverageSqftPerBox,
IsFavorited: favSKUs[p.SKU],
SpecCount: specCounts[p.Brand+"|"+p.StyleName+"|"+p.SeriesName],
})
}
effectiveLimit := params.Limit
if effectiveLimit <= 0 {
effectiveLimit = 20
}
totalPages := 0
if total > 0 {
totalPages = (total + effectiveLimit - 1) / effectiveLimit
}
writeJSON(w, 200, ProductListResponse{
Products: items, Total: total, Page: params.Page,
Limit: effectiveLimit, TotalPages: totalPages, FilterOptions: filterOpts,
})
}
}
func handleProductOptions(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
opts, err := repository.GetFilterOptions(db)
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 200, opts)
}
}
func handleFilterOptions(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
filters, err := repository.GetFilterHierarchy(db)
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 200, map[string]interface{}{"filters": filters})
}
}

View File

@ -0,0 +1,79 @@
package handler
import (
"database/sql"
"fmt"
"log"
"net/http"
"floorvisualizer/internal/model"
"floorvisualizer/internal/repository"
"floorvisualizer/internal/service"
)
type RecommendResponse struct {
Source model.Product `json:"source"`
Products []model.Product `json:"products"`
Scores []string `json:"scores"`
Total int `json:"total"`
}
var productService *service.ProductService
func initProductService(db *sql.DB) {
if productService != nil { return }
products, err := repository.QueryAllProducts(db)
if err != nil {
log.Printf("Failed to load products: %v", err)
return
}
log.Printf("Loaded %d products for recommendation engine", len(products))
productService = service.NewProductService(products, model.DefaultEngineConfig())
}
func handleRecommend(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sku := r.URL.Query().Get("sku")
if sku == "" {
writeJSON(w, 400, map[string]string{"error": "sku is required"})
return
}
initProductService(db)
if productService == nil {
writeJSON(w, 500, map[string]string{"error": "Engine not initialized"})
return
}
source, err := repository.GetProductBySKU(db, sku)
if err != nil {
writeJSON(w, 404, map[string]string{"error": "product not found"})
return
}
// Load variants & build specs for price-aware matching
if variants, _ := repository.GetVariantsByStyle(db, sku); len(variants) > 0 {
source.Variants = variants
allVariants := append([]model.Product{*source}, variants...)
for _, v := range allVariants {
source.Specs = append(source.Specs, model.ProductSpec{
SKU: v.SKU, SizeLabel: v.SizeLabel,
WidthIn: v.WidthIn, LengthIn: v.LengthIn, Finish: v.Finish,
PricePerSqft: v.PricePerSqft, PriceTier: v.PriceTier,
CoverageSqftPerBox: v.CoverageSqftPerBox, MainImageURL: v.MainImageURL,
})
}
}
results := productService.FindMatches(*source)
prods := make([]model.Product, len(results))
scores := make([]string, len(results))
for i, r := range results {
prods[i] = r.Product
scores[i] = fmt.Sprintf("%.0f%%", r.Score)
}
writeJSON(w, 200, RecommendResponse{
Source: *source, Products: prods, Scores: scores, Total: len(prods),
})
}
}

View File

@ -0,0 +1,98 @@
package handler
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/url"
"floorvisualizer/internal/middleware"
"floorvisualizer/internal/openrouter"
"floorvisualizer/internal/service"
)
func NewProxyClient(proxyURLStr string) *http.Client {
u, _ := url.Parse(proxyURLStr)
if u == nil {
return http.DefaultClient
}
return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(u)}}
}
func RegisterAll(mux *http.ServeMux, client *openrouter.Client, db *sql.DB, authSvc *service.AuthService) {
requireAuth := middleware.RequireAuth(authSvc)
// Auth
mux.HandleFunc("/auth/register", handleRegister(authSvc, db))
mux.HandleFunc("/auth/login", handleLogin(authSvc, db))
// User (protected)
mux.HandleFunc("/user/me", requireAuth(handleMe(db)))
mux.HandleFunc("/user/avatar", requireAuth(handleUploadAvatar(db)))
mux.HandleFunc("/user/favorites", requireAuth(handleFavorites(db)))
mux.HandleFunc("/user/favorites/", requireAuth(handleFavorites(db)))
mux.HandleFunc("/user/projects", requireAuth(handleProjects(db)))
mux.HandleFunc("/user/projects/", requireAuth(handleProjects(db)))
mux.HandleFunc("/user/frequent-brands", requireAuth(handleFrequentBrands(db)))
// Upload
mux.HandleFunc("/healthz", handleHealthz)
mux.HandleFunc("/upload", handleUpload)
// Products
mux.HandleFunc("/products", handleProducts(db))
mux.HandleFunc("/products/", handleProducts(db))
mux.HandleFunc("/product-options", handleProductOptions(db))
mux.HandleFunc("/filter-options", handleFilterOptions(db))
// Recommend
mux.HandleFunc("/recommend/api", handleRecommend(db))
// Calculator
mux.HandleFunc("/calculator/calc", handleCalc)
// Floor AI
mux.HandleFunc("/floor/generate", handleFloorGenerate(client, db))
mux.HandleFunc("/floor/status", handleFloorStatus)
mux.HandleFunc("/floor/options", handleFloorOptions(db))
// Articles (recommend before /articles/ to avoid path conflict)
mux.HandleFunc("/articles/recommend", handleArticleRecommend(db))
mux.HandleFunc("/articles", handleArticles(db))
mux.HandleFunc("/articles/", handleArticles(db))
// Static
mux.Handle("/outputs/", http.StripPrefix("/outputs/", http.FileServer(http.Dir("./outputs"))))
mux.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir("./uploads"))))
mux.Handle("/knowledge_images/", http.StripPrefix("/knowledge_images/", http.FileServer(http.Dir("./knowledge_images"))))
mux.Handle("/images/knowledge/", http.StripPrefix("/images/knowledge/", http.FileServer(http.Dir("./knowledge_images"))))
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
if status >= 400 {
msg := "Unknown error"
switch x := v.(type) {
case string:
msg = x
case map[string]string:
if e, ok := x["error"]; ok {
msg = e
}
case map[string]any:
if e, ok := x["error"]; ok {
msg = fmt.Sprint(e)
}
}
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{"code": status, "error": msg})
return
}
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{"code": status, "data": v})
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, msg)
}

View File

@ -0,0 +1,69 @@
package handler
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
func handleUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErr(w, 405, "Method not allowed")
return
}
if err := r.ParseMultipartForm(20 << 20); err != nil {
writeErr(w, 400, "image too large (max 20MB)")
return
}
file, _, err := r.FormFile("image")
if err != nil {
writeErr(w, 400, "missing image file")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil || len(data) == 0 {
writeErr(w, 400, "failed to read image")
return
}
ct := http.DetectContentType(data)
if !strings.HasPrefix(ct, "image/") {
writeErr(w, 400, "file is not an image")
return
}
hash := sha256.Sum256(data)
name := hex.EncodeToString(hash[:])[:16] + "_" + fmt.Sprintf("%d", time.Now().UnixNano())
dir := "static/images"
os.MkdirAll(dir, 0755)
// Determine extension from content type
ext := ".jpg"
switch {
case strings.Contains(ct, "png"):
ext = ".png"
case strings.Contains(ct, "webp"):
ext = ".webp"
case strings.Contains(ct, "gif"):
ext = ".gif"
case strings.Contains(ct, "svg"):
ext = ".svg"
}
filePath := filepath.Join(dir, name+ext)
if err := os.WriteFile(filePath, data, 0644); err != nil {
writeErr(w, 500, "failed to save image")
return
}
url := "/static/images/" + name + ext
writeJSON(w, 200, map[string]string{"url": url, "filename": name + ext})
}

View File

@ -0,0 +1,292 @@
package handler
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"floorvisualizer/internal/middleware"
"floorvisualizer/internal/model"
"floorvisualizer/internal/repository"
)
func handleMe(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
switch r.Method {
case http.MethodGet:
user, err := repository.GetUserByID(db, userID)
if err != nil {
writeJSON(w, 404, map[string]string{"error": "user not found"})
return
}
writeJSON(w, 200, user)
case http.MethodPut:
var input struct {
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid request body"})
return
}
user, err := repository.UpdateUser(db, userID, input.Name, input.AvatarURL)
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 200, user)
default:
writeErr(w, 405, "Method not allowed")
}
}
}
func handleFavorites(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
switch r.Method {
case http.MethodGet:
favs, err := repository.ListFavorites(db, userID)
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
if favs == nil {
favs = []model.Favorite{}
}
writeJSON(w, 200, map[string]interface{}{"favorites": favs, "total": len(favs)})
case http.MethodPost:
sku := r.URL.Query().Get("sku")
if sku == "" || strings.Contains(sku, "/") {
writeJSON(w, 400, map[string]string{"error": "sku is required"})
return
}
product, err := repository.GetProductBySKU(db, sku)
if err != nil {
writeJSON(w, 404, map[string]string{"error": "product not found"})
return
}
fav := model.Favorite{
SKU: product.SKU, Brand: product.Brand, SeriesName: product.SeriesName,
StyleName: product.StyleName, MainImageURL: product.MainImageURL,
Category: product.Category, Material: product.Material,
ColorTone: product.ColorTone, PricePerSqft: product.PricePerSqft,
}
if err := repository.AddFavorite(db, userID, fav); err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 201, fav)
case http.MethodDelete:
sku := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/user/favorites/"))
if sku == "" || strings.Contains(sku, "/") {
writeJSON(w, 400, map[string]string{"error": "missing sku"})
return
}
if err := repository.RemoveFavorite(db, userID, sku); err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 200, map[string]string{"ok": "removed"})
default:
writeErr(w, 405, "Method not allowed")
}
}
}
func handleProjects(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
projectID := strings.TrimPrefix(r.URL.Path, "/user/projects/")
if projectID != "" && projectID != r.URL.Path {
handleSingleProject(w, r, db, userID, projectID)
return
}
switch r.Method {
case http.MethodGet:
projects, err := repository.ListProjects(db, userID)
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
if projects == nil {
projects = []model.Project{}
}
writeJSON(w, 200, map[string]interface{}{"projects": projects, "total": len(projects)})
case http.MethodPost:
var input struct {
Name string `json:"name"`
OriginalImageURL string `json:"original_image_url"`
GeneratedImageURL string `json:"generated_image_url"`
FloorStyle string `json:"floor_style"`
Pattern string `json:"pattern"`
RoomType string `json:"room_type"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid request body"})
return
}
if input.Name == "" {
input.Name = "Untitled"
}
project, err := repository.CreateProject(db, userID, input.Name,
input.OriginalImageURL, input.GeneratedImageURL, input.FloorStyle, input.Pattern, input.RoomType)
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 201, project)
default:
writeErr(w, 405, "Method not allowed")
}
}
}
func handleSingleProject(w http.ResponseWriter, r *http.Request, db *sql.DB, userID, projectID string) {
switch r.Method {
case http.MethodGet:
project, err := repository.GetProject(db, projectID, userID)
if err != nil {
writeJSON(w, 404, map[string]string{"error": "project not found"})
return
}
writeJSON(w, 200, project)
case http.MethodPut:
var input struct{ Name string `json:"name"` }
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid request body"})
return
}
project, err := repository.UpdateProject(db, projectID, userID, input.Name)
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 200, project)
case http.MethodDelete:
if err := repository.DeleteProject(db, projectID, userID); err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 200, map[string]string{"ok": "deleted"})
default:
writeErr(w, 405, "Method not allowed")
}
}
func handleUploadAvatar(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
if err := r.ParseMultipartForm(5 << 20); err != nil {
writeJSON(w, 400, map[string]string{"error": "image too large (max 5MB)"})
return
}
file, _, err := r.FormFile("image")
if err != nil {
writeJSON(w, 400, map[string]string{"error": "missing image file"})
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil || len(data) == 0 {
writeJSON(w, 400, map[string]string{"error": "failed to read image"})
return
}
ct := http.DetectContentType(data)
if !strings.HasPrefix(ct, "image/") {
writeJSON(w, 400, map[string]string{"error": "file is not an image"})
return
}
hash := sha256.Sum256(data)
name := hex.EncodeToString(hash[:])[:16] + "_" + fmt.Sprintf("%d", time.Now().UnixNano())
dir := "uploads/avatars"
os.MkdirAll(dir, 0755)
filePath := filepath.Join(dir, name+".jpg")
if err := os.WriteFile(filePath, data, 0644); err != nil {
writeJSON(w, 500, map[string]string{"error": "failed to save image"})
return
}
current, err := repository.GetUserByID(db, userID)
if err != nil || current == nil {
writeJSON(w, 500, map[string]string{"error": "user not found"})
return
}
avatarURL := "/uploads/avatars/" + name + ".jpg"
user, err := repository.UpdateUser(db, userID, current.Name, avatarURL)
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 200, user)
}
}
// ── Brand view tracking ───────────────────────────────────
func handleBrandView(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErr(w, 405, "Method not allowed")
return
}
var body struct {
Brand string `json:"brand"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, 400, map[string]string{"error": "invalid request body"})
return
}
if body.Brand == "" {
writeJSON(w, 400, map[string]string{"error": "brand is required"})
return
}
userID := middleware.UserID(r.Context())
if userID == "" {
writeJSON(w, 401, map[string]string{"error": "login required"})
return
}
if err := repository.RecordBrandView(db, userID, body.Brand); err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
writeJSON(w, 200, map[string]string{"ok": "recorded"})
}
}
func handleFrequentBrands(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
if userID == "" {
writeJSON(w, 401, map[string]string{"error": "login required"})
return
}
brands, err := repository.GetFrequentBrands(db, userID)
if err != nil {
writeJSON(w, 500, map[string]string{"error": err.Error()})
return
}
if brands == nil {
brands = []repository.FrequentBrand{}
}
writeJSON(w, 200, map[string]interface{}{
"brands": brands,
"total": len(brands),
})
}
}

139
internal/logger/logger.go Normal file
View File

@ -0,0 +1,139 @@
package logger
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
type Level int
const (
DEBUG Level = iota
INFO
WARN
ERROR
)
const maxLogAge = 30 * 24 * time.Hour
var levelNames = map[Level]string{DEBUG: "DEBUG", INFO: "INFO", WARN: "WARN", ERROR: "ERROR"}
var (
mu sync.Mutex
std *log.Logger
logDir string
logLevel Level = INFO
logFile *os.File
todayDate string
)
// Init sets up logging with daily rotation to dir/app-YYYY-MM-DD.log.
func Init(dir, level string) {
logDir = dir
os.MkdirAll(dir, 0755)
switch strings.ToUpper(level) {
case "DEBUG":
logLevel = DEBUG
case "WARN":
logLevel = WARN
case "ERROR":
logLevel = ERROR
default:
logLevel = INFO
}
openToday()
go dailyCleanup()
Info("Logger initialized, level=%s, dir=%s", level, dir)
}
func openToday() {
if logFile != nil {
logFile.Close()
}
todayDate = time.Now().Format("2006-01-02")
f, err := os.OpenFile(filepath.Join(logDir, "app-"+todayDate+".log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
log.Printf("Failed to open log file: %v, logging to stdout only", err)
std = log.New(os.Stdout, "", 0)
logFile = nil
return
}
logFile = f
multi := io.MultiWriter(os.Stdout, f)
std = log.New(multi, "", 0)
}
func dailyCleanup() {
for {
time.Sleep(1 * time.Hour)
// Delete old logs
cutoff := time.Now().Add(-maxLogAge)
entries, _ := os.ReadDir(logDir)
for _, e := range entries {
if strings.HasPrefix(e.Name(), "app-") && strings.HasSuffix(e.Name(), ".log") {
dateStr := strings.TrimPrefix(strings.TrimSuffix(e.Name(), ".log"), "app-")
if t, err := time.Parse("2006-01-02", dateStr); err == nil && t.Before(cutoff) {
os.Remove(filepath.Join(logDir, e.Name()))
}
}
}
}
}
func Close() {
if logFile != nil {
logFile.Close()
}
}
func rotateIfNeeded() {
d := time.Now().Format("2006-01-02")
if d != todayDate {
mu.Lock()
if d != todayDate {
openToday()
}
mu.Unlock()
}
}
func output(level Level, format string, args ...any) {
if level < logLevel {
return
}
rotateIfNeeded()
msg := fmt.Sprintf(format, args...)
now := time.Now().Format("2006-01-02 15:04:05.000")
_, file, line, _ := runtime.Caller(2)
short := file[strings.LastIndex(file, "/")+1:]
lineStr := fmt.Sprintf("%s [%s] %s:%d %s", now, levelNames[level], short, line, msg)
mu.Lock()
if std != nil {
std.Println(lineStr)
}
mu.Unlock()
}
func Debug(format string, args ...any) { output(DEBUG, format, args...) }
func Info(format string, args ...any) { output(INFO, format, args...) }
func Warn(format string, args ...any) { output(WARN, format, args...) }
func Error(format string, args ...any) { output(ERROR, format, args...) }
func StdLogger() *log.Logger {
if std != nil {
return std
}
return log.New(os.Stdout, "", 0)
}

View File

@ -0,0 +1,44 @@
package middleware
import (
"context"
"net/http"
"strings"
"floorvisualizer/internal/service"
)
type contextKey string
const userIDKey contextKey = "userID"
func RequireAuth(authSvc *service.AuthService) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
return
}
claims, err := authSvc.ParseToken(parts[1])
if err != nil {
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
next(w, r.WithContext(ctx))
}
}
}
func UserID(ctx context.Context) string {
if v, ok := ctx.Value(userIDKey).(string); ok {
return v
}
return ""
}

View File

@ -0,0 +1,41 @@
package middleware
import (
"net/http"
"time"
"floorvisualizer/internal/logger"
)
type responseWriter struct {
http.ResponseWriter
statusCode int
wroteHeader bool
}
func (rw *responseWriter) WriteHeader(code int) {
if !rw.wroteHeader {
rw.statusCode = code
rw.wroteHeader = true
}
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
if !rw.wroteHeader {
rw.statusCode = http.StatusOK
rw.wroteHeader = true
}
return rw.ResponseWriter.Write(b)
}
// Logging wraps an http.Handler with access logging, like Fiber's requestLogMiddleware.
// Format: METHOD /path 200 1.234ms
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rw, r)
logger.Info("%s %s %d %s", r.Method, r.URL.Path, rw.statusCode, time.Since(start))
})
}

33
internal/model/article.go Normal file
View File

@ -0,0 +1,33 @@
package model
import (
"encoding/json"
"time"
)
type Article struct {
ID int `json:"id"`
Title string `json:"title"`
Summary string `json:"summary"`
Content string `json:"content"`
CoverURL string `json:"cover_url"`
ImageURLs []string `json:"image_urls"`
ImageJSON string `json:"-"` // stored as JSON in DB
CreatedAt time.Time `json:"created_at"`
}
// ScanImageJSON unmarshals image_urls from the JSON column.
func (a *Article) ScanImageJSON() {
if a.ImageJSON != "" {
json.Unmarshal([]byte(a.ImageJSON), &a.ImageURLs)
}
if a.ImageURLs == nil {
a.ImageURLs = []string{}
}
}
// SetImageJSON marshals image_urls for DB storage.
func (a *Article) SetImageJSON() {
data, _ := json.Marshal(a.ImageURLs)
a.ImageJSON = string(data)
}

9
internal/model/brand.go Normal file
View File

@ -0,0 +1,9 @@
package model
// BrandInfo holds enriched brand data returned by /product-options.
type BrandInfo struct {
Name string `json:"name"`
LogoURL string `json:"logo_url"`
StyleCount int `json:"style_count"`
CollectionCount int `json:"collection_count"`
}

35
internal/model/engine.go Normal file
View File

@ -0,0 +1,35 @@
package model
type MatchResult struct {
Product Product `json:"product"`
Score float64 `json:"score"`
Details ScoreDetail `json:"details"`
}
type ScoreDetail struct {
Visual float64 `json:"visual"`
Material float64 `json:"material"`
Price float64 `json:"price"`
Category float64 `json:"category"`
GroupPenalty float64 `json:"group_penalty"`
}
type EngineConfig struct {
MinScore float64
MaxResults int
WeightVisual float64
WeightMaterial float64
WeightPrice float64
WeightCategory float64
}
func DefaultEngineConfig() EngineConfig {
return EngineConfig{
MinScore: 70,
MaxResults: 10,
WeightVisual: 40,
WeightMaterial: 30,
WeightPrice: 20,
WeightCategory: 10,
}
}

View File

@ -0,0 +1,18 @@
package model
import "time"
type Favorite struct {
ID int `json:"id"`
UserID string `json:"user_id"`
SKU string `json:"sku"`
Brand string `json:"brand"`
SeriesName string `json:"series_name"`
StyleName string `json:"style_name"`
MainImageURL string `json:"main_image_url"`
Category string `json:"category"`
Material string `json:"material"`
ColorTone string `json:"color_tone"`
PricePerSqft float64 `json:"price_per_sqft"`
CreatedAt time.Time `json:"created_at"`
}

42
internal/model/product.go Normal file
View File

@ -0,0 +1,42 @@
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"`
}
// ProductSpec is a single size/price variant of a product style.
type ProductSpec struct {
SKU string `json:"sku"`
SizeLabel string `json:"size_label"`
WidthIn float64 `json:"width_in"`
LengthIn float64 `json:"length_in"`
Finish string `json:"finish"`
PricePerSqft float64 `json:"price_per_sqft"`
PriceTier string `json:"price_tier"`
CoverageSqftPerBox float64 `json:"coverage_sqft_per_box"`
MainImageURL string `json:"main_image_url"`
}

16
internal/model/project.go Normal file
View File

@ -0,0 +1,16 @@
package model
import "time"
type Project struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
OriginalImageURL string `json:"original_image_url"`
GeneratedImageURL string `json:"generated_image_url"`
FloorStyle string `json:"floor_style"`
Pattern string `json:"pattern"`
RoomType string `json:"room_type"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

13
internal/model/user.go Normal file
View File

@ -0,0 +1,13 @@
package model
import "time"
type User struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@ -0,0 +1,93 @@
package openrouter
import "context"
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Modalities []string `json:"modalities,omitempty"`
ImageConfig *ImageConfig `json:"image_config,omitempty"`
Provider *Provider `json:"provider,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stream bool `json:"stream,omitempty"`
}
type ImageConfig struct {
AspectRatio string `json:"aspect_ratio,omitempty"`
ImageSize string `json:"image_size,omitempty"`
}
type Provider struct {
Order []string `json:"order,omitempty"`
Only []string `json:"only,omitempty"`
AllowFallbacks *bool `json:"allow_fallbacks,omitempty"`
RequireParameters *bool `json:"require_parameters,omitempty"`
}
type Message struct {
Role string `json:"role"`
Content []ContentPart `json:"content"`
}
type ContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
}
type ImageURL struct {
URL string `json:"url"`
}
type ChatResponse struct {
Choices []Choice `json:"choices"`
}
type Choice struct {
Message AssistantMessage `json:"message"`
}
type AssistantMessage struct {
Content string `json:"content"`
Images []ResponseImage `json:"images,omitempty"`
}
type ResponseImage struct {
Type string `json:"type"`
ImageURL ImageURL `json:"image_url"`
}
// Chat sends a chat completion request to the OpenRouter API.
func (c *Client) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
return c.chatCompletion(ctx, req)
}
func (c *Client) chatCompletion(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
var resp chatResponseRaw
if err := c.do(ctx, "/chat/completions", req, &resp); err != nil {
return nil, err
}
if resp.Error != nil {
return nil, &APIError{Message: resp.Error.Message, Code: resp.Error.Code}
}
return &ChatResponse{Choices: resp.Choices}, nil
}
// APIError API 返回的业务错误
type APIError struct {
Message string
Code any
}
func (e *APIError) Error() string {
return "openrouter: api error: " + e.Message
}
// chatResponseRaw 包含 error 字段,仅用于内部解析
type chatResponseRaw struct {
Choices []Choice `json:"choices"`
Error *struct {
Message string `json:"message"`
Code any `json:"code"`
} `json:"error"`
}

View File

@ -0,0 +1,74 @@
package openrouter
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
const defaultBaseURL = "https://openrouter.ai/api/v1"
type Client struct {
apiKey string
baseURL string
httpClient *http.Client
}
type Option func(*Client)
func WithBaseURL(u string) Option {
return func(c *Client) { c.baseURL = u }
}
func WithHTTPClient(hc *http.Client) Option {
return func(c *Client) { c.httpClient = hc }
}
func NewClient(apiKey string, opts ...Option) *Client {
c := &Client{
apiKey: apiKey,
baseURL: defaultBaseURL,
httpClient: http.DefaultClient,
}
for _, o := range opts {
o(c)
}
return c
}
func (c *Client) do(ctx context.Context, path string, reqBody, respBody any) error {
data, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("openrouter: marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("openrouter: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("openrouter: send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("openrouter: read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("openrouter: HTTP %d: %s", resp.StatusCode, body)
}
if err := json.Unmarshal(body, respBody); err != nil {
return fmt.Errorf("openrouter: unmarshal response: %w\nbody: %s", err, body)
}
return nil
}

View File

@ -0,0 +1,466 @@
package openrouter
import (
"bytes"
"context"
"encoding/base64"
stderrors "errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
"image/png"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
)
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."
var supportedAspectRatios = []struct {
value string
width int
height int
}{
{value: "1:1", width: 1, height: 1},
{value: "2:3", width: 2, height: 3},
{value: "3:2", width: 3, height: 2},
{value: "3:4", width: 3, height: 4},
{value: "4:3", width: 4, height: 3},
{value: "4:5", width: 4, height: 5},
{value: "5:4", width: 5, height: 4},
{value: "9:16", width: 9, height: 16},
{value: "16:9", width: 16, height: 9},
{value: "21:9", width: 21, height: 9},
}
type ImageGenerationRequest struct {
Prompt string
OutputPath string
Model string
Modalities []string
Provider *Provider
InputImagePath string
InputMimeType string
MaskImagePath string
MaskMimeType string
AspectRatio string
ImageSize string
TopP *float64
TargetWidth int
TargetHeight int
MatchSizeImagePath string
}
type ImageGenerationResult struct {
Path string
MimeType string
Model string
TextContent string
Width int
Height int
}
type ImageRecognitionRequest struct {
Prompt string
OutputPath string
Model string
Modalities []string
Provider *Provider
InputImagePath string
InputMimeType string
AspectRatio string
ImageSize string
TopP *float64
TargetWidth int
TargetHeight int
MatchSizeImagePath string
ResizeOutput bool
}
type ImageRecognitionResult = ImageGenerationResult
// RecognizeImage calls an OpenRouter image-capable chat model and saves the
// first returned image as a PNG file while returning the text content for JSON parsing.
func (c *Client) RecognizeImage(ctx context.Context, req ImageRecognitionRequest) (*ImageRecognitionResult, error) {
if strings.TrimSpace(req.Prompt) == "" {
return nil, stderrors.New("openrouter: recognition prompt is required")
}
if strings.TrimSpace(req.InputImagePath) == "" {
return nil, stderrors.New("openrouter: recognition input image path is required")
}
if strings.TrimSpace(req.OutputPath) == "" {
return nil, stderrors.New("openrouter: recognition output path is required")
}
model := req.Model
if model == "" {
model = Gemini3ProImagePreviewModel
}
targetWidth, targetHeight := 0, 0
if req.ResizeOutput || req.AspectRatio == "" {
if req.MatchSizeImagePath == "" {
req.MatchSizeImagePath = req.InputImagePath
}
var err error
targetWidth, targetHeight, err = resolveTargetSize(ImageGenerationRequest{
TargetWidth: req.TargetWidth,
TargetHeight: req.TargetHeight,
MatchSizeImagePath: req.MatchSizeImagePath,
})
if err != nil {
return nil, err
}
}
content, err := buildImageRecognitionContent(req)
if err != nil {
return nil, err
}
chatReq := ChatRequest{
Model: model,
Modalities: imageOutputModalities(model, req.Modalities),
Provider: req.Provider,
TopP: req.TopP,
Messages: []Message{
{
Role: "user",
Content: content,
},
},
}
if req.AspectRatio == "" && targetWidth > 0 && targetHeight > 0 {
req.AspectRatio = aspectRatioString(targetWidth, targetHeight)
}
if req.AspectRatio != "" || req.ImageSize != "" {
chatReq.ImageConfig = &ImageConfig{
AspectRatio: req.AspectRatio,
ImageSize: normalizeImageSize(req.ImageSize),
}
}
resp, err := c.chatCompletion(ctx, chatReq)
if err != nil {
return nil, err
}
outputWidth, outputHeight := 0, 0
if req.ResizeOutput {
outputWidth, outputHeight = targetWidth, targetHeight
}
return saveFirstResponseImage(resp.Choices, req.OutputPath, model, outputWidth, outputHeight)
}
// GenerateImageToFile calls an OpenRouter image-capable chat model and saves the
// first returned image as a PNG file.
func (c *Client) GenerateImageToFile(ctx context.Context, req ImageGenerationRequest) (*ImageGenerationResult, error) {
if strings.TrimSpace(req.Prompt) == "" {
return nil, stderrors.New("openrouter: image prompt is required")
}
if strings.TrimSpace(req.OutputPath) == "" {
return nil, stderrors.New("openrouter: image output path is required")
}
model := req.Model
if model == "" {
model = Gemini3ProImagePreviewModel
}
if req.MatchSizeImagePath == "" {
req.MatchSizeImagePath = req.InputImagePath
}
targetWidth, targetHeight, err := resolveTargetSize(req)
if err != nil {
return nil, err
}
content, err := buildImageGenerationContent(req)
if err != nil {
return nil, err
}
chatReq := ChatRequest{
Model: model,
Modalities: imageOutputModalities(model, req.Modalities),
Provider: req.Provider,
TopP: req.TopP,
Messages: []Message{
{
Role: "user",
Content: content,
},
},
}
if req.AspectRatio == "" && targetWidth > 0 && targetHeight > 0 {
req.AspectRatio = aspectRatioString(targetWidth, targetHeight)
}
if req.AspectRatio != "" || req.ImageSize != "" {
chatReq.ImageConfig = &ImageConfig{
AspectRatio: req.AspectRatio,
ImageSize: normalizeImageSize(req.ImageSize),
}
}
resp, err := c.chatCompletion(ctx, chatReq)
if err != nil {
return nil, err
}
return saveFirstResponseImage(resp.Choices, req.OutputPath, model, targetWidth, targetHeight)
}
func saveFirstResponseImage(choices []Choice, outputPath string, model string, targetWidth, targetHeight int) (*ImageGenerationResult, error) {
outputPath = normalizePNGOutputPath(outputPath)
if len(choices) == 0 {
return nil, stderrors.New("openrouter: image response has no choices")
}
message := choices[0].Message
if len(message.Images) == 0 {
return nil, fmt.Errorf("openrouter: image response has no images: %s", message.Content)
}
_, data, err := decodeDataURL(message.Images[0].ImageURL.URL)
if err != nil {
return nil, err
}
data, width, height, err := encodePNGImageData(data, targetWidth, targetHeight)
if err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
return nil, fmt.Errorf("openrouter: create output directory: %w", err)
}
if err := os.WriteFile(outputPath, data, 0644); err != nil {
return nil, fmt.Errorf("openrouter: write image file: %w", err)
}
return &ImageGenerationResult{
Path: outputPath,
MimeType: "image/png",
Model: model,
TextContent: message.Content,
Width: width,
Height: height,
}, nil
}
func resolveTargetSize(req ImageGenerationRequest) (int, int, error) {
if req.TargetWidth < 0 || req.TargetHeight < 0 {
return 0, 0, stderrors.New("openrouter: target image size must be positive")
}
if req.TargetWidth > 0 || req.TargetHeight > 0 {
if req.TargetWidth == 0 || req.TargetHeight == 0 {
return 0, 0, stderrors.New("openrouter: target width and height must be set together")
}
return req.TargetWidth, req.TargetHeight, nil
}
if req.MatchSizeImagePath == "" {
return 0, 0, nil
}
file, err := os.Open(req.MatchSizeImagePath)
if err != nil {
return 0, 0, fmt.Errorf("openrouter: open match size image: %w", err)
}
defer file.Close()
config, _, err := image.DecodeConfig(file)
if err != nil {
return 0, 0, fmt.Errorf("openrouter: decode match size image config: %w", err)
}
return config.Width, config.Height, nil
}
func buildImageGenerationContent(req ImageGenerationRequest) ([]ContentPart, error) {
if req.MaskImagePath != "" && req.InputImagePath == "" {
return nil, stderrors.New("openrouter: mask image requires input image")
}
content := make([]ContentPart, 0, 4)
if req.InputImagePath != "" {
dataURI, err := imageFileDataURI(req.InputImagePath, req.InputMimeType)
if err != nil {
return nil, err
}
content = append(content, ContentPart{
Type: "image_url",
ImageURL: &ImageURL{URL: dataURI},
})
}
if req.MaskImagePath != "" {
dataURI, err := imageFileDataURI(req.MaskImagePath, req.MaskMimeType)
if err != nil {
return nil, err
}
content = append(content, ContentPart{Type: "text", Text: maskImageInstruction})
content = append(content, ContentPart{
Type: "image_url",
ImageURL: &ImageURL{URL: dataURI},
})
}
content = append(content, ContentPart{Type: "text", Text: req.Prompt})
return content, nil
}
func buildImageRecognitionContent(req ImageRecognitionRequest) ([]ContentPart, error) {
dataURI, err := imageFileDataURI(req.InputImagePath, req.InputMimeType)
if err != nil {
return nil, err
}
return []ContentPart{
{
Type: "image_url",
ImageURL: &ImageURL{URL: dataURI},
},
{Type: "text", Text: req.Prompt},
}, nil
}
func imageFileDataURI(path string, mimeType string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("openrouter: read input image: %w", err)
}
if mimeType == "" {
mimeType = http.DetectContentType(data)
}
return fmt.Sprintf("data:%s;base64,%s", mimeType, base64.StdEncoding.EncodeToString(data)), nil
}
func normalizePNGOutputPath(outputPath string) string {
ext := filepath.Ext(outputPath)
if strings.EqualFold(ext, ".png") {
return strings.TrimSuffix(outputPath, ext) + ".png"
}
if ext == "" {
return outputPath + ".png"
}
return strings.TrimSuffix(outputPath, ext) + ".png"
}
func normalizeImageSize(imageSize string) string {
imageSize = strings.TrimSpace(imageSize)
if strings.HasSuffix(strings.ToLower(imageSize), "k") {
return strings.TrimSuffix(imageSize, imageSize[len(imageSize)-1:]) + "K"
}
return imageSize
}
func imageOutputModalities(model string, override []string) []string {
if len(override) > 0 {
return normalizeModalities(override)
}
if isImageOnlyModel(model) {
return []string{"image"}
}
return []string{"image", "text"}
}
func normalizeModalities(modalities []string) []string {
normalized := make([]string, 0, len(modalities))
seen := make(map[string]struct{}, len(modalities))
for _, modality := range modalities {
modality = strings.ToLower(strings.TrimSpace(modality))
if modality == "" {
continue
}
if _, ok := seen[modality]; ok {
continue
}
seen[modality] = struct{}{}
normalized = append(normalized, modality)
}
return normalized
}
func isImageOnlyModel(model string) bool {
model = strings.ToLower(strings.TrimSpace(model))
imageOnlyPrefixes := []string{
"black-forest-labs/",
"sourceful/",
"bytedance-seed/",
}
for _, prefix := range imageOnlyPrefixes {
if strings.HasPrefix(model, prefix) {
return true
}
}
imageOnlyModels := []string{
"x-ai/grok-imagine-image-quality",
}
for _, candidate := range imageOnlyModels {
if model == candidate {
return true
}
}
return false
}
func encodePNGImageData(data []byte, targetWidth, targetHeight int) ([]byte, int, int, error) {
src, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, 0, 0, fmt.Errorf("openrouter: decode generated image: %w", err)
}
dst := src
width := src.Bounds().Dx()
height := src.Bounds().Dy()
if targetWidth > 0 && targetHeight > 0 {
resized := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
draw.ApproxBiLinear.Scale(resized, resized.Bounds(), src, src.Bounds(), draw.Over, nil)
dst = resized
width = targetWidth
height = targetHeight
}
var buf bytes.Buffer
if err := png.Encode(&buf, dst); err != nil {
return nil, 0, 0, fmt.Errorf("openrouter: encode png image: %w", err)
}
return buf.Bytes(), width, height, nil
}
func aspectRatioString(width, height int) string {
if width <= 0 || height <= 0 {
return ""
}
sourceRatio := math.Log(float64(width) / float64(height))
best := supportedAspectRatios[0]
bestDistance := math.Abs(sourceRatio - math.Log(float64(best.width)/float64(best.height)))
for _, candidate := range supportedAspectRatios[1:] {
distance := math.Abs(sourceRatio - math.Log(float64(candidate.width)/float64(candidate.height)))
if distance < bestDistance {
best = candidate
bestDistance = distance
}
}
return best.value
}
func decodeDataURL(dataURL string) (string, []byte, error) {
header, payload, ok := strings.Cut(dataURL, ",")
if !ok {
return "", nil, stderrors.New("openrouter: invalid image data url")
}
if !strings.HasPrefix(header, "data:") || !strings.Contains(header, ";base64") {
return "", nil, stderrors.New("openrouter: unsupported image data url")
}
mimeType := strings.TrimPrefix(strings.Split(header, ";")[0], "data:")
data, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return "", nil, fmt.Errorf("openrouter: decode image data: %w", err)
}
return mimeType, data, nil
}

122
internal/queue/redis.go Normal file
View File

@ -0,0 +1,122 @@
package queue
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
const (
queueKey = "jobs:queue"
jobKeyPrefix = "jobs:"
jobTTL = 1 * time.Hour
)
// 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"`
}
// NewRedisClient creates a Redis client and pings to verify connectivity.
func NewRedisClient(addr, password string) (*redis.Client, error) {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: 0,
})
if err := rdb.Ping(context.Background()).Err(); err != nil {
return nil, fmt.Errorf("redis connect: %w", err)
}
return rdb, nil
}
// EnqueueJob pushes a job ID onto the queue and sets initial status.
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.Expire(ctx, jobKey(jobID), jobTTL)
_, err := pipe.Exec(ctx)
return err
}
// DequeueJob blocks until a job is available, then pops and returns its ID.
func DequeueJob(ctx context.Context, rdb *redis.Client) (string, error) {
result, err := rdb.BLPop(ctx, 0, queueKey).Result()
if err != nil {
return "", err
}
if len(result) < 2 {
return "", fmt.Errorf("unexpected blpop result: %v", result)
}
return result[1], nil
}
// 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()
}
// 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()
}
// 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()
}
// 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()
}
// GetJobStatus retrieves the full status of a job.
func GetJobStatus(ctx context.Context, rdb *redis.Client, jobID string) (*JobStatus, error) {
m, err := rdb.HGetAll(ctx, jobKey(jobID)).Result()
if err != nil {
return nil, err
}
if len(m) == 0 {
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"],
}
return js, nil
}
// StoreJobPayload saves the job's input parameters (floor_id, pattern_code, room_code, input_path).
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()
}
// GetJobPayload retrieves the stored input parameters.
func GetJobPayload(ctx context.Context, rdb *redis.Client, jobID string) (map[string]string, error) {
s, err := rdb.HGet(ctx, jobKey(jobID), "payload").Result()
if err != nil {
return nil, err
}
var p map[string]string
json.Unmarshal([]byte(s), &p)
return p, nil
}
func jobKey(id string) string {
return jobKeyPrefix + id
}

186
internal/queue/worker.go Normal file
View File

@ -0,0 +1,186 @@
package queue
import (
"context"
"database/sql"
"fmt"
"io"
"floorvisualizer/internal/logger"
"os"
"path/filepath"
"time"
"github.com/redis/go-redis/v9"
"floorvisualizer/internal/handler"
"floorvisualizer/internal/openrouter"
)
// StartWorkers launches n goroutines that consume jobs from the Redis queue.
func StartWorkers(ctx context.Context, n int, rdb *redis.Client, client *openrouter.Client, db *sql.DB) {
for i := 0; i < n; i++ {
go func(workerID int) {
logger.Info("[worker %d] started", workerID)
for {
select {
case <-ctx.Done():
logger.Info("[worker %d] shutting down", workerID)
return
default:
}
jobID, err := DequeueJob(ctx, rdb)
if err != nil {
if ctx.Err() != nil {
return
}
logger.Error("[worker %d] dequeue error: %v", workerID, err)
time.Sleep(time.Second)
continue
}
logger.Info("[worker %d] picked job %s", workerID, jobID)
processJob(ctx, rdb, client, db, jobID)
}
}(i + 1)
}
}
func processJob(ctx context.Context, rdb *redis.Client, client *openrouter.Client, db *sql.DB, jobID string) {
SetJobStatus(ctx, rdb, jobID, "processing")
// Load payload
payload, err := GetJobPayload(ctx, rdb, jobID)
if err != nil {
SetJobError(ctx, rdb, jobID, "Failed to load job payload")
return
}
inputPath := payload["input_path"]
floorID := payload["floor_id"]
patternCode := payload["pattern_code"]
roomCode := payload["room_code"]
// Look up floor and pattern from DB (same logic as before)
floor := handler.FindFloorBySKUPublic(db, floorID)
pattern := handler.FindPatternByCodePublic(patternCode)
room := handler.FindRoomByCodePublic(roomCode)
if floor == nil || pattern == nil {
SetJobError(ctx, rdb, jobID, "Invalid floor or pattern")
return
}
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)
defer cancel()
// Step 0: content check
SetJobProgress(jobCtx, rdb, jobID, "check", "Checking content...")
logger.Info("[%s] Content check", jobID)
if passed, _ := validateContent(client, jobCtx, inputPath); !passed {
SetJobError(jobCtx, rdb, jobID, "Content check failed: Please upload an indoor room photo")
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
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)
floorPrompt := handler.BuildFloorPromptPublic(*floor, *pattern, room)
result, err := client.GenerateImageToFile(jobCtx, openrouter.ImageGenerationRequest{
Prompt: floorPrompt, OutputPath: outputPath, InputImagePath: inputPath,
MaskImagePath: maskPath, ImageSize: "2K",
})
if err != nil {
SetJobError(jobCtx, rdb, jobID, "Floor generation failed: "+err.Error())
return
}
// Clean up intermediate files
os.Remove(inputPath)
os.Remove(maskPath)
_ = result
SetJobResult(jobCtx, rdb, jobID, "/"+filepath.ToSlash(outputPath))
logger.Info("[%s] Done", jobID)
}
// validateContent checks if the image is an indoor room photo.
func validateContent(client *openrouter.Client, ctx context.Context, imagePath string) (bool, int) {
imgData, err := os.ReadFile(imagePath)
if err != nil {
return false, 0
}
dataURI := "data:image/png;base64," + base64enc(imgData)
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).`},
}}},
})
if err != nil || len(resp.Choices) == 0 {
return true, 0
}
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 {
v := 0
fmt.Sscanf(s, "%d", &v)
return v
}
}
return 0
}
func base64enc(data []byte) string {
const tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
var b []byte
for i := 0; i < len(data); i += 3 {
b0, b1, b2 := data[i], byte(0), byte(0)
if i+1 < len(data) {
b1 = data[i+1]
}
if i+2 < len(data) {
b2 = data[i+2]
}
b = append(b, tbl[b0>>2])
b = append(b, tbl[((b0&3)<<4)|(b1>>4)])
if i+1 < len(data) {
b = append(b, tbl[((b1&15)<<2)|(b2>>6)])
} else {
b = append(b, '=')
}
if i+2 < len(data) {
b = append(b, tbl[b2&63])
} else {
b = append(b, '=')
}
}
return string(b)
}
// Make sure io is used (for any future imports)
var _ = io.Discard

View File

@ -0,0 +1,114 @@
package repository
import (
"database/sql"
"encoding/json"
"fmt"
"floorvisualizer/internal/model"
)
func InsertArticles(d *sql.DB, articles []model.Article) error {
for i := range articles {
articles[i].SetImageJSON()
}
const batchSize = 50
for start := 0; start < len(articles); start += batchSize {
end := start + batchSize
if end > len(articles) {
end = len(articles)
}
batch := articles[start:end]
for _, a := range batch {
_, err := d.Exec(`
INSERT INTO articles (title, summary, content, cover_url, image_urls)
VALUES ($1,$2,$3,$4,$5)
`, a.Title, a.Summary, a.Content, a.CoverURL, a.ImageJSON)
if err != nil {
return fmt.Errorf("insert article: %w", err)
}
}
}
return nil
}
func GetArticleByID(d *sql.DB, id int) (*model.Article, error) {
var a model.Article
var imageJSON string
err := d.QueryRow(`
SELECT id, title, summary, content, cover_url, image_urls::text, created_at
FROM articles WHERE id = $1
`, id).Scan(&a.ID, &a.Title, &a.Summary, &a.Content, &a.CoverURL, &imageJSON, &a.CreatedAt)
if err != nil {
return nil, err
}
json.Unmarshal([]byte(imageJSON), &a.ImageURLs)
if a.ImageURLs == nil {
a.ImageURLs = []string{}
}
return &a, nil
}
func ListArticles(d *sql.DB, page, limit int) ([]model.Article, int, error) {
if limit <= 0 {
limit = 10
}
if page <= 0 {
page = 1
}
var total int
if err := d.QueryRow("SELECT COUNT(*) FROM articles").Scan(&total); err != nil {
return nil, 0, err
}
offset := (page - 1) * limit
rows, err := d.Query(`
SELECT id, title, summary, content, cover_url, image_urls::text, created_at
FROM articles ORDER BY id LIMIT $1 OFFSET $2
`, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []model.Article
for rows.Next() {
var a model.Article
var imageJSON string
if err := rows.Scan(&a.ID, &a.Title, &a.Summary, &a.Content, &a.CoverURL, &imageJSON, &a.CreatedAt); err != nil {
return nil, 0, err
}
json.Unmarshal([]byte(imageJSON), &a.ImageURLs)
if a.ImageURLs == nil {
a.ImageURLs = []string{}
}
out = append(out, a)
}
return out, total, rows.Err()
}
func RandomArticles(d *sql.DB, count int) ([]model.Article, error) {
rows, err := d.Query(`
SELECT id, title, summary, content, cover_url, image_urls::text, created_at
FROM articles ORDER BY RANDOM() LIMIT $1
`, count)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Article
for rows.Next() {
var a model.Article
var imageJSON string
if err := rows.Scan(&a.ID, &a.Title, &a.Summary, &a.Content, &a.CoverURL, &imageJSON, &a.CreatedAt); err != nil {
return nil, err
}
json.Unmarshal([]byte(imageJSON), &a.ImageURLs)
if a.ImageURLs == nil {
a.ImageURLs = []string{}
}
out = append(out, a)
}
return out, rows.Err()
}

View File

@ -0,0 +1,70 @@
package repository
import (
"database/sql"
)
// RecordBrandView inserts a brand view record.
func RecordBrandView(d *sql.DB, userID, brand string) error {
_, err := d.Exec(`
INSERT INTO user_brand_views (user_id, brand) VALUES ($1, $2)
`, userID, brand)
return err
}
// FrequentBrand represents a brand the user interacts with frequently.
type FrequentBrand struct {
Brand string `json:"brand"`
ViewCount int `json:"view_count"`
FavCount int `json:"fav_count"`
LogoURL string `json:"logo_url,omitempty"`
StyleCount int `json:"style_count,omitempty"`
}
// GetFrequentBrands returns brands where user has views≥3 or favorites≥2.
func GetFrequentBrands(d *sql.DB, userID string) ([]FrequentBrand, error) {
rows, err := d.Query(`
SELECT brand,
COALESCE(v.cnt, 0) AS view_count,
COALESCE(f.cnt, 0) AS fav_count
FROM (
SELECT brand, COUNT(*) AS cnt
FROM user_brand_views
WHERE user_id = $1
GROUP BY brand
HAVING COUNT(*) >= 3
) v
FULL OUTER JOIN (
SELECT brand, COUNT(*) AS cnt
FROM favorites
WHERE user_id = $1
GROUP BY brand
HAVING COUNT(*) >= 2
) f USING (brand)
ORDER BY COALESCE(v.cnt, 0) + COALESCE(f.cnt, 0) * 2 DESC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []FrequentBrand
for rows.Next() {
var fb FrequentBrand
if err := rows.Scan(&fb.Brand, &fb.ViewCount, &fb.FavCount); err != nil {
continue
}
// Load brand logo and stats
logoMap := loadBrandLogos()
if u, ok := logoMap[fb.Brand]; ok {
fb.LogoURL = u
}
// Get style count from products
var sc int
d.QueryRow(`SELECT COUNT(DISTINCT style_name) FROM products WHERE brand = $1`, fb.Brand).Scan(&sc)
fb.StyleCount = sc
out = append(out, fb)
}
return out, rows.Err()
}

21
internal/repository/db.go Normal file
View File

@ -0,0 +1,21 @@
package repository
import (
"database/sql"
"fmt"
_ "github.com/lib/pq"
)
const DefaultDSN = "postgres://postgres:dev123456@localhost:5432/floorvisualizer?sslmode=disable"
func Connect(dsn string) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping db: %w", err)
}
return db, nil
}

View File

@ -0,0 +1,79 @@
package repository
import (
"database/sql"
"fmt"
"strings"
"floorvisualizer/internal/model"
)
func AddFavorite(d *sql.DB, userID string, f model.Favorite) error {
_, err := d.Exec(`
INSERT INTO favorites (user_id, sku, brand, series_name, style_name,
main_image_url, category, material, color_tone, price_per_sqft)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (user_id, sku) DO NOTHING
`, userID, f.SKU, f.Brand, f.SeriesName, f.StyleName,
f.MainImageURL, f.Category, f.Material, f.ColorTone, f.PricePerSqft)
return err
}
func RemoveFavorite(d *sql.DB, userID, sku string) error {
_, err := d.Exec(`DELETE FROM favorites WHERE user_id = $1 AND sku = $2`, userID, sku)
return err
}
// GetFavoritedSKUs returns a set of SKUs favorited by the user.
// Returns empty set if userID is empty.
func GetFavoritedSKUs(d *sql.DB, userID string, skus []string) (map[string]bool, error) {
result := map[string]bool{}
if userID == "" || len(skus) == 0 {
return result, nil
}
// Build IN clause
placeholders := make([]string, len(skus))
args := make([]interface{}, 0, len(skus)+1)
args = append(args, userID)
for i, sku := range skus {
placeholders[i] = fmt.Sprintf("$%d", i+2)
args = append(args, sku)
}
query := fmt.Sprintf(
"SELECT sku FROM favorites WHERE user_id = $1 AND sku IN (%s)",
strings.Join(placeholders, ","),
)
rows, err := d.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var sku string
rows.Scan(&sku)
result[sku] = true
}
return result, rows.Err()
}
func ListFavorites(d *sql.DB, userID string) ([]model.Favorite, error) {
rows, err := d.Query(`
SELECT id, user_id, sku, brand, series_name, style_name,
main_image_url, category, material, color_tone, price_per_sqft, created_at
FROM favorites WHERE user_id = $1 ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Favorite
for rows.Next() {
var f model.Favorite
if err := rows.Scan(&f.ID, &f.UserID, &f.SKU, &f.Brand, &f.SeriesName, &f.StyleName,
&f.MainImageURL, &f.Category, &f.Material, &f.ColorTone, &f.PricePerSqft, &f.CreatedAt); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}

View File

@ -0,0 +1,86 @@
package repository
import (
"database/sql"
"strings"
)
type FilterGroup struct {
Key string `json:"key"`
Label string `json:"label"`
Options []string `json:"options,omitempty"`
Groups []FilterGroup `json:"groups,omitempty"`
}
func GetFilterHierarchy(d *sql.DB) ([]FilterGroup, error) {
cats, _ := queryDistinct(d, "category")
colors, _ := queryDistinct(d, "color_tone")
finishes, _ := queryDistinct(d, "finish")
species, _ := queryDistinct(d, "wood_species")
materials, _ := queryDistinct(d, "material")
woodGroup := FilterGroup{Label: "Wood"}
porcelainGroup := FilterGroup{Label: "Porcelain / Tile"}
vinylGroup := FilterGroup{Label: "Vinyl / SPC"}
otherGroup := FilterGroup{Label: "Other"}
woodMaterials := []string{"Oak", "Maple", "Hickory", "Walnut", "Ash", "Pine", "Elm", "Teak",
"Mahogany", "Chestnut", "Beech", "Acacia", "Cherry Wood", "Birch"}
vinylMaterials := []string{"Luxury Vinyl", "Vinyl", "Rigid Core", "SPC", "LVT", "PVC"}
for _, m := range materials {
if containsAny(m, woodMaterials) {
woodGroup.Options = append(woodGroup.Options, m)
} else if containsAny(m, vinylMaterials) {
vinylGroup.Options = append(vinylGroup.Options, m)
} else if strings.HasPrefix(m, "Porcelain") {
porcelainGroup.Options = append(porcelainGroup.Options, m)
} else {
otherGroup.Options = append(otherGroup.Options, m)
}
}
materialGroup := FilterGroup{
Key: "material",
Label: "Material",
Groups: filterEmptyGroups([]FilterGroup{woodGroup, porcelainGroup, vinylGroup, otherGroup}),
}
return []FilterGroup{
{Key: "category", Label: "Category", Options: cats},
materialGroup,
{Key: "color_tone", Label: "Color Tone", Options: colors},
{Key: "finish", Label: "Finish", Options: finishes},
{Key: "wood_species", Label: "Wood Species", Options: species},
}, nil
}
func queryDistinct(d *sql.DB, col string) ([]string, error) {
rows, err := d.Query("SELECT DISTINCT " + col + " FROM products WHERE " + col + " != '' ORDER BY " + col)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var v string
rows.Scan(&v)
out = append(out, v)
}
return out, rows.Err()
}
func containsAny(s string, list []string) bool {
for _, item := range list {
if s == item { return true }
}
return false
}
func filterEmptyGroups(groups []FilterGroup) []FilterGroup {
var out []FilterGroup
for _, g := range groups {
if len(g.Options) > 0 { out = append(out, g) }
}
return out
}

View File

@ -0,0 +1,24 @@
package repository
import (
"database/sql"
"fmt"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// GDB is the global GORM instance, initialized after DB connect.
var GDB *gorm.DB
// InitGORM wraps an existing *sql.DB with GORM.
func InitGORM(db *sql.DB) error {
gdb, err := gorm.Open(postgres.New(postgres.Config{Conn: db}), &gorm.Config{
SkipDefaultTransaction: true,
})
if err != nil {
return fmt.Errorf("gorm init: %w", err)
}
GDB = gdb
return nil
}

View File

@ -0,0 +1,272 @@
package repository
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"floorvisualizer/internal/model"
)
const selectCols = `brand, group_name, sku, series_name, style_name,
category, material, color_tone, finish,
price_per_sqft, price_tier, price_source,
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,
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)
`, 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.MainImageURL, p.RoomImageURL, p.SourceURL, p.Description, p.Status,
p.CoverageSqftPerBox, p.WoodSpecies,
p.SizeLabel, nf(p.WidthIn), nf(p.LengthIn))
if err != nil {
return fmt.Errorf("insert %s: %w", p.SKU, err)
}
}
return nil
}
func nf(v float64) interface{} {
if v == 0 { return nil }
return v
}
// ── Query types ─────────────────────────────────────────────
type FilterParams struct {
SeriesName, Category, Search, Brand, Material, ColorTone, Finish, WoodSpecies string
Page, Limit int
}
type FilterOptions struct {
Categories []string `json:"categories"`
SeriesNames []string `json:"series_names"`
Brands []model.BrandInfo `json:"brands"`
}
// ── Queries ─────────────────────────────────────────────────
func QueryAllProducts(d *sql.DB) ([]model.Product, error) {
prods, _, err := QueryFilteredProducts(d, FilterParams{Limit: 10000})
return prods, err
}
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 }
var conds []string
var args []interface{}
i := 1
add := func(clause string, val string) {
if val != "" {
conds = append(conds, fmt.Sprintf(clause, i))
args = append(args, val)
i++
}
}
add("category = $%d", p.Category)
add("series_name = $%d", p.SeriesName)
add("brand = $%d", p.Brand)
add("material = $%d", p.Material)
add("color_tone = $%d", p.ColorTone)
add("finish = $%d", p.Finish)
add("wood_species = $%d", p.WoodSpecies)
if p.Search != "" {
pat := "%" + p.Search + "%"
clauses := []string{
fmt.Sprintf("style_name ILIKE $%d", i),
fmt.Sprintf("sku ILIKE $%d", i+1),
fmt.Sprintf("brand ILIKE $%d", i+2),
}
conds = append(conds, "("+strings.Join(clauses, " OR ")+")")
for k := 0; k < 3; k++ { args = append(args, pat) }
i += 3
}
where := ""
if len(conds) > 0 { where = "WHERE " + strings.Join(conds, " AND ") }
// Count distinct styles (dedup same brand+style_name across different sizes)
var total int
d.QueryRow("SELECT COUNT(*) FROM (SELECT DISTINCT brand, style_name FROM products "+where+") AS t", args...).Scan(&total)
offset := (p.Page - 1) * p.Limit
// Use DISTINCT ON to keep one row per brand+style_name, pick the first by id
query := fmt.Sprintf("SELECT DISTINCT ON (brand, style_name) "+selectCols+" FROM products %s ORDER BY brand, style_name, id LIMIT $%d OFFSET $%d", where, i, i+1)
args = append(args, p.Limit, offset)
rows, err := d.Query(query, args...)
if err != nil { return nil, 0, err }
defer rows.Close()
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 {
return nil, err
}
return &p, nil
}
// GetVariantsByStyle returns all products with the same brand+style_name but different SKU.
func GetVariantsByStyle(d *sql.DB, sku string) ([]model.Product, error) {
rows, err := d.Query(`
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 }
defer rows.Close()
return scanProducts(rows)
}
func VariantCount(d *sql.DB, sku string) int {
var n int
d.QueryRow(`SELECT COUNT(*) FROM products WHERE style_name = (SELECT style_name FROM products WHERE sku = $1) AND brand = (SELECT brand FROM products WHERE sku = $1) AND sku != $1`, sku).Scan(&n)
return n
}
// GetSpecCounts returns (brand|style_name|series_name) → total variant count for a batch of SKUs.
func GetSpecCounts(d *sql.DB, skus []string) map[string]int {
result := map[string]int{}
if len(skus) == 0 {
return result
}
placeholders := make([]string, len(skus))
args := make([]interface{}, len(skus))
for i, sku := range skus {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = sku
}
query := fmt.Sprintf(`
SELECT p.brand, p.style_name, p.series_name, COUNT(*) AS total
FROM products p
WHERE (p.brand, p.style_name, p.series_name) IN (
SELECT brand, style_name, series_name FROM products WHERE sku IN (%s)
)
GROUP BY p.brand, p.style_name, p.series_name
`, strings.Join(placeholders, ","))
rows, err := d.Query(query, args...)
if err != nil {
return result
}
defer rows.Close()
for rows.Next() {
var brand, style, series string
var total int
if err := rows.Scan(&brand, &style, &series, &total); err == nil {
result[brand+"|"+style+"|"+series] = total
}
}
return result
}
func GetFilterOptions(d *sql.DB) (*FilterOptions, error) {
opts := &FilterOptions{}
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) }
rows.Close()
}
opts.Brands = GetBrandInfos(d)
return opts, nil
}
// ── Brand info with stats ───────────────────────────────────
var (
brandLogos map[string]string
brandLogosOnce sync.Once
)
func loadBrandLogos() map[string]string {
brandLogosOnce.Do(func() {
brandLogos = map[string]string{}
data, err := os.ReadFile("brand_logos.json")
if err != nil {
return
}
json.Unmarshal(data, &brandLogos)
})
return brandLogos
}
// GetBrandInfos returns all brands with style count, category count, and logo URL.
func GetBrandInfos(d *sql.DB) []model.BrandInfo {
logos := loadBrandLogos()
rows, err := d.Query(`
SELECT brand,
COUNT(DISTINCT style_name) AS style_count,
COUNT(DISTINCT SPLIT_PART(series_name, ' | ', 1)) AS collection_count
FROM products
WHERE brand != ''
GROUP BY brand
ORDER BY brand
`)
if err != nil {
return nil
}
defer rows.Close()
var out []model.BrandInfo
for rows.Next() {
var bi model.BrandInfo
if err := rows.Scan(&bi.Name, &bi.StyleCount, &bi.CollectionCount); err != nil {
continue
}
if u, ok := logos[bi.Name]; ok {
bi.LogoURL = u
}
out = append(out, bi)
}
return out
}
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.MainImageURL, &p.RoomImageURL, &p.SourceURL, &p.Description, &p.Status,
&p.CoverageSqftPerBox, &p.WoodSpecies,
&p.SizeLabel, &p.WidthIn, &p.LengthIn,
); err != nil { return nil, err }
out = append(out, p)
}
return out, rows.Err()
}

View File

@ -0,0 +1,79 @@
package repository
import (
"database/sql"
"fmt"
"floorvisualizer/internal/model"
)
func CreateProject(d *sql.DB, userID, name, originalURL, generatedURL, floorStyle, pattern, roomType string) (*model.Project, error) {
var p model.Project
err := d.QueryRow(`
INSERT INTO projects (user_id, name, original_image_url, generated_image_url, floor_style, pattern, room_type)
VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING id, user_id, name, original_image_url, generated_image_url, floor_style, pattern, room_type, created_at, updated_at
`, userID, name, originalURL, generatedURL, floorStyle, pattern, roomType).Scan(
&p.ID, &p.UserID, &p.Name, &p.OriginalImageURL, &p.GeneratedImageURL,
&p.FloorStyle, &p.Pattern, &p.RoomType, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("create project: %w", err)
}
return &p, nil
}
func UpdateProject(d *sql.DB, id, userID, name string) (*model.Project, error) {
var p model.Project
err := d.QueryRow(`
UPDATE projects SET name=$1, updated_at=now() WHERE id=$2 AND user_id=$3
RETURNING id, user_id, name, original_image_url, generated_image_url, floor_style, pattern, room_type, created_at, updated_at
`, name, id, userID).Scan(
&p.ID, &p.UserID, &p.Name, &p.OriginalImageURL, &p.GeneratedImageURL,
&p.FloorStyle, &p.Pattern, &p.RoomType, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("update project: %w", err)
}
return &p, nil
}
func DeleteProject(d *sql.DB, id, userID string) error {
_, err := d.Exec(`DELETE FROM projects WHERE id=$1 AND user_id=$2`, id, userID)
return err
}
func ListProjects(d *sql.DB, userID string) ([]model.Project, error) {
rows, err := d.Query(`
SELECT id, user_id, name, original_image_url, generated_image_url,
floor_style, pattern, room_type, created_at, updated_at
FROM projects WHERE user_id=$1 ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Project
for rows.Next() {
var p model.Project
if err := rows.Scan(&p.ID, &p.UserID, &p.Name, &p.OriginalImageURL, &p.GeneratedImageURL,
&p.FloorStyle, &p.Pattern, &p.RoomType, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func GetProject(d *sql.DB, id, userID string) (*model.Project, error) {
var p model.Project
err := d.QueryRow(`
SELECT id, user_id, name, original_image_url, generated_image_url,
floor_style, pattern, room_type, created_at, updated_at
FROM projects WHERE id=$1 AND user_id=$2
`, id, userID).Scan(
&p.ID, &p.UserID, &p.Name, &p.OriginalImageURL, &p.GeneratedImageURL,
&p.FloorStyle, &p.Pattern, &p.RoomType, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
return nil, err
}
return &p, nil
}

View File

@ -0,0 +1,57 @@
package repository
import (
"database/sql"
"fmt"
"floorvisualizer/internal/model"
)
func CreateUser(d *sql.DB, username, passwordHash, name string) (*model.User, error) {
var u model.User
err := d.QueryRow(`
INSERT INTO users (username, password_hash, name) VALUES ($1,$2,$3)
RETURNING id, username, name, avatar_url, created_at, updated_at
`, username, passwordHash, name).Scan(&u.ID, &u.Username, &u.Name, &u.AvatarURL, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
return &u, nil
}
func GetUserByUsername(d *sql.DB, username string) (*model.User, error) {
var u model.User
err := d.QueryRow(`
SELECT id, username, password_hash, name, avatar_url, created_at, updated_at
FROM users WHERE username = $1
`, username).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
return nil, err
}
return &u, nil
}
func GetUserByID(d *sql.DB, id string) (*model.User, error) {
var u model.User
err := d.QueryRow(`
SELECT id, username, password_hash, name, avatar_url, created_at, updated_at
FROM users WHERE id = $1
`, id).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
return nil, err
}
return &u, nil
}
func UpdateUser(d *sql.DB, id, name, avatarURL string) (*model.User, error) {
var u model.User
err := d.QueryRow(`
UPDATE users SET name=$1, avatar_url=$2, updated_at=now() WHERE id=$3
RETURNING id, username, name, avatar_url, created_at, updated_at
`, name, avatarURL, id).Scan(&u.ID, &u.Username, &u.Name, &u.AvatarURL, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
return &u, nil
}

View File

@ -0,0 +1,120 @@
package service
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"floorvisualizer/internal/model"
"floorvisualizer/internal/repository"
)
type AuthService struct {
secret []byte
ttl time.Duration
}
func NewAuthService(secret string) *AuthService {
if secret == "" {
b := make([]byte, 32)
rand.Read(b)
secret = hex.EncodeToString(b)
}
return &AuthService{secret: []byte(secret), ttl: 72 * time.Hour}
}
func HashPassword(password string) string {
h := sha256.Sum256([]byte(password))
return hex.EncodeToString(h[:])
}
type Claims struct {
UserID string `json:"uid"`
Username string `json:"uname"`
jwt.RegisteredClaims
}
func (s *AuthService) GenerateToken(user *model.User) (string, error) {
claims := Claims{
UserID: user.ID,
Username: user.Username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "floorvisualizer",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.secret)
}
func (s *AuthService) ParseToken(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{},
func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return s.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
type RegisterInput struct {
Username string `json:"username"`
Password string `json:"password"`
Name string `json:"name"`
}
type LoginInput struct {
Username string `json:"username"`
Password string `json:"password"`
}
type AuthResponse struct {
Token string `json:"token"`
User model.User `json:"user"`
}
func (s *AuthService) Register(db *sql.DB, input RegisterInput) (*AuthResponse, error) {
if strings.TrimSpace(input.Username) == "" || strings.TrimSpace(input.Password) == "" {
return nil, errors.New("username and password are required")
}
user, err := repository.CreateUser(db, input.Username, HashPassword(input.Password), input.Name)
if err != nil {
return nil, fmt.Errorf("register: %w", err)
}
token, err := s.GenerateToken(user)
if err != nil {
return nil, err
}
return &AuthResponse{Token: token, User: *user}, nil
}
func (s *AuthService) Login(db *sql.DB, input LoginInput) (*AuthResponse, error) {
user, err := repository.GetUserByUsername(db, input.Username)
if err != nil {
return nil, errors.New("invalid username or password")
}
if HashPassword(input.Password) != user.PasswordHash {
return nil, errors.New("invalid username or password")
}
token, err := s.GenerateToken(user)
if err != nil {
return nil, err
}
return &AuthResponse{Token: token, User: *user}, nil
}

View File

@ -0,0 +1,230 @@
package service
import (
"math"
"sort"
"strings"
"floorvisualizer/internal/model"
)
type ProductService struct {
products []model.Product
config model.EngineConfig
}
func NewProductService(products []model.Product, config model.EngineConfig) *ProductService {
if config.MinScore <= 0 {
config.MinScore = 70
}
if config.MaxResults <= 0 {
config.MaxResults = 10
}
return &ProductService{products: products, config: config}
}
func (s *ProductService) AllProducts() []model.Product { return s.products }
func (s *ProductService) FindMatches(source model.Product) []model.MatchResult {
candidates := make([]model.Product, 0)
for _, p := range s.products {
if p.SKU == source.SKU && p.Brand == source.Brand {
continue
}
if p.Brand == source.Brand && p.StyleName == source.StyleName {
continue
}
if p.Category != source.Category {
continue
}
candidates = append(candidates, p)
}
// Score all candidates
allResults := make([]model.MatchResult, 0, len(candidates))
for _, c := range candidates {
score, detail := s.score(source, c)
allResults = append(allResults, model.MatchResult{Product: c, Score: score, Details: detail})
}
sort.Slice(allResults, func(i, j int) bool { return allResults[i].Score > allResults[j].Score })
// Prefer above MinScore, fill remaining with best below
above := make([]model.MatchResult, 0)
below := make([]model.MatchResult, 0)
for _, r := range allResults {
if r.Score >= s.config.MinScore {
above = append(above, r)
} else {
below = append(below, r)
}
}
// Always return at least 5 (or as many as available), filling gaps with best below-70
combined := above
need := s.config.MaxResults - len(combined)
for i := 0; i < need && i < len(below); i++ {
combined = append(combined, below[i])
}
if len(combined) == 0 {
return nil
}
const maxPerBrand = 3
brandCount := map[string]int{}
diverse := make([]model.MatchResult, 0, s.config.MaxResults)
for _, r := range combined {
if brandCount[r.Product.Brand] < maxPerBrand {
brandCount[r.Product.Brand]++
diverse = append(diverse, r)
}
if len(diverse) >= s.config.MaxResults {
break
}
}
return diverse
}
func (s *ProductService) score(source, candidate model.Product) (float64, model.ScoreDetail) {
w := s.config
colorS := scoreColor(source.ColorTone, candidate.ColorTone)
finishS := scoreFinish(source.Finish, candidate.Finish)
visualScore := clamp((colorS*0.7 + finishS*0.3) * 100)
materialScore := 0.0
if normalizeMaterial(source.Material) == normalizeMaterial(candidate.Material) {
materialScore = 100
} else if sameMaterialFamily(source.Material, candidate.Material) {
materialScore = 50
}
priceScore := scorePrice(source, candidate)
categoryScore := 100.0
hasPrice := source.PricePerSqft > 0 && candidate.PricePerSqft > 0 &&
source.PricePerSqft != 4.99 && candidate.PricePerSqft != 4.99 &&
source.PriceSource != "pending_review" && candidate.PriceSource != "pending_review"
weightV, weightM, weightP, weightC := w.WeightVisual, w.WeightMaterial, w.WeightPrice, w.WeightCategory
if !hasPrice {
redist := weightP / 3
weightV += redist * 2
weightM += redist
weightP = 0
}
total := (visualScore*weightV + materialScore*weightM + priceScore*weightP + categoryScore*weightC) / 100.0
groupPenalty := 0.0
if source.GroupName != "" && candidate.GroupName != "" && source.GroupName == candidate.GroupName {
if priceTierGap(source.PriceTier, candidate.PriceTier) >= 2 {
groupPenalty = -10
} else {
groupPenalty = -20
}
total += groupPenalty
}
detail := model.ScoreDetail{
Visual: round(visualScore),
Material: round(materialScore),
Price: round(priceScore),
Category: round(categoryScore),
GroupPenalty: groupPenalty,
}
return round(total), detail
}
// ── Scoring helpers ─────────────────────────────────────────
func scoreColor(source, candidate string) float64 {
if source == "" || candidate == "" { return 0.5 }
if source == candidate { return 1.0 }
groups := [][]string{
{"Light Gray", "Off White", "Taupe"},
{"Dark Brown", "Black Walnut", "Taupe"},
{"Natural Oak", "Warm Honey", "Taupe"},
{"Cherry", "Dark Brown"},
{"Green", "Blue"},
}
for _, g := range groups {
if contains(g, source) && contains(g, candidate) { return 0.6 }
}
return 0.1
}
func scoreFinish(source, candidate string) float64 {
if source == "" || candidate == "" { return 0.5 }
if source == candidate { return 1.0 }
if (source == "Matte" && candidate == "Textured") || (source == "Textured" && candidate == "Matte") {
return 0.6
}
return 0.2
}
func scorePrice(source model.Product, candidate model.Product) float64 {
sp, cp := source.PricePerSqft, candidate.PricePerSqft
ss, cs := source.PriceSource, candidate.PriceSource
if ss == "pending_review" || cs == "pending_review" || ss == "estimated" || cs == "estimated" { return 50 }
if cp <= 0 { return 50 }
// If source has specs with different prices, use the closest-matching one
if len(source.Specs) > 1 {
bestDist := math.Abs(sp - cp)
for _, s := range source.Specs {
if s.PricePerSqft > 0 {
d := math.Abs(s.PricePerSqft - cp)
if d < bestDist {
bestDist = d
sp = s.PricePerSqft
}
}
}
}
if sp <= 0 { return 50 }
maxP := math.Max(sp, cp)
if maxP == 0 { return 50 }
return clamp(100 * (1 - math.Abs(sp-cp)/maxP))
}
func normalizeMaterial(m string) string {
for _, prefix := range []string{"Porcelain"} {
if len(m) >= len(prefix) && m[:len(prefix)] == prefix { return prefix }
}
lower := strings.ToLower(m)
if strings.Contains(lower, "rigid core") { return "Rigid Core" }
if strings.Contains(lower, "luxury vinyl") || strings.Contains(lower, "vinyl") { return "LVT" }
if strings.Contains(lower, "laminate") { return "Laminate" }
return m
}
func sameMaterialFamily(a, b string) bool {
aN, bN := normalizeMaterial(a), normalizeMaterial(b)
woods := []string{"Oak", "Maple", "Hickory", "Walnut", "Cherry Wood", "Birch", "Pine", "Ash", "Acacia", "Elm", "Teak", "Bamboo", "Mahogany", "Chestnut", "Rosewood", "Ebony"}
stones := []string{"Porcelain", "Stone Texture", "Marble", "Travertine", "Slate", "Cement"}
vinyls := []string{"SPC", "LVT", "PVC", "Rigid Core", "Luxury Vinyl", "Vinyl"}
if contains(woods, aN) && contains(woods, bN) { return true }
if contains(stones, aN) && contains(stones, bN) { return true }
if contains(vinyls, aN) && contains(vinyls, bN) { return true }
return false
}
func priceTierGap(a, b string) int {
rank := map[string]int{"": 0, "Budget/Value": 1, "Mid-Range": 2, "Upper Mid-Range": 3, "Premium": 4}
ra, rb := rank[a], rank[b]
diff := ra - rb
if diff < 0 { diff = -diff }
return diff
}
func round(v float64) float64 { return math.Round(v*10) / 10 }
func clamp(v float64) float64 { return math.Max(0, math.Min(100, v)) }
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item { return true }
}
return false
}

175
main.go Normal file
View File

@ -0,0 +1,175 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"time"
"encoding/json"
"math/rand"
"floorvisualizer/internal/handler"
"floorvisualizer/internal/logger"
"floorvisualizer/internal/model"
"floorvisualizer/internal/middleware"
"floorvisualizer/internal/openrouter"
"floorvisualizer/internal/queue"
"floorvisualizer/internal/repository"
"floorvisualizer/internal/service"
)
const defaultAPIKey = "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"
const defaultJWTSecret = "floorviz-dev-secret-change-in-production"
var proxyURL = "http://127.0.0.1:7897"
func init() {
if v := os.Getenv("PROXY_URL"); v != "" {
proxyURL = v
}
if v := os.Getenv("DISABLE_PROXY"); v == "true" || v == "1" {
proxyURL = ""
}
}
func main() {
logDir := "logs"
if v := os.Getenv("LOG_DIR"); v != "" {
logDir = v
}
logLevel := "INFO"
if v := os.Getenv("LOG_LEVEL"); v != "" {
logLevel = v
}
logger.Init(logDir, logLevel)
defer logger.Close()
dsn := repository.DefaultDSN
if v := os.Getenv("DATABASE_URL"); v != "" {
dsn = v
}
db, err := repository.Connect(dsn)
if err != nil {
logger.Warn("Database not available: %v", err)
} else {
defer db.Close()
logger.Info("Database connected (tables managed via schema.sql)")
if err := repository.InitGORM(db); err != nil {
logger.Warn("GORM init failed, falling back to raw SQL: %v", err)
} else {
logger.Info("GORM initialized")
}
}
jwtSecret := defaultJWTSecret
if s := os.Getenv("JWT_SECRET"); s != "" {
jwtSecret = s
}
authSvc := service.NewAuthService(jwtSecret)
apiKey := defaultAPIKey
if k := os.Getenv("OPENROUTER_API_KEY"); k != "" {
apiKey = k
}
client := openrouter.NewClient(apiKey)
if proxyURL != "" {
client = openrouter.NewClient(apiKey, openrouter.WithHTTPClient(handler.NewProxyClient(proxyURL)))
}
// ── Redis ──────────────────────────────────────────────
redisAddr := "localhost:6379"
if v := os.Getenv("REDIS_ADDR"); v != "" {
redisAddr = v
}
rdb, err := queue.NewRedisClient(redisAddr, "")
if err != nil {
logger.Warn("Redis not available (%v), worker queue disabled", err)
} else {
defer rdb.Close()
logger.Info("Redis connected")
// Wire up the queue: generate handler enqueues to Redis
handler.SetQueue(func(ctx context.Context, jobID string, payload map[string]string) error {
if err := queue.StoreJobPayload(ctx, rdb, jobID, payload); err != nil {
return err
}
return queue.EnqueueJob(ctx, rdb, jobID)
})
// Wire up status query
handler.SetStatusQuery(func(ctx context.Context, jobID string) (*handler.JobStatusResp, error) {
js, err := queue.GetJobStatus(ctx, rdb, jobID)
if err != nil {
return nil, err
}
return &handler.JobStatusResp{
JobID: js.JobID, Status: js.Status, Step: js.Step,
Message: js.Message, Result: js.Result, Error: js.Error,
}, nil
})
// Start worker pool (default 5, override with WORKER_COUNT env)
workerCount := 5
if v := os.Getenv("WORKER_COUNT"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 20 {
workerCount = n
}
}
workerCtx, cancelWorkers := context.WithCancel(context.Background())
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 ────────────────────────────────
handler.SetProductsCache(
// Getter: check Redis cache for hot products
func(ctx context.Context, sku string) (*model.Product, bool) {
val, err := rdb.Get(ctx, "product:"+sku).Result()
if err != nil {
return nil, false
}
var p model.Product
if json.Unmarshal([]byte(val), &p) == nil {
return &p, true
}
return nil, false
},
// Setter: only cache if this SKU is in the hot list (top 200)
func(ctx context.Context, sku string, product *model.Product) error {
rank, err := rdb.ZRevRank(ctx, "product:views", sku).Result()
if err != nil || rank >= 200 {
return nil // not hot enough, skip caching
}
data, _ := json.Marshal(product)
ttl := 1*time.Hour + time.Duration(rand.Intn(600))*time.Second // ±10min jitter
return rdb.Set(ctx, "product:"+sku, data, ttl).Err()
},
// View tracker: increment view count in sorted set
func(ctx context.Context, sku string) {
rdb.ZIncrBy(ctx, "product:views", 1, sku)
},
)
mux := http.NewServeMux()
handler.RegisterAll(mux, client, db, authSvc)
port := "8099"
if p := os.Getenv("PORT"); p != "" {
port = p
}
logger.Info("FloorVisualizer -> http://localhost:%s", port)
log.Fatal(http.ListenAndServe(":"+port, middleware.Logging(mux)))
}

97
schema.sql Normal file
View File

@ -0,0 +1,97 @@
-- FloorVisualizer Database Schema
-- Generated from PostgreSQL
-- Users
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Products
CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY,
brand TEXT DEFAULT '',
group_name TEXT DEFAULT '',
sku TEXT DEFAULT '',
series_name TEXT DEFAULT '',
style_name TEXT DEFAULT '',
category TEXT DEFAULT '',
material TEXT DEFAULT '',
color_tone TEXT DEFAULT '',
finish TEXT DEFAULT '',
price_per_sqft NUMERIC(10,2) DEFAULT 0,
price_tier TEXT DEFAULT '',
price_source TEXT DEFAULT '',
main_image_url TEXT DEFAULT '',
room_image_url TEXT DEFAULT '',
source_url TEXT DEFAULT '',
description TEXT DEFAULT '',
status TEXT DEFAULT '',
coverage_sqft_per_box NUMERIC(10,2) DEFAULT 0,
wood_species TEXT DEFAULT '',
size_label TEXT DEFAULT '',
width_in NUMERIC(10,2),
length_in NUMERIC(10,2)
);
CREATE INDEX IF NOT EXISTS idx_p_sku ON products(sku);
CREATE INDEX IF NOT EXISTS idx_p_cat ON products(category);
CREATE INDEX IF NOT EXISTS idx_p_brand ON products(brand);
CREATE INDEX IF NOT EXISTS idx_p_style ON products(style_name);
-- Articles
CREATE TABLE IF NOT EXISTS articles (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
cover_url TEXT NOT NULL DEFAULT '',
image_urls JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT now()
);
-- Favorites
CREATE TABLE IF NOT EXISTS favorites (
id SERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
sku TEXT NOT NULL,
brand TEXT DEFAULT '',
series_name TEXT DEFAULT '',
style_name TEXT DEFAULT '',
main_image_url TEXT DEFAULT '',
category TEXT DEFAULT '',
material TEXT DEFAULT '',
color_tone TEXT DEFAULT '',
price_per_sqft NUMERIC(10,2) DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(user_id, sku)
);
CREATE INDEX IF NOT EXISTS idx_fav_user ON favorites(user_id);
-- Projects
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT 'Untitled',
original_image_url TEXT DEFAULT '',
generated_image_url TEXT DEFAULT '',
floor_style TEXT DEFAULT '',
pattern TEXT DEFAULT '',
room_type TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Brand Views
CREATE TABLE IF NOT EXISTS user_brand_views (
id SERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
brand TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_ubv_user ON user_brand_views(user_id, brand);
CREATE INDEX IF NOT EXISTS idx_ubv_time ON user_brand_views(user_id, created_at);