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:
commit
94895cbc22
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@ -0,0 +1,11 @@
|
||||
.git
|
||||
.claude
|
||||
__MACOSX
|
||||
*.exe
|
||||
*.md
|
||||
apifox-import.json
|
||||
sample_images
|
||||
*.log
|
||||
/tmp
|
||||
/outputs
|
||||
/uploads
|
||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal 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
31
Dockerfile
Normal 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
176
README.md
Normal 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
2331
apifox-import.json
Normal file
File diff suppressed because it is too large
Load Diff
23
cmd/import/main.go
Normal file
23
cmd/import/main.go
Normal 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)
|
||||
}
|
||||
42
cmd/import_articles/main.go
Normal file
42
cmd/import_articles/main.go
Normal 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
190
cmd/scrape_logos/main.py
Normal 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
34
docker-compose.dev.yml
Normal 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
83
docker-compose.yml
Normal 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
26
go.mod
Normal 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
41
go.sum
Normal 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=
|
||||
75
internal/handler/article_handler.go
Normal file
75
internal/handler/article_handler.go
Normal 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
49
internal/handler/auth_handler.go
Normal file
49
internal/handler/auth_handler.go
Normal 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)
|
||||
}
|
||||
}
|
||||
230
internal/handler/calc_handler.go
Normal file
230
internal/handler/calc_handler.go
Normal 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
|
||||
}
|
||||
397
internal/handler/floor_handler.go
Normal file
397
internal/handler/floor_handler.go
Normal 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()
|
||||
}
|
||||
11
internal/handler/health_handler.go
Normal file
11
internal/handler/health_handler.go
Normal 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",
|
||||
})
|
||||
}
|
||||
196
internal/handler/product_handler.go
Normal file
196
internal/handler/product_handler.go
Normal 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})
|
||||
}
|
||||
}
|
||||
79
internal/handler/recommend_handler.go
Normal file
79
internal/handler/recommend_handler.go
Normal 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
98
internal/handler/router.go
Normal file
98
internal/handler/router.go
Normal 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)
|
||||
}
|
||||
69
internal/handler/upload_handler.go
Normal file
69
internal/handler/upload_handler.go
Normal 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})
|
||||
}
|
||||
292
internal/handler/user_handler.go
Normal file
292
internal/handler/user_handler.go
Normal 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
139
internal/logger/logger.go
Normal 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)
|
||||
}
|
||||
44
internal/middleware/auth.go
Normal file
44
internal/middleware/auth.go
Normal 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 ""
|
||||
}
|
||||
41
internal/middleware/logger.go
Normal file
41
internal/middleware/logger.go
Normal 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
33
internal/model/article.go
Normal 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
9
internal/model/brand.go
Normal 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
35
internal/model/engine.go
Normal 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,
|
||||
}
|
||||
}
|
||||
18
internal/model/favorite.go
Normal file
18
internal/model/favorite.go
Normal 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
42
internal/model/product.go
Normal 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
16
internal/model/project.go
Normal 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
13
internal/model/user.go
Normal 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"`
|
||||
}
|
||||
93
internal/openrouter/chat.go
Normal file
93
internal/openrouter/chat.go
Normal 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"`
|
||||
}
|
||||
74
internal/openrouter/client.go
Normal file
74
internal/openrouter/client.go
Normal 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
|
||||
}
|
||||
466
internal/openrouter/image.go
Normal file
466
internal/openrouter/image.go
Normal 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
122
internal/queue/redis.go
Normal 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
186
internal/queue/worker.go
Normal 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
|
||||
114
internal/repository/article_repo.go
Normal file
114
internal/repository/article_repo.go
Normal 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()
|
||||
}
|
||||
70
internal/repository/brand_view_repo.go
Normal file
70
internal/repository/brand_view_repo.go
Normal 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
21
internal/repository/db.go
Normal 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
|
||||
}
|
||||
79
internal/repository/favorite_repo.go
Normal file
79
internal/repository/favorite_repo.go
Normal 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()
|
||||
}
|
||||
86
internal/repository/filter_repo.go
Normal file
86
internal/repository/filter_repo.go
Normal 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
|
||||
}
|
||||
24
internal/repository/gorm.go
Normal file
24
internal/repository/gorm.go
Normal 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
|
||||
}
|
||||
272
internal/repository/product_repo.go
Normal file
272
internal/repository/product_repo.go
Normal 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()
|
||||
}
|
||||
79
internal/repository/project_repo.go
Normal file
79
internal/repository/project_repo.go
Normal 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
|
||||
}
|
||||
57
internal/repository/user_repo.go
Normal file
57
internal/repository/user_repo.go
Normal 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
|
||||
}
|
||||
120
internal/service/auth_service.go
Normal file
120
internal/service/auth_service.go
Normal 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
|
||||
}
|
||||
230
internal/service/product_service.go
Normal file
230
internal/service/product_service.go
Normal 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
175
main.go
Normal 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
97
schema.sql
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user