FloorVisualizer/internal/handler/floor_handler.go
2026-07-27 14:39:45 +08:00

642 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"context"
"database/sql"
"encoding/json"
"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"`
WidthIn float64 `json:"width_in"`
LengthIn float64 `json:"length_in"`
ImageURL string `json:"image_url"`
SKU string `json:"sku"`
Description string `json:"description"`
FloorDescription string `json:"floor_description,omitempty"`
Variants []FloorOption `json:"variants,omitempty"`
}
type PatternOption struct {
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: "planks running from bottom-left toward top-right of the image, joints staggered randomly, at least 6 inch offset between rows"},
{Code: 2, Name: "横铺 Horizontal Lay", Category: "wood", Pattern: "planks running horizontally left to right, parallel to the bottom edge of the image, joints staggered randomly, at least 6 inch offset"},
{Code: 3, Name: "人字铺 Herringbone", Category: "wood", Pattern: "ALL planks must be IDENTICAL width - no piece may appear narrower. Planks at 90 degrees forming continuous zigzag V-lines from bottom-left to top-right of the image. At far distance, planks remain distinct with visible seams - NOT merging into a solid mass."},
{Code: 4, Name: "鱼骨铺 Chevron", Category: "wood", Pattern: "planks with 45 degree mitered ends forming continuous V-points aligned from bottom-left to top-right of the image"},
}
var tilePatterns = []PatternOption{
{Code: 5, Name: "直铺网格对缝Straight Grid", Category: "tile", Pattern: "tiles aligned in rows (parallel to bottom edge) and columns (perpendicular to bottom edge), all grout lines continuous, no offset"},
{Code: 6, Name: "工字铺1/2错缝Running Bond", Category: "tile", Pattern: "rows parallel to bottom edge of image, each row offset 50% from previous, horizontal grout lines continuous, vertical grout lines staggered"},
{Code: 7, Name: "三七错铺 1/3 Offset", Category: "tile", Pattern: "rows parallel to bottom edge of image, each row offset exactly 33% of tile length, every third row aligns"},
{Code: 8, Name: "六边形 Hexagonal", Category: "tile", Pattern: "six-sided tiles interlocked in honeycomb mesh, flat edges horizontal (parallel to bottom edge of image), grout follows hexagonal edges"},
{Code: 9, Name: "斜铺60度对角Diagonal", Category: "tile", Pattern: "tiles rotated 60 degrees to the image frame, grout lines run diagonally at 60 and 150 degrees to the bottom edge, no lines parallel to image edges"},
}
var roomOptions = []RoomOption{
{Code: 0, Name: "自动识别"},
{Code: 1, Name: "客厅"},
{Code: 2, Name: "卧室"},
{Code: 3, Name: "厨房"},
{Code: 4, Name: "餐厅"},
{Code: 5, Name: "浴室"},
{Code: 6, Name: "书房"},
{Code: 7, Name: "走廊"},
}
func findPatternByCode(code int) *PatternOption {
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 all products, deduplicated by brand+style_name. Variants
// (different sizes of the same style) are attached to the representative entry.
func loadFloorOptions(db *sql.DB) ([]FloorOption, error) {
// Use raw query without DISTINCT ON so all size variants are returned
rows, err := db.Query(`SELECT brand, group_name, sku, series_name, style_name,
category, material, color_tone, finish, price_per_sqft, price_tier, price_source,
is_official_price, main_image_url, room_image_url, source_url, description, status,
COALESCE(coverage_sqft_per_box,0), COALESCE(wood_species,''),
size_label, COALESCE(width_in,0), COALESCE(length_in,0)
FROM products ORDER BY brand, style_name, id LIMIT 5000`)
if err != nil {
return nil, err
}
defer rows.Close()
prods, err := repository.ScanProducts(rows)
if err != nil {
return nil, err
}
type key struct{ brand, style string }
seen := map[key]int{} // key 閳?index in out slice
var out []FloorOption
for _, p := range prods {
k := key{p.Brand, p.StyleName}
if idx, ok := seen[k]; ok {
// Add as variant to the representative entry
out[idx].Variants = append(out[idx].Variants, FloorOption{
SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn,
SKU: p.SKU,
})
} else {
seen[k] = len(out)
out = append(out, FloorOption{
ID: p.SKU, Name: p.StyleName, Category: p.Category,
Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish,
SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn,
ImageURL: p.MainImageURL, SKU: p.SKU, Description: p.Description,
})
}
}
return out, nil
}
func isTileProduct(p FloorOption) bool {
m := strings.ToLower(p.Material)
return strings.Contains(m, "porcelain") || strings.Contains(m, "ceramic") ||
strings.Contains(m, "stone look") || m == "glass"
}
func isVinylProduct(p FloorOption) bool {
m := strings.ToLower(p.Material)
return m == "luxury vinyl" || strings.Contains(m, "rigid core") || m == "vinyl"
}
func isLaminateProduct(p FloorOption) bool {
return strings.EqualFold(p.Material, "laminate")
}
func isWoodProduct(p FloorOption) bool {
return !isTileProduct(p) && !isVinylProduct(p) && !isLaminateProduct(p)
}
func findFloorBySKU(db *sql.DB, sku string) *FloorOption {
p, err := repository.GetProductBySKU(db, sku)
if err != nil {
return nil
}
f := &FloorOption{
ID: p.SKU, Name: p.StyleName, Category: p.Category,
Material: p.Material, ColorTone: p.ColorTone, Finish: p.Finish,
SizeLabel: p.SizeLabel, WidthIn: p.WidthIn, LengthIn: p.LengthIn,
ImageURL: p.MainImageURL, SKU: p.SKU, Description: p.Description,
}
// Fetch size variants (same style, different dimensions)
variants, _ := repository.GetVariantsByStyle(db, sku)
for _, vp := range variants {
f.Variants = append(f.Variants, FloorOption{
SizeLabel: vp.SizeLabel, WidthIn: vp.WidthIn, LengthIn: vp.LengthIn,
SKU: vp.SKU,
})
}
// Canonical reference image = smallest size variant (by width)
// All sizes of the same style use the same reference image to prevent color drift
canonicalSKU, canonicalImage, canonicalWidth, canonicalLength := p.SKU, p.MainImageURL, f.WidthIn, f.LengthIn
rows, _ := db.Query(`SELECT sku, main_image_url, COALESCE(width_in,0), COALESCE(length_in,0) FROM products
WHERE style_name = $1 AND brand = $2
ORDER BY COALESCE(width_in,999), COALESCE(length_in,999) LIMIT 1`,
p.StyleName, p.Brand)
if rows != nil {
defer rows.Close()
if rows.Next() {
rows.Scan(&canonicalSKU, &canonicalImage, &canonicalWidth, &canonicalLength)
}
}
f.ImageURL = canonicalImage
// Build dynamic size instruction if selected size differs from reference image size
if (canonicalWidth > 0 && canonicalLength > 0) && (f.WidthIn > 0 && f.LengthIn > 0) {
if canonicalWidth != f.WidthIn || canonicalLength != f.LengthIn {
f.Description = fmt.Sprintf(
`尺寸映射:参考图显示 %.1f" x %.1f" 的板/砖;本次生成 %.1f" x %.1f" 的板/砖。材质必须完全相同,只按比例调整板/砖尺寸。 %s`,
canonicalWidth, canonicalLength, f.WidthIn, f.LengthIn, f.Description,
)
}
}
return f
}
// 閳光偓閳光偓 Public accessors for worker 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
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 buildCompactFloorPrompt(f, p, room)
}
// BuildCombinedFloorPrompt builds a single prompt that identifies the floor area AND replaces it.
// No separate mask generation step 閳?the AI does both in one pass.
func BuildCombinedFloorPrompt(f FloorOption, p PatternOption, room *RoomOption, groutLines, whiteBaseboard bool) string {
return buildCompactCombinedFloorPrompt(f, p, room, groutLines, whiteBaseboard)
}
// buildPatternConstraint returns installation rules specific to each pattern code.
func buildPatternConstraint(code int) string {
switch code {
case 1:
return "铺法规则:木地板直铺,板材连续铺设,接缝随机错开至少 6 英寸。"
case 2:
return "铺法规则:木地板横铺,板材沿房间主方向铺设,接缝随机错开至少 6 英寸。"
case 3:
return "铺法规则:人字拼,板材 90 度相接形成连续折线,每片板宽一致,接缝清楚。"
case 4:
return "铺法规则:鱼骨拼,板材 45 度斜切端形成连续 V 形,必须是斜切对接。"
case 5:
return "铺法规则:瓷砖直铺网格对缝,砖块成行成列对齐,无错缝,砖缝约 1/8 英寸。"
case 6:
return "铺法规则:瓷砖工字铺,相邻行错开砖长的 50%,行向砖缝连续,竖向接缝错开。"
case 7:
return "铺法规则:瓷砖三七错铺,相邻行错开砖长的 33%,每三行重新对齐,不是 50% 工字铺。"
case 8:
return "铺法规则:六边形瓷砖,六边形砖互锁成蜂窝网,砖缝沿六边形边缘,不是方格网。"
case 9:
return "铺法规则:瓷砖斜铺,砖块相对墙边形成菱形或三角切砖效果,砖缝约 1/8 英寸。"
default:
return ""
}
}
// 閳光偓閳光偓 Floor Generate Handler (Redis queue) 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
func SetQueue(enqueue func(ctx context.Context, jobID string, payload map[string]string) error) {
generateQueue = enqueue
}
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 FloorGenerate(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"),
"grout_lines": boolFormValue(r, "grout_lines"),
"white_baseboard": boolFormValue(r, "white_baseboard"),
"material_source": r.FormValue("material_source"),
}
if err := generateQueue(r.Context(), jobID, payload); err != nil {
writeErr(w, 500, "Failed to enqueue job")
return
}
_ = payload // payload stored inside generateQueue wrapper
writeJSON(w, 200, map[string]string{"job_id": jobID})
}
}
// 閳光偓閳光偓 Progress 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
// 閳光偓閳光偓 Floor Status Query 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
func FloorStatus(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 FloorOptions(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
all, _ := loadFloorOptions(db)
var allWood, allTile, vinylFloors []FloorOption
for _, f := range all {
switch {
case isVinylProduct(f):
vinylFloors = append(vinylFloors, f)
case isTileProduct(f) || isLaminateProduct(f):
allTile = append(allTile, f)
default:
allWood = append(allWood, f)
}
}
// Load special test products with floor descriptions
specialFloors := loadSpecialFloors(db)
writeJSON(w, 200, map[string]any{
"wood_floors": allWood,
"wood_patterns": woodPatterns,
"tile_floors": allTile,
"tile_patterns": tilePatterns,
"vinyl_floors": vinylFloors,
"special_floors": specialFloors,
"rooms": roomOptions,
})
}
}
// materialIndex caches SKU 閳?material description from MaterialAssets JSON files.
var materialIndex map[string]string
// LoadMaterialIndex loads the material description index from a JSON file.
func LoadMaterialIndex(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, &materialIndex)
}
// LookupMaterialDesc returns the material description for a given SKU from the loaded index.
func LookupMaterialDesc(sku string) string {
if materialIndex == nil {
return ""
}
return materialIndex[sku]
}
// loadSpecialFloors loads the 7 special test products with pre-generated floor descriptions.
func loadSpecialFloors(db *sql.DB) []FloorOption {
data, err := os.ReadFile("outputs/special_7_products.json")
if err != nil {
return nil
}
var raw []struct {
Brand string `json:"brand"`
Style string `json:"style"`
Material string `json:"material"`
Description string `json:"description"`
ImageURL string `json:"image_url"`
}
if json.Unmarshal(data, &raw) != nil {
return nil
}
var out []FloorOption
for _, r := range raw {
// Find any real SKU for this style to get DB variants and sizes
var sku string
db.QueryRow(`SELECT sku FROM products WHERE style_name=$1 AND brand=$2 LIMIT 1`,
r.Style, r.Brand).Scan(&sku)
if sku == "" {
continue
}
f := findFloorBySKU(db, sku)
if f == nil {
continue
}
f.FloorDescription = r.Description
// Keep f.ImageURL from DB (canonical smallest) 閳?do NOT override with local path
out = append(out, *f)
}
return out
}
// 閳光偓閳光偓 Prompts 閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓閳光偓
func buildMaskPrompt(room *RoomOption) string {
base := "你是精确的地面分割工具。请为这张室内照片生成纯黑白遮罩图。\n\n" +
"严格定义:\n" +
"- 白色(#FFFFFF只表示平整、水平、可行走的地面。包含所有可见地面一直到墙面、踢脚线、柜体或楼梯边缘。\n" +
"- 黑色(#000000所有非水平地面的内容包括墙面、踢脚线、楼梯、家具、家具腿、门窗、柜体、电器、地毯、天花板、人物、宠物。\n\n" +
"边缘规则:\n" +
"- 墙地交界处必须沿交界线精确切分;踢脚线为黑色,地面为白色。\n" +
"- 家具腿和椅轮为黑色;家具腿之间和周围真实可见的地面为白色。\n" +
"- 楼梯和地毯都标为黑色。\n\n" +
"只输出遮罩图片,不要文字说明。"
if room != nil && room.Code != 0 {
base += fmt.Sprintf("\n房间类型%s。", room.Name)
}
return base
}
func buildMaterialRules(floor FloorOption) string {
if isTileProduct(floor) || isLaminateProduct(floor) {
return "整片地面必须是同一个 SKU基础颜色在全地面保持一致。允许原图阴影让局部变暗但不能改变地砖色相或材质。瓷砖表面平整缝隙连续、透视一致不要生成局部偏绿、偏蓝、偏黄、偏青的色块。反光只能来自原图已有光照不能新增窗户反光、亮斑或湿地板效果。"
}
return "地面必须保持同一个 SKU 的颜色、纹理、木纹或板纹和光泽。保留原图已有光照和阴影;不要新增反光、亮斑或阴影。板缝透视一致,纹理自然但不能明显重复。"
}
func buildCompactCombinedFloorPrompt(f FloorOption, p PatternOption, room *RoomOption, groutLines, whiteBaseboard bool) string {
roomName := "自动识别"
if room != nil && room.Code != 0 {
roomName = room.Name
}
var priority []string
if whiteBaseboard {
priority = append(priority, "最高优先级:必须生成白色踢脚线。\n踢脚线属于可编辑区域不属于墙面保护区域。\n沿所有可见墙地交界处生成连续白色踢脚线高约 4-6 英寸。\n踢脚线应为真实白色木质或漆面条带有轻微厚度和接触阴影。\n如果没有明显、连续的白色踢脚线结果视为失败。")
}
if groutLines {
priority = append(priority, "必须生成清晰、等距、连续的瓷砖缝。")
}
priorityText := ""
if len(priority) > 0 {
priorityText = strings.Join(priority, "\n\n") + "\n\n"
}
editScope := "只替换地面"
if whiteBaseboard {
editScope = "只替换地面和白色踢脚线"
}
return fmt.Sprintf("%s你是室内照片编辑工具不是重新生成图片。\n房间类型%s。\n\n任务\n%s。墙面、家具、桌椅、门窗、天花板保持原图不变。\n保持原图尺寸、构图、相机角度、光照、阴影、白平衡不变。\n\n%s\n\n输出\n只返回完成后的图片不要文字、不要 JSON。",
priorityText,
roomName,
editScope,
buildCompactFloorPrompt(f, p, room),
)
}
func buildCompactFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption) string {
matType := "木地板"
if isTileProduct(floor) || isLaminateProduct(floor) {
matType = "瓷砖"
}
materialDesc := fmt.Sprintf("%s%s色调%s表面", floor.Material, floor.ColorTone, floor.Finish)
if floor.SizeLabel != "" {
materialDesc += ",尺寸 " + floor.SizeLabel
}
var details []string
if floor.Description != "" {
details = append(details, "产品描述:"+floor.Description)
}
if floor.FloorDescription != "" {
details = append(details, "目录材质描述:"+floor.FloorDescription)
}
if floor.SizeLabel != "" || (floor.WidthIn > 0 && floor.LengthIn > 0) {
details = append(details, compactSizeInstruction(floor))
}
if floor.ImageURL != "" {
details = append(details, "产品图是唯一材质参考:锁定基础颜色、纹理、纹路、光泽、材质类型;不要重新美化或改色。")
}
if guard := compactProductNameGuard(floor); guard != "" {
details = append(details, guard)
}
return fmt.Sprintf("地板产品:\nSKU%s\n产品名%s这是商品目录名不是视觉效果指令\n类型%s\n材质%s\n铺法%s。%s\n\n材质规则\n%s\n%s\n\n禁止\nno wet floor, no puddles, no water stains, no color shift, no green/blue/yellow/cyan patches, no new lights, no new shadows, no CGI, no text, no watermark。",
floor.SKU,
floor.Name,
matType,
materialDesc,
pattern.Name,
pattern.Pattern,
buildMaterialRules(floor),
strings.Join(details, "\n"),
)
}
func compactSizeInstruction(floor FloorOption) string {
if floor.SizeLabel != "" {
return "尺寸规则:使用所选尺寸 " + floor.SizeLabel + ";只调整板/砖大小和缝距,不改变材质颜色和纹理。"
}
if floor.WidthIn > 0 && floor.LengthIn > 0 {
return fmt.Sprintf("尺寸规则:每片 %.1f\" x %.1f\";只调整板/砖大小和缝距,不改变材质颜色和纹理。", floor.WidthIn, floor.LengthIn)
}
return ""
}
func compactProductNameGuard(floor FloorOption) string {
text := strings.ToLower(floor.Name + " " + floor.Description + " " + floor.FloorDescription)
for _, word := range []string{"rain", "puddle", "water", "wet", "storm", "mist", "fog", "snow", "ice"} {
if strings.Contains(text, word) {
return "产品名防误解:不要按产品名生成雨水、积水、水渍、雾气、冰雪、湿地板或彩色斑块;只看产品参考图和结构化材质字段。"
}
}
return ""
}
func buildFloorPrompt(floor FloorOption, pattern PatternOption, room *RoomOption) string {
return buildCompactFloorPrompt(floor, pattern, room)
}
func buildProductNameGuard(floor FloorOption) string {
return compactProductNameGuard(floor)
}
func validateContent(client *openrouter.Client, ctx context.Context, imagePath string) (bool, int) {
imgData, err := os.ReadFile(imagePath)
if err != nil {
return false, 0
}
dataURI := "data:image/png;base64," + b64enc(imgData)
prompt := "请按 1-10 分评估这张图片是否适合做室内地面替换。\n8-10 分:清晰室内房间,地面可见。\n5-7 分:室内图片,但角度或地面可见度一般。\n1-4 分:室外、宠物特写、物体特写、没有可替换地面,或不适合做地面替换。\n只返回一个数字1-10。"
resp, err := client.Chat(ctx, openrouter.ChatRequest{
Model: "google/gemini-2.5-flash",
Messages: []openrouter.Message{{Role: "user", Content: []openrouter.ContentPart{
{Type: "image_url", ImageURL: &openrouter.ImageURL{URL: dataURI}},
{Type: "text", Text: prompt},
}}},
})
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()
}
func boolFormValue(r *http.Request, key string) string {
value := strings.ToLower(strings.TrimSpace(r.FormValue(key)))
switch value {
case "1", "true", "yes", "y", "on":
return "true"
default:
return "false"
}
}