commit 94895cbc224763ff7b86a6fb8fab5fa5c03d6b8f Author: dindang Date: Thu Jul 16 09:47:11 2026 +0800 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..05f1e5a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.claude +__MACOSX +*.exe +*.md +apifox-import.json +sample_images +*.log +/tmp +/outputs +/uploads diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2762182 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c086082 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..87f0e8e --- /dev/null +++ b/README.md @@ -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 天前的日志 diff --git a/apifox-import.json b/apifox-import.json new file mode 100644 index 0000000..ed8fb28 --- /dev/null +++ b/apifox-import.json @@ -0,0 +1,2331 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "FloorVisualizer API", + "description": "FloorVisualizer 后端接口文档\n\n## 全局响应格式\n所有 API 统一返回 JSON 信封格式:\n```\n成功: {\"code\": 200, \"data\": {...}}\n失败: {\"code\": 400, \"error\": \"错误描述\"}\n```\n\n认证方式:注册/登录获取 JWT token,在需要认证的接口中设置 Header: Authorization: Bearer {token}\n\n---\n## 产品筛选层层递进说明\n产品列表支持 category / material / color_tone / finish / wood_species / brand / series_name 多维度组合筛选。\n前端实现层层递进:用户每选一个筛选项,将已选参数传给 /products 获取结果,再根据返回的 filter_options 动态更新其他筛选项的可选范围。\n\n---\n## AI换地板对接说明\n1. GET /floor/options 获取可用的地板样式(从产品库动态加载,每个样式对应真实SKU)\n2. POST /floor/generate 上传图片 + floor_id(产品SKU)+ pattern_code(1-9)+ room_code(0-7)→ 返回 job_id\n3. GET /floor/progress?job_id=xxx(SSE)跟踪进度 → check → mask → inpaint → done\n4. 生成完成后保存到用户项目:POST /user/projects\n\n---\n## 数字编码对照\n\n### 铺设方式 (pattern_code)\n| code | 木地板 | 瓷砖 |\n|------|--------|------|\n| 1 | Straight Lay | — |\n| 2 | Horizontal Lay | — |\n| 3 | Herringbone | — |\n| 4 | Chevron | — |\n| 5 | — | Straight Grid |\n| 6 | — | Running Bond |\n| 7 | — | 1/3 Offset |\n| 8 | — | Hexagonal |\n| 9 | — | Diagonal (45°) |\n\n### 房间类型 (room_code)\n| code | 房间 |\n|------|------|\n| 0 | Auto Detect(不传房间信息给AI) |\n| 1 | Living Room |\n| 2 | Bedroom |\n| 3 | Kitchen |\n| 4 | Dining Room |\n| 5 | Bathroom |\n| 6 | Study Room |\n| 7 | Hallway |\n\n---\n## 静态文件路径\n- 上传图片: /static/images/\n- 知识图片: /knowledge_images/ 或 /images/knowledge/\n- 生成结果: /outputs/\n- 用户上传: /uploads/", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost:8099", + "description": "本地开发环境" + } + ], + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "注册或登录后获取的 JWT token" + } + }, + "schemas": { + "AuthResponse": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "JWT token,后续请求放 Authorization header" + }, + "user": { + "$ref": "#/components/schemas/User" + } + }, + "example": { + "username": "demo", + "password": "123456", + "name": "Demo User" + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "用户唯一ID (UUID)" + }, + "username": { + "type": "string", + "description": "用户名" + }, + "name": { + "type": "string", + "description": "显示名称" + }, + "avatar_url": { + "type": "string", + "description": "头像URL" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "注册时间" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "更新时间" + } + } + }, + "Favorite": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "收藏记录ID" + }, + "user_id": { + "type": "string", + "description": "用户ID" + }, + "sku": { + "type": "string", + "description": "产品SKU编码" + }, + "brand": { + "type": "string", + "description": "品牌" + }, + "series_name": { + "type": "string", + "description": "系列名称" + }, + "style_name": { + "type": "string", + "description": "花色/款式名称" + }, + "main_image_url": { + "type": "string", + "description": "产品主图URL" + }, + "category": { + "type": "string", + "description": "分类" + }, + "material": { + "type": "string", + "description": "材质" + }, + "color_tone": { + "type": "string", + "description": "色调" + }, + "price_per_sqft": { + "type": "number", + "description": "每平方英尺价格" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "收藏时间" + } + } + }, + "Project": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "项目唯一ID (UUID)" + }, + "user_id": { + "type": "string", + "description": "用户ID" + }, + "name": { + "type": "string", + "description": "项目名称" + }, + "original_image_url": { + "type": "string", + "description": "换地板前的原图URL" + }, + "generated_image_url": { + "type": "string", + "description": "AI生成后的效果图URL" + }, + "floor_style": { + "type": "string", + "description": "使用的款式(如 Light Oak)" + }, + "pattern": { + "type": "string", + "description": "铺设方式(如 Herringbone, Straight Lay)" + }, + "room_type": { + "type": "string", + "description": "房间类型(如 Living Room)" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "创建时间" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "更新时间" + } + } + }, + "ProductItem": { + "type": "object", + "properties": { + "brand": { + "type": "string", + "description": "品牌" + }, + "series_name": { + "type": "string", + "description": "系列名称" + }, + "style_name": { + "type": "string", + "description": "花色/款式名称" + }, + "sku": { + "type": "string", + "description": "产品SKU编码" + }, + "category": { + "type": "string", + "description": "分类(Hardwood / Laminate / SPC/LVP / Engineered Wood / Wood-Look Tile)" + }, + "material": { + "type": "string", + "description": "材质(如 Oak, Porcelain, Walnut)" + }, + "color_tone": { + "type": "string", + "description": "色调(如 Light Gray, Natural Oak, Dark Brown)" + }, + "finish": { + "type": "string", + "description": "表面处理(如 Matte, Gloss, Textured)" + }, + "price_per_sqft": { + "type": "number", + "description": "每平方英尺价格(美元)" + }, + "price_tier": { + "type": "string", + "description": "价格段位(Budget/Value, Mid-Range, Upper Mid-Range, Premium)" + }, + "main_image_url": { + "type": "string", + "description": "产品主图URL" + }, + "size_label": { + "type": "string", + "description": "Size label" + }, + "coverage_sqft_per_box": { + "type": "number", + "description": "Coverage per box (sqft)" + }, + "is_favorited": { + "type": "boolean", + "description": "当前用户是否已收藏。未登录时始终为 false" + }, + "spec_count": { + "type": "integer", + "description": "该花色的规格数量(同品牌同花色的SKU数,含自身)。>1 表示有多种尺寸可选" + } + } + }, + "ProductListResponse": { + "type": "object", + "properties": { + "products": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProductItem" + }, + "description": "产品列表" + }, + "total": { + "type": "integer", + "description": "总数" + }, + "page": { + "type": "integer", + "description": "当前页码" + }, + "limit": { + "type": "integer", + "description": "每页数量" + }, + "total_pages": { + "type": "integer", + "description": "总页数" + }, + "filter_options": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "所有分类" + }, + "series_names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "所有系列" + }, + "brands": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BrandInfo" + }, + "description": "所有品牌(含logo、款式数、类型数)" + } + } + } + } + }, + "RecommendResponse": { + "type": "object", + "properties": { + "source": { + "": "#/components/schemas/ProductDetail", + "description": "源产品信息" + }, + "products": { + "type": "array", + "items": { + "": "#/components/schemas/ProductItem" + }, + "description": "推荐产品列表" + }, + "scores": { + "type": "array", + "items": { + "type": "string" + }, + "description": "匹配度(如 90%)" + }, + "total": { + "type": "integer", + "description": "推荐总数" + } + } + }, + "ProductDetail": { + "type": "object", + "properties": { + "brand": { + "type": "string" + }, + "group_name": { + "type": "string" + }, + "sku": { + "type": "string" + }, + "series_name": { + "type": "string" + }, + "style_name": { + "type": "string" + }, + "category": { + "type": "string" + }, + "material": { + "type": "string" + }, + "color_tone": { + "type": "string" + }, + "finish": { + "type": "string" + }, + "price_per_sqft": { + "type": "number" + }, + "price_tier": { + "type": "string" + }, + "price_source": { + "type": "string" + }, + "main_image_url": { + "type": "string" + }, + "room_image_url": { + "type": "string" + }, + "source_url": { + "type": "string" + }, + "description": { + "type": "string" + }, + "status": { + "type": "string" + }, + "coverage_sqft_per_box": { + "type": "number", + "description": "Coverage per box (sqft)" + }, + "wood_species": { + "type": "string", + "description": "Wood species" + }, + "size_label": { + "type": "string", + "description": "Size label (e.g. 6 x 48 in)" + }, + "width_in": { + "type": "number", + "description": "Width (inches)" + }, + "length_in": { + "type": "number", + "description": "Length (inches)" + }, + "variants": { + "type": "array", + "description": "同款不同尺寸的完整产品数据。仅当同品牌同花色的其他SKU存在时返回(旧格式,建议使用 specs)", + "items": { + "$ref": "#/components/schemas/ProductVariant" + } + }, + "specs": { + "type": "array", + "description": "规格列表:该花色的所有尺寸+价格。包含主产品自身(至少1条)。多规格时前端可展示尺寸选择器", + "items": { + "$ref": "#/components/schemas/ProductSpec" + } + }, + "is_favorited": { + "type": "boolean", + "description": "当前用户是否已收藏。未登录时始终为 false" + } + } + }, + "ProductSpec": { + "type": "object", + "description": "单个规格(尺寸+价格)", + "properties": { + "sku": { + "type": "string", + "description": "该规格对应的SKU" + }, + "size_label": { + "type": "string", + "description": "尺寸标签,如 6 x 48 in" + }, + "width_in": { + "type": "number", + "description": "宽度(英寸)" + }, + "length_in": { + "type": "number", + "description": "长度(英寸)" + }, + "price_per_sqft": { + "type": "number", + "description": "每平方英尺价格(美元)" + }, + "price_tier": { + "type": "string", + "description": "价格段位" + }, + "coverage_sqft_per_box": { + "type": "number", + "description": "每箱覆盖面积(平方英尺)" + }, + "main_image_url": { + "type": "string", + "description": "该规格产品图片URL" + } + } + }, + "ProductVariant": { + "type": "object", + "description": "Product variant (same style, different size/price)", + "properties": { + "sku": { + "type": "string" + }, + "size_label": { + "type": "string" + }, + "width_in": { + "type": "number" + }, + "length_in": { + "type": "number" + }, + "price_per_sqft": { + "type": "number" + }, + "price_tier": { + "type": "string" + }, + "series_name": { + "type": "string" + }, + "main_image_url": { + "type": "string" + } + } + }, + "FilterHierarchy": { + "type": "object", + "properties": { + "filters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilterGroup" + } + } + } + }, + "FilterGroup": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Filter parameter name (used with /products)" + }, + "label": { + "type": "string", + "description": "Display label" + }, + "options": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Available values (flat structure)" + }, + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FilterGroup" + }, + "description": "Sub-groups (hierarchical, e.g. Material > Wood/Porcelain/Vinyl)" + } + } + }, + "BrandInfo": { + "type": "object", + "description": "品牌信息(含统计和logo)", + "properties": { + "name": { + "type": "string", + "description": "品牌名称" + }, + "logo_url": { + "type": "string", + "description": "品牌 logo 图片 URL(存在 static/brand_logos/ 下),可能为空" + }, + "style_count": { + "type": "integer", + "description": "该品牌下的款式数量(distinct style_name)" + }, + "collection_count": { + "type": "integer", + "description": "该品牌下的产品合集数量(distinct category)" + } + }, + "example": { + "name": "Shaw Floors", + "logo_url": "/static/brand_logos/shaw_floors.webp", + "style_count": 1797, + "collection_count": 4 + } + }, + "Article": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "content": { + "type": "string", + "description": "Full article text" + }, + "cover_url": { + "type": "string", + "description": "Cover image URL" + }, + "image_urls": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Article images" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "ArticleListResponse": { + "type": "object", + "properties": { + "articles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Article" + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + }, + "ArticleRecommendResponse": { + "type": "object", + "properties": { + "articles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Article" + } + }, + "total": { + "type": "integer" + } + } + }, + "FloorStyleOption": { + "type": "object", + "description": "Floor style loaded from product database", + "properties": { + "id": { + "type": "string", + "description": "Product SKU(用于 floor_id 参数)" + }, + "sku": { + "type": "string", + "description": "Product SKU(同 id,兼容旧版)" + }, + "name": { + "type": "string", + "description": "Style name" + }, + "material": { + "type": "string", + "description": "Material (used in AI prompt)" + }, + "color_tone": { + "type": "string", + "description": "Color tone (used in AI prompt)" + }, + "finish": { + "type": "string", + "description": "Finish (used in AI prompt)" + }, + "size_label": { + "type": "string", + "description": "Size label, e.g. 6 x 48 in" + }, + "image_url": { + "type": "string", + "description": "Preview image URL" + }, + "category": { + "type": "string", + "description": "Product category" + } + } + }, + "FloorOptionsResponse": { + "type": "object", + "properties": { + "wood_floors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FloorStyleOption" + }, + "description": "Hardwood + Engineered Wood products" + }, + "tile_floors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FloorStyleOption" + }, + "description": "Wood-Look Tile + Laminate products" + }, + "vinyl_floors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FloorStyleOption" + }, + "description": "SPC/LVP products" + }, + "wood_patterns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PatternOption" + } + }, + "tile_patterns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PatternOption" + } + }, + "rooms": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RoomOption" + } + } + } + }, + "PatternOption": { + "type": "object", + "description": "铺设方式选项。code 1-4 为木地板,5-9 为瓷砖", + "properties": { + "code": { + "type": "integer", + "description": "铺设方式编码(1-9),见文档顶部数字编码对照表" + }, + "name": { + "type": "string", + "description": "铺设方式名称" + }, + "pattern": { + "type": "string", + "description": "AI提示词用的铺设描述" + }, + "category": { + "type": "string", + "description": "所属类型:wood 或 tile" + } + }, + "example": { + "code": 3, + "name": "Herringbone", + "pattern": "herringbone/zigzag pattern with rectangular planks arranged in a broken V shape", + "category": "wood" + } + }, + "RoomOption": { + "type": "object", + "description": "房间类型选项。code 0 为自动检测", + "properties": { + "code": { + "type": "integer", + "description": "房间编码(0-7),见文档顶部数字编码对照表" + }, + "name": { + "type": "string", + "description": "房间名称" + } + }, + "example": { + "code": 1, + "name": "Living Room" + } + }, + "CalcInput": { + "type": "object", + "required": [ + "floor_type", + "rooms", + "piece_length", + "piece_width" + ], + "properties": { + "floor_type": { + "type": "string", + "enum": [ + "wood", + "tile" + ], + "description": "地板类型" + }, + "rooms": { + "type": "array", + "description": "房间列表,至少一个", + "items": { + "type": "object", + "required": [ + "length", + "width" + ], + "properties": { + "name": { + "type": "string", + "description": "房间名称,如 Living Room" + }, + "length": { + "type": "number", + "description": "长(米),如 5" + }, + "width": { + "type": "number", + "description": "宽(米),如 4" + }, + "deductions": { + "type": "array", + "description": "扣减区域,如柜子、壁炉等不铺地板的区域", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "区域名称,如 Fireplace / Kitchen Island" + }, + "shape": { + "type": "string", + "enum": [ + "rectangle", + "circle", + "custom" + ], + "description": "形状类型:rectangle=矩形(填length+width) / circle=圆形(填radius直径) / custom=自定义(填area)" + }, + "length": { + "type": "number", + "description": "长(米),仅 rectangle 矩形时填写" + }, + "width": { + "type": "number", + "description": "宽(米),仅 rectangle 矩形时填写" + }, + "radius": { + "type": "number", + "description": "半径(米),仅 circle 圆形时填写,直径=radius*2" + }, + "area": { + "type": "number", + "description": "面积(平方米),仅 custom 自定义时填写" + } + } + } + } + } + } + }, + "pattern_index": { + "type": "integer", + "default": 0, + "description": "铺设方式索引(0-5)" + }, + "piece_length": { + "type": "number", + "description": "单片长度(米),必填,如 1.2" + }, + "piece_width": { + "type": "number", + "description": "单片宽度(米),必填,如 0.19" + }, + "pieces_per_box": { + "type": "integer", + "description": "每箱片数,选填,如 8" + }, + "price_per_sqm": { + "type": "number", + "description": "每平方米单价,选填,如 45" + }, + "joint_width": { + "type": "number", + "description": "缝宽(米),瓷砖用,如 0.002" + } + }, + "example": { + "floor_type": "wood", + "rooms": [ + { + "name": "Living Room", + "length": 5, + "width": 4, + "deductions": [ + { + "name": "Fireplace", + "shape": "rectangle", + "length": 1.5, + "width": 0.8 + } + ] + }, + { + "name": "Hallway", + "length": 3, + "width": 1.2, + "deductions": [] + } + ], + "pattern_index": 0, + "piece_length": 1.2, + "piece_width": 0.19, + "pieces_per_box": 8, + "price_per_sqm": 45 + } + }, + "CalcRoomInput": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "房间名称" + }, + "length": { + "type": "number", + "description": "长(米)" + }, + "width": { + "type": "number", + "description": "宽(米)" + }, + "deductions": { + "type": "array", + "items": { + "": "#/components/schemas/CalcDeduction" + }, + "description": "扣减区域(柜子/壁炉等不铺地板的区域)" + } + } + }, + "CalcDeduction": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "shape": { + "type": "string", + "enum": [ + "rectangle", + "circle", + "custom" + ], + "description": "形状" + }, + "length": { + "type": "number", + "description": "长(rectangle时用)" + }, + "width": { + "type": "number", + "description": "宽(rectangle时用)" + }, + "radius": { + "type": "number", + "description": "半径(circle时用)" + }, + "area": { + "type": "number", + "description": "自定义面积(custom时用)" + } + } + }, + "CalcResult": { + "type": "object", + "properties": { + "floor_type": { + "type": "string" + }, + "rooms": { + "type": "array", + "items": { + "": "#/components/schemas/CalcRoomResult" + } + }, + "total_room_area": { + "type": "number", + "description": "房间总面积" + }, + "total_deduct": { + "type": "number", + "description": "总扣减面积" + }, + "total_net_area": { + "type": "number", + "description": "总净面积(需铺地板)" + }, + "waste_rate": { + "type": "number", + "description": "损耗率(%)" + }, + "waste_area": { + "type": "number", + "description": "损耗面积" + }, + "total_area": { + "type": "number", + "description": "含损耗总面积" + }, + "pieces_needed": { + "type": "integer", + "description": "需要片数" + }, + "boxes_needed": { + "type": "number", + "description": "需要箱数(仅传入pieces_per_box时返回)" + }, + "joint_width": { + "type": "number" + }, + "grout_kg": { + "type": "number", + "description": "填缝剂重量kg(仅瓷砖模式返回)" + }, + "grout_bags": { + "type": "integer", + "description": "填缝剂袋数(仅瓷砖模式返回)" + }, + "price_per_sqm": { + "type": "number" + }, + "total_price": { + "type": "number", + "description": "总价(仅传入price_per_sqm时返回)" + }, + "pattern_name": { + "type": "string" + }, + "piece_length": { + "type": "number" + }, + "piece_width": { + "type": "number" + } + } + }, + "CalcRoomResult": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "length": { + "type": "number" + }, + "width": { + "type": "number" + }, + "area": { + "type": "number" + }, + "deductions": { + "type": "array", + "items": { + "": "#/components/schemas/CalcDeduction" + } + }, + "deduction_total": { + "type": "number" + }, + "net_area": { + "type": "number" + } + } + }, + "FrequentBrand": { + "type": "object", + "description": "用户常用品牌", + "properties": { + "brand": { + "type": "string", + "description": "品牌名称" + }, + "view_count": { + "type": "integer", + "description": "浏览次数" + }, + "fav_count": { + "type": "integer", + "description": "收藏次数" + }, + "logo_url": { + "type": "string", + "description": "品牌logo URL" + }, + "style_count": { + "type": "integer", + "description": "该品牌款式总数" + } + } + } + } + }, + "paths": { + "/auth/register": { + "post": { + "tags": [ + "认证" + ], + "summary": "用户注册", + "description": "使用用户名和密码注册新账号,返回 JWT token 和用户信息", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "type": "string", + "description": "用户名" + }, + "password": { + "type": "string", + "description": "密码" + }, + "name": { + "type": "string", + "description": "显示名称(选填)" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "注册成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthResponse" + } + } + } + }, + "400": { + "description": "参数错误(用户名或密码为空/用户名已存在)" + } + } + } + }, + "/auth/login": { + "post": { + "tags": [ + "认证" + ], + "summary": "用户登录", + "description": "用户名+密码登录,返回 JWT token 和用户信息", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "type": "string", + "description": "用户名" + }, + "password": { + "type": "string", + "description": "密码" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "登录成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthResponse" + } + } + } + }, + "401": { + "description": "用户名或密码错误" + } + } + } + }, + "/user/me": { + "get": { + "tags": [ + "用户" + ], + "summary": "获取个人信息", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "用户信息", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "401": { + "description": "未登录或token过期" + } + } + }, + "put": { + "tags": [ + "用户" + ], + "summary": "更新个人信息", + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "显示名称" + }, + "avatar_url": { + "type": "string", + "description": "头像URL" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "更新成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/user/favorites": { + "get": { + "tags": [ + "收藏" + ], + "summary": "获取收藏列表", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "收藏列表", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "favorites": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Favorite" + } + }, + "total": { + "type": "integer" + } + } + } + } + } + } + } + }, + "post": { + "tags": [ + "收藏" + ], + "summary": "添加收藏", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "201": { + "description": "收藏成功" + } + }, + "description": "传入产品SKU,后端自动查库补全产品信息", + "parameters": [ + { + "name": "sku", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "产品SKU编码" + } + ] + } + }, + "/user/favorites/{sku}": { + "delete": { + "tags": [ + "收藏" + ], + "summary": "取消收藏", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "sku", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "产品SKU编码" + } + ], + "responses": { + "200": { + "description": "取消成功" + } + } + } + }, + "/user/projects": { + "get": { + "tags": [ + "项目" + ], + "summary": "获取项目列表", + "description": "获取当前用户的所有AI换地板项目记录", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "项目列表", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + }, + "total": { + "type": "integer" + } + } + } + } + } + } + } + }, + "post": { + "tags": [ + "项目" + ], + "summary": "创建项目", + "description": "保存一次AI换地板记录", + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "项目名称,默认 Untitled", + "default": "Untitled" + }, + "original_image_url": { + "type": "string", + "description": "换地板前原图URL" + }, + "generated_image_url": { + "type": "string", + "description": "AI生成效果图URL" + }, + "floor_style": { + "type": "string", + "description": "使用的款式" + }, + "pattern": { + "type": "string", + "description": "铺设方式" + }, + "room_type": { + "type": "string", + "description": "房间类型" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "创建成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + } + } + } + }, + "/user/projects/{id}": { + "get": { + "tags": [ + "项目" + ], + "summary": "获取单个项目", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "项目UUID" + } + ], + "responses": { + "200": { + "description": "项目详情", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "404": { + "description": "项目不存在" + } + } + }, + "put": { + "tags": [ + "项目" + ], + "summary": "重命名项目", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "项目UUID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "新名称" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "更新成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + } + } + }, + "delete": { + "tags": [ + "项目" + ], + "summary": "删除项目", + "security": [ + { + "BearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "项目UUID" + } + ], + "responses": { + "200": { + "description": "删除成功" + } + } + } + }, + "/products": { + "get": { + "tags": [ + "产品" + ], + "summary": "产品列表(多维度筛选+搜索+分页)", + "parameters": [ + { + "name": "category", + "in": "query", + "schema": { + "type": "string" + }, + "description": "类目:Hardwood / Engineered Wood / Laminate / SPC/LVP / Wood-Look Tile" + }, + { + "name": "material", + "in": "query", + "schema": { + "type": "string" + }, + "description": "材质:Oak / Porcelain / Luxury Vinyl / Maple / Hickory 等" + }, + { + "name": "color_tone", + "in": "query", + "schema": { + "type": "string" + }, + "description": "色调:Light Gray / Natural Oak / Dark Brown / Taupe 等" + }, + { + "name": "finish", + "in": "query", + "schema": { + "type": "string" + }, + "description": "表面处理:Matte / Gloss / Textured / Wire-Brushed 等" + }, + { + "name": "wood_species", + "in": "query", + "schema": { + "type": "string" + }, + "description": "木种:Oak / Maple / Hickory / Walnut / Birch 等" + }, + { + "name": "brand", + "in": "query", + "schema": { + "type": "string" + }, + "description": "品牌筛选" + }, + { + "name": "series_name", + "in": "query", + "schema": { + "type": "string" + }, + "description": "系列名称筛选" + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "description": "模糊搜索(匹配款式名称/编号/品牌)" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + }, + "description": "页码" + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 20 + }, + "description": "每页数量" + } + ], + "responses": { + "200": { + "description": "产品列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductListResponse" + } + } + } + } + }, + "description": "所有筛选参数可任意组合。\n\n前端层层递进实现方式:用户每选择一个筛选项,将已选参数传给本接口,根据返回结果中的 filter_options 动态收窄其他筛选项的可选范围。" + } + }, + "/product-options": { + "get": { + "tags": [ + "产品" + ], + "summary": "筛选选项(扁平结构)", + "description": "返回分类/系列/品牌列表。品牌包含名称、logo、款式数、类型数。", + "responses": { + "200": { + "description": "筛选项", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "分类列表" + }, + "series_names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "系列名称列表" + }, + "brands": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BrandInfo" + }, + "description": "品牌列表(含统计和logo)" + } + } + } + } + }, + "example": { + "code": 200, + "data": { + "categories": [ + "Hardwood", + "Laminate", + "SPC/LVP", + "Engineered Wood", + "Wood-Look Tile" + ], + "series_names": [ + "American Retreat", + "Nature's Splendor", + "Timeless Design" + ], + "brands": [ + { + "name": "Shaw Floors", + "logo_url": "/static/brand_logos/shaw_floors.webp", + "style_count": 1797, + "collection_count": 4 + }, + { + "name": "Bruce", + "logo_url": "/static/brand_logos/bruce.jpg", + "style_count": 224, + "collection_count": 4 + }, + { + "name": "Karndean", + "logo_url": "/static/brand_logos/karndean.jpg", + "style_count": 251, + "collection_count": 1 + } + ] + } + } + } + } + } + } + } + }, + "/recommend": { + "post": { + "tags": [ + "推荐" + ], + "summary": "智能推荐", + "description": "输入产品SKU,返回跨品牌相似地板推荐", + "responses": { + "200": { + "description": "推荐结果", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecommendResponse" + } + } + } + } + }, + "parameters": [ + { + "name": "sku", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "产品SKU编码", + "example": "CB218LG" + } + ] + } + }, + "/calculator/calc": { + "post": { + "tags": [ + "计算器" + ], + "summary": "地板面积计算", + "description": "支持多房间、扣减区域、单片尺寸、铺设方式损耗、每箱片数、缝宽、单价。报告由前端根据返回结果自行生成。", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CalcInput" + } + } + } + }, + "responses": { + "200": { + "description": "计算结果", + "content": { + "application/json": { + "schema": { + "": "#/components/schemas/CalcResult" + } + } + } + }, + "400": { + "description": "参数错误" + } + } + } + }, + "/floor/options": { + "get": { + "tags": [ + "地板更换" + ], + "summary": "获取地板样式选项(从产品库动态加载)", + "description": "从产品数据库(3600+产品)动态加载地板样式选项。\n\n每个样式对应一个真实产品SKU,/floor/generate 的 floor_id 参数必须传真实SKU。\n\n分类说明:\n- wood_floors:实木+实木复合产品\n- tile_floors:瓷砖仿木纹+强化复合产品\n- vinyl_floors:SPC/LVP产品\n- wood_patterns / tile_patterns:铺设方式\n- rooms:房间类型", + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "data": { + "$ref": "#/components/schemas/FloorOptionsResponse" + } + } + }, + "example": { + "code": 200, + "data": { + "wood_floors": [], + "tile_floors": [], + "vinyl_floors": [], + "wood_patterns": [ + { + "code": 1, + "name": "Straight Lay", + "pattern": "straight/parallel planks...", + "category": "wood" + }, + { + "code": 2, + "name": "Horizontal Lay", + "pattern": "horizontal planks...", + "category": "wood" + }, + { + "code": 3, + "name": "Herringbone", + "pattern": "herringbone/zigzag pattern...", + "category": "wood" + }, + { + "code": 4, + "name": "Chevron", + "pattern": "chevron pattern...", + "category": "wood" + } + ], + "tile_patterns": [ + { + "code": 5, + "name": "Straight Grid", + "pattern": "straight grid layout...", + "category": "tile" + }, + { + "code": 6, + "name": "Running Bond", + "pattern": "running bond/brick pattern...", + "category": "tile" + } + ], + "rooms": [ + { + "code": 0, + "name": "Auto Detect" + }, + { + "code": 1, + "name": "Living Room" + }, + { + "code": 2, + "name": "Bedroom" + } + ] + } + } + } + } + } + } + } + }, + "/floor/generate": { + "post": { + "tags": [ + "地板更换" + ], + "summary": "AI换地板(使用真实产品数据)", + "description": "上传房间照片 + 地板选项,加入 Redis 异步队列,返回 job_id。3 个 worker 并发处理。用 GET /floor/status?job_id=xxx 查询进度", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "image", + "floor_id", + "pattern_code" + ], + "properties": { + "image": { + "type": "string", + "format": "binary", + "description": "房间照片" + }, + "floor_id": { + "type": "string", + "description": "产品SKU,从 /floor/options 获取(如 \"CB218LG\"),必须是真实产品SKU" + }, + "pattern_code": { + "type": "integer", + "description": "铺设方式编码(1-9),见文档顶部数字编码对照表", + "example": 3 + }, + "room_code": { + "type": "integer", + "description": "房间类型编码(0-7),见文档顶部数字编码对照表。0 或省略表示自动检测", + "example": 1 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "成功返回 job_id", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "用于查询进度的 job_id" + } + } + } + } + }, + "example": { + "code": 200, + "data": { + "job_id": "1750123456789000000" + } + } + } + } + }, + "400": { + "description": "参数错误", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 400 + }, + "error": { + "type": "string" + } + } + }, + "example": { + "code": 400, + "error": "Invalid options" + } + } + } + } + } + } + }, + "/products/{sku}": { + "get": { + "tags": [ + "Products" + ], + "summary": "产品详情(含同花色多尺寸变体)", + "description": "返回产品完整信息。如果该产品有同品牌+同花色(style_name)的其他SKU(不同尺寸/价格),会自动聚合到 variants 数组中,形成父子关系展示。\n\n例如:查询 Matte White 3×6 in,variants 返回 4×16 / 12×24 / 24×24 等其他尺寸。", + "parameters": [ + { + "name": "sku", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Product SKU code" + } + ], + "responses": { + "200": { + "description": "Product with variants", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProductDetail" + } + } + } + }, + "404": { + "description": "Product not found" + } + } + } + }, + "/filter-options": { + "get": { + "tags": [ + "Products" + ], + "summary": "分级筛选选项(层级结构)", + "description": "返回所有可用的筛选值,支持层级结构(Material 下分 Wood / Porcelain / Vinyl / Other 子组)。\n\n前端层层递进实现方式:\n1. 页面加载时调用本接口获取全部选项\n2. 用户选择某个筛选项后,将已选参数传给 /products 接口\n3. 根据 /products 返回的 filter_options 动态收窄其他筛选项的可选范围", + "responses": { + "200": { + "description": "Filter hierarchy", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FilterHierarchy" + } + } + } + } + } + } + }, + "/articles": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "科普文章列表(分页)", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 10 + } + } + ], + "responses": { + "200": { + "description": "Article list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArticleListResponse" + } + } + } + } + } + } + }, + "/articles/{id}": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "文章详情", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Article detail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Article" + } + } + } + }, + "404": { + "description": "Article not found" + } + } + } + }, + "/articles/recommend": { + "get": { + "tags": [ + "Knowledge" + ], + "summary": "随机推荐文章(默认6篇)", + "description": "每次随机返回指定数量的文章,默认6篇,最多20篇", + "parameters": [ + { + "name": "count", + "in": "query", + "schema": { + "type": "integer", + "default": 6 + }, + "description": "Count (1-20)" + } + ], + "responses": { + "200": { + "description": "Recommended articles", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArticleRecommendResponse" + } + } + } + } + } + } + }, + "/user/avatar": { + "post": { + "tags": [ + "用户" + ], + "summary": "上传头像", + "description": "上传用户头像图片,自动替换旧头像。最大5MB。头像存储在 uploads/avatars/ 目录。", + "security": [ + { + "BearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "image": { + "type": "string", + "format": "binary", + "description": "头像图片文件" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "上传成功,返回用户信息(含avatar_url)" + } + } + } + }, + "/floor/status": { + "get": { + "tags": [ + "地板更换" + ], + "summary": "查询生成进度(HTTP 轮询)", + "description": "Redis 异步队列任务状态查询。status: pending | processing | done | error", + "parameters": [ + { + "name": "job_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "floor-generate 返回的 job_id" + } + ], + "responses": { + "200": { + "description": "SSE 事件流" + } + } + } + }, + "/upload": { + "post": { + "tags": [ + "Upload" + ], + "summary": "通用图片上传", + "description": "上传图片文件,返回访问URL。最大20MB,自动检测图片类型。存于 static/images/ 目录", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "image": { + "type": "string", + "format": "binary", + "description": "图片文件" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "上传成功", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "url": { + "type": "string", + "example": "/static/images/abc123.jpg" + }, + "filename": { + "type": "string", + "example": "abc123.jpg" + } + } + } + } + } + } + } + } + } + } + }, + "/user/frequent-brands": { + "get": { + "tags": [ + "Brand Tracking" + ], + "summary": "获取常用品牌", + "description": "返回浏览≥3次或收藏≥2次的品牌。需登录。按权重排序", + "security": [ + { + "BearerAuth": [] + } + ], + "responses": { + "200": { + "description": "常用品牌列表", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "data": { + "type": "object", + "properties": { + "brands": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FrequentBrand" + } + }, + "total": { + "type": "integer" + } + } + } + } + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/cmd/import/main.go b/cmd/import/main.go new file mode 100644 index 0000000..a688ace --- /dev/null +++ b/cmd/import/main.go @@ -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) +} diff --git a/cmd/import_articles/main.go b/cmd/import_articles/main.go new file mode 100644 index 0000000..a404f4f --- /dev/null +++ b/cmd/import_articles/main.go @@ -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) +} diff --git a/cmd/scrape_logos/main.py b/cmd/scrape_logos/main.py new file mode 100644 index 0000000..71b8719 --- /dev/null +++ b/cmd/scrape_logos/main.py @@ -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']+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']+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() diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..26bf6df --- /dev/null +++ b/docker-compose.dev.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5ff4426 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..07dc8eb --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e413204 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/handler/article_handler.go b/internal/handler/article_handler.go new file mode 100644 index 0000000..b7ad49f --- /dev/null +++ b/internal/handler/article_handler.go @@ -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), + }) + } +} diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go new file mode 100644 index 0000000..d94bd6d --- /dev/null +++ b/internal/handler/auth_handler.go @@ -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) + } +} diff --git a/internal/handler/calc_handler.go b/internal/handler/calc_handler.go new file mode 100644 index 0000000..476b595 --- /dev/null +++ b/internal/handler/calc_handler.go @@ -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 +} diff --git a/internal/handler/floor_handler.go b/internal/handler/floor_handler.go new file mode 100644 index 0000000..fd16302 --- /dev/null +++ b/internal/handler/floor_handler.go @@ -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() +} diff --git a/internal/handler/health_handler.go b/internal/handler/health_handler.go new file mode 100644 index 0000000..d8607ae --- /dev/null +++ b/internal/handler/health_handler.go @@ -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", + }) +} diff --git a/internal/handler/product_handler.go b/internal/handler/product_handler.go new file mode 100644 index 0000000..4fa6a87 --- /dev/null +++ b/internal/handler/product_handler.go @@ -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}) + } +} diff --git a/internal/handler/recommend_handler.go b/internal/handler/recommend_handler.go new file mode 100644 index 0000000..1c3f41d --- /dev/null +++ b/internal/handler/recommend_handler.go @@ -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), + }) + } +} diff --git a/internal/handler/router.go b/internal/handler/router.go new file mode 100644 index 0000000..635f553 --- /dev/null +++ b/internal/handler/router.go @@ -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) +} diff --git a/internal/handler/upload_handler.go b/internal/handler/upload_handler.go new file mode 100644 index 0000000..6fedb2d --- /dev/null +++ b/internal/handler/upload_handler.go @@ -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}) +} diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go new file mode 100644 index 0000000..6f51b39 --- /dev/null +++ b/internal/handler/user_handler.go @@ -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), + }) + } +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..4060c04 --- /dev/null +++ b/internal/logger/logger.go @@ -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) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..f6f3972 --- /dev/null +++ b/internal/middleware/auth.go @@ -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 "" +} diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go new file mode 100644 index 0000000..ca27ec4 --- /dev/null +++ b/internal/middleware/logger.go @@ -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)) + }) +} diff --git a/internal/model/article.go b/internal/model/article.go new file mode 100644 index 0000000..aa393dd --- /dev/null +++ b/internal/model/article.go @@ -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) +} diff --git a/internal/model/brand.go b/internal/model/brand.go new file mode 100644 index 0000000..1ad88c4 --- /dev/null +++ b/internal/model/brand.go @@ -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"` +} diff --git a/internal/model/engine.go b/internal/model/engine.go new file mode 100644 index 0000000..899c92a --- /dev/null +++ b/internal/model/engine.go @@ -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, + } +} diff --git a/internal/model/favorite.go b/internal/model/favorite.go new file mode 100644 index 0000000..52dfa6f --- /dev/null +++ b/internal/model/favorite.go @@ -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"` +} diff --git a/internal/model/product.go b/internal/model/product.go new file mode 100644 index 0000000..c5d3e45 --- /dev/null +++ b/internal/model/product.go @@ -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"` +} diff --git a/internal/model/project.go b/internal/model/project.go new file mode 100644 index 0000000..511d039 --- /dev/null +++ b/internal/model/project.go @@ -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"` +} diff --git a/internal/model/user.go b/internal/model/user.go new file mode 100644 index 0000000..e869186 --- /dev/null +++ b/internal/model/user.go @@ -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"` +} diff --git a/internal/openrouter/chat.go b/internal/openrouter/chat.go new file mode 100644 index 0000000..f9d1215 --- /dev/null +++ b/internal/openrouter/chat.go @@ -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"` +} diff --git a/internal/openrouter/client.go b/internal/openrouter/client.go new file mode 100644 index 0000000..2d5036f --- /dev/null +++ b/internal/openrouter/client.go @@ -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 +} diff --git a/internal/openrouter/image.go b/internal/openrouter/image.go new file mode 100644 index 0000000..f2e872e --- /dev/null +++ b/internal/openrouter/image.go @@ -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 +} diff --git a/internal/queue/redis.go b/internal/queue/redis.go new file mode 100644 index 0000000..4036547 --- /dev/null +++ b/internal/queue/redis.go @@ -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 +} diff --git a/internal/queue/worker.go b/internal/queue/worker.go new file mode 100644 index 0000000..1ab77d0 --- /dev/null +++ b/internal/queue/worker.go @@ -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 diff --git a/internal/repository/article_repo.go b/internal/repository/article_repo.go new file mode 100644 index 0000000..d1eae9f --- /dev/null +++ b/internal/repository/article_repo.go @@ -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() +} diff --git a/internal/repository/brand_view_repo.go b/internal/repository/brand_view_repo.go new file mode 100644 index 0000000..b0c9a39 --- /dev/null +++ b/internal/repository/brand_view_repo.go @@ -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() +} diff --git a/internal/repository/db.go b/internal/repository/db.go new file mode 100644 index 0000000..ddf3d97 --- /dev/null +++ b/internal/repository/db.go @@ -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 +} diff --git a/internal/repository/favorite_repo.go b/internal/repository/favorite_repo.go new file mode 100644 index 0000000..e104460 --- /dev/null +++ b/internal/repository/favorite_repo.go @@ -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() +} diff --git a/internal/repository/filter_repo.go b/internal/repository/filter_repo.go new file mode 100644 index 0000000..e6b339f --- /dev/null +++ b/internal/repository/filter_repo.go @@ -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 +} diff --git a/internal/repository/gorm.go b/internal/repository/gorm.go new file mode 100644 index 0000000..e7340a1 --- /dev/null +++ b/internal/repository/gorm.go @@ -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 +} diff --git a/internal/repository/product_repo.go b/internal/repository/product_repo.go new file mode 100644 index 0000000..4abfe53 --- /dev/null +++ b/internal/repository/product_repo.go @@ -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() +} diff --git a/internal/repository/project_repo.go b/internal/repository/project_repo.go new file mode 100644 index 0000000..9e615f7 --- /dev/null +++ b/internal/repository/project_repo.go @@ -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 +} diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go new file mode 100644 index 0000000..28322b8 --- /dev/null +++ b/internal/repository/user_repo.go @@ -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 +} diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go new file mode 100644 index 0000000..c4828c5 --- /dev/null +++ b/internal/service/auth_service.go @@ -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 +} diff --git a/internal/service/product_service.go b/internal/service/product_service.go new file mode 100644 index 0000000..3575a3b --- /dev/null +++ b/internal/service/product_service.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..bbb6476 --- /dev/null +++ b/main.go @@ -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))) +} diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..cd22e3f --- /dev/null +++ b/schema.sql @@ -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);