import asyncio
     1|from fastapi import FastAPI, File, UploadFile, HTTPException
     2|from fastapi.middleware.cors import CORSMiddleware
     3|from pydantic import BaseModel, Field
     4|from typing import Optional, List
     5|import os
     6|import uuid
import asyncio
     7|from datetime import datetime
     8|from pathlib import Path
     9|import httpx
    10|import json
    11|import database
    12|
    13|app = FastAPI(title="Home Renovation API", version="1.0.0")
    14|
    15|# CORS 配置
    16|app.add_middleware(
    17|    CORSMiddleware,
    18|    allow_origins=["*"],
    19|    allow_credentials=True,
    20|    allow_methods=["*"],
    21|    allow_headers=["*"],
    22|)
    23|
    24|# 创建 uploads 目录
    25|UPLOAD_DIR = Path("uploads")
    26|UPLOAD_DIR.mkdir(exist_ok=True)
    27|
    28|# Pydantic 模型定义
    29|class LayoutUploadResponse(BaseModel):
    30|    """上传户型图响应模型"""
    31|    layout_id: str = Field(..., description="布局唯一 ID")
    32|    filename: str = Field(..., description="原始文件名")
    33|    file_path: str = Field(..., description="文件保存路径")
    34|    file_size: int = Field(..., description="文件大小（字节）")
    35|    upload_time: str = Field(..., description="上传时间")
    36|    message: str = Field(default="上传成功", description="响应消息")
    37|
    38|
    39|class LayoutInfo(BaseModel):
    40|    """户型图信息模型（用于数据库存储，当前 MVP 先保存到本地）"""
    41|    layout_id: str
    42|    filename: str
    43|    file_path: str
    44|    file_size: int
    45|    upload_time: datetime
    46|    content_type: Optional[str] = None
    47|
    48|
    49|# 内存存储（MVP 阶段，后续迁移到数据库）
    50|layouts_db = {}
    51|
    52|# 加载材料数据库
    53|materials_db = {}
    54|try:
    55|    data_path = Path(__file__).parent / "data" / "materials.json"
    56|    
    57|    # 根据环境变量选择数据库（默认中国，MARKET=US 加载美国数据库）
    58|    market = os.getenv("MARKET", "CN").upper()
    59|    if market == "US":
    60|        data_path = Path(__file__).parent / "data" / "materials_us.json"
    61|        print(f"🌎 加载北美材料数据库 (MARKET={market})")
    62|    
    63|    with open(data_path, 'r', encoding='utf-8') as f:
    64|        materials_db = json.load(f)
    65|    print(f"✅ 材料数据库加载成功，共 {len(materials_db.get('materials', []))} 种材料")
    66|except Exception as e:
    67|    print(f"⚠️ 材料数据库加载失败: {e}")
    68|    materials_db = {"materials": [], "rooms": {}}
    69|
    70|
    71|@app.get("/")
    72|async def root():
    73|    return {"message": "Home Renovation API is running"}
    74|
    75|
    76|@app.get("/health")
    77|async def health_check():
    78|    return {"status": "healthy"}
    79|
    80|
    81|@app.post("/api/v1/upload-layout", response_model=LayoutUploadResponse)
    82|async def upload_layout(
    83|    file: UploadFile = File(..., description="户型图文件（PNG/JPG）")
    84|):
    85|    """
    86|    上传户型图接口
    87|    
    88|    - **file**: 户型图文件，支持 PNG/JPG 格式
    89|    
    90|    返回布局 ID，用于后续生成装修方案
    91|    """
    92|    # 验证文件类型（同时检查 MIME 类型和文件扩展名）
    93|    allowed_types = ["image/png", "image/jpeg", "image/jpg", "application/octet-stream"]
    94|    file_ext = (file.filename or "").lower().split('.')[-1] if file.filename else ""
    95|    allowed_exts = ["png", "jpg", "jpeg"]
    96|    
    97|    # 检查 MIME 类型或文件扩展名
    98|    if file.content_type not in allowed_types and file_ext not in allowed_exts:
    99|        raise HTTPException(
   100|            status_code=400,
   101|            detail=f"不支持的文件类型: {file.content_type} (扩展名: .{file_ext})。仅支持 PNG/JPG"
   102|        )
   103|    
   104|    # 验证文件大小（限制 10MB）
   105|    MAX_SIZE = 10 * 1024 * 1024  # 10MB
   106|    file_size = 0
   107|    content = await file.read()
   108|    file_size = len(content)
   109|    
   110|    if file_size > MAX_SIZE:
   111|        raise HTTPException(
   112|            status_code=400,
   113|            detail=f"文件过大: {file_size / 1024 / 1024:.2f}MB。最大支持 10MB"
   114|        )
   115|    
   116|    # 生成唯一 ID
   117|    layout_id = str(uuid.uuid4())
   118|    
   119|    # 构建保存路径（使用原始扩展名）
   120|    original_filename = file.filename or "unknown"
   121|    file_ext = Path(original_filename).suffix or ".jpg"
   122|    safe_filename = f"{layout_id}{file_ext}"
   123|    file_path = UPLOAD_DIR / safe_filename
   124|    
   125|    # 保存文件
   126|    try:
   127|        with open(file_path, "wb") as f:
   128|            f.write(content)
   129|    except Exception as e:
   130|        raise HTTPException(status_code=500, detail=f"文件保存失败: {str(e)}")
   131|    
   132|    # 保存到数据库
   133|    upload_time = datetime.now()
   134|    db_success = database.save_layout(
   135|        layout_id, original_filename, file_size, upload_time, 'uploaded', str(file_path)
   136|    )
   137|    
   138|    if not db_success:
   139|        print(f"⚠️ 数据库保存失败，但文件已上传: {layout_id}")
   140|    
   141|    return LayoutUploadResponse(
   142|        layout_id=layout_id,
   143|        filename=original_filename,
   144|        file_path=str(file_path),
   145|        file_size=file_size,
   146|        upload_time=upload_time.isoformat(),
   147|        message="户型图上传成功"
   148|    )
   149|
   150|
   151|@app.get("/api/v1/layouts/{layout_id}")
   152|async def get_layout(layout_id: str):
   153|    """获取户型图信息（从数据库查询）"""
   154|    row = database.get_layout(layout_id)
   155|    if not row:
   156|        raise HTTPException(status_code=404, detail="布局不存在")
   157|    
   158|    return {
   159|        "layout_id": row['layout_id'],
   160|        "filename": row['filename'],
   161|        "file_path": row['image_path'],
   162|        "file_size": row['file_size'],
   163|        "upload_time": row['upload_time'].isoformat() if row['upload_time'] else None,
   164|        "content_type": None
   165|    }
   166|
   167|# AI 服务配置
   168|import os
   169|AI_SERVICE_URL = os.getenv("AI_SERVICE_URL", "http://localhost:8001")
   170|
   171|class PlanRequest(BaseModel):
   172|    """生成方案请求模型"""
   173|    layout_id: str = Field(..., description="户型图 ID")
   174|    style: str = Field(..., description="装修风格（现代、北欧、中式等）")
   175|
   176|class MaterialItem(BaseModel):
   177|    """材料项"""
   178|    name: str
   179|    category: str
   180|    estimated_cost: float
   181|    unit: str = "平方米"
   182|    brand: Optional[str] = None
   183|    model: Optional[str] = None
   184|    room: Optional[str] = None
   185|    supplier: Optional[str] = None
   186|    purchase_url: Optional[str] = None
   187|
   188|class PlanResponse(BaseModel):
   189|    """生成方案响应"""
   190|    layout_id: str
   191|    style: str
   192|    plans: List[dict] = Field(..., description="3套方案")
   193|    materials: List[MaterialItem] = Field(..., description="材料清单")
   194|    message: str = "方案生成成功"
   195|
   196|@app.post("/api/v1/generate-plan")
   197|async def generate_plan(request: PlanRequest):
   198|    """
   199|    生成装修方案
   200|    
   201|    - **layout_id**: 户型图 ID
   202|    - **style**: 装修风格
   203|    
   204|    返回 3 套方案（含材料清单），调用 AI 服务生成效果图
   205|    """
   206|    # 从数据库验证 layout_id 是否存在
   207|    if not database.layout_exists(request.layout_id):
   208|        raise HTTPException(status_code=404, detail="户型图不存在")
   209|    
   210|    try:
   211|        # 调用 AI 服务获取方案图片
   212|        async with httpx.AsyncClient() as client:
   213|            # 获取 3 套方案
   214|            plans = []
   215|            room_types = ["living_room", "bedroom", "kitchen"]
   216|            
   217|            for i in range(3):
   218|                try:
   219|                    print(f"正在调用 AI 服务: {AI_SERVICE_URL}/inference, 参数: style={request.style}, layout_id={request.layout_id}, room_type={room_types[i]}")
   220|                    response = await client.post(
   221|                        f"{AI_SERVICE_URL}/inference",
   222|                        params={
   223|                            "style": request.style,
   224|                            "layout_id": request.layout_id,
   225|                            "room_type": room_types[i]
   226|                        },
   227|                        timeout=60.0
   228|                    )
   229|                    print(f"AI 服务响应: status={response.status_code}")
   230|                    
   231|                    if response.status_code == 200:
   232|                        data = response.json()
   233|                        if "image_base64" in data:
   234|                            plans.append({
   235|                                "plan_id": i + 1,
   236|                                "image_base64": data["image_base64"],
   237|                                "style": request.style,
   238|                                "view": room_types[i],
   239|                                "description": f"{request.style} - {room_types[i]}（Stability AI 生成）"
   240|                            })
   241|                        else:
   242|                            print(f"AI 服务返回数据缺少 image_base64: {data.keys()}")
   243|                    else:
   244|                        print(f"AI服务调用失败: {response.status_code} - {response.text}")
   245|                except Exception as e:
   246|                    print(f"调用 AI 服务时发生异常: {type(e).__name__}: {str(e)}")
   247|            
   248|            if not plans:
   249|                raise HTTPException(status_code=500, detail=f"AI服务未返回任何方案，请检查 AI 服务日志")
   250|            
   251|            # 生成真实材料清单（基于材料数据库）
   252|            materials = []
   253|            all_materials = materials_db.get('materials', [])
   254|            rooms_config = materials_db.get('rooms', {})
   255|            
   256|            # 定义房间类型列表（与生成图片的顺序一致）
   257|            room_types = ["living_room", "bedroom", "kitchen"]
   258|            
   259|            # 遍历每个房间，推荐材料
   260|            for room_type in room_types:
   261|                room_config = rooms_config.get(room_type)
   262|                if not room_config:
   263|                    continue
   264|                
   265|                room_name = room_config.get('name', room_type)
   266|                required_cats = room_config.get('required_materials', [])
   267|                areas = room_config.get('areas', {})
   268|                
   269|                for cat in required_cats:
   270|                    # 映射：将 required_materials 中的键映射到材料类别
   271|                    cat_map = {
   272|                        "floor": "地面",
   273|                        "wall": "墙面",
   274|                        "ceiling": "顶面",
   275|                        "kitchen": "厨房",
   276|                        "lighting": "灯具",
   277|                        "bathroom": "卫浴"
   278|                    }
   279|                    target_category = cat_map.get(cat, cat)
   280|                    
   281|                    # 从材料数据库中找到该类别的材料
   282|                    cat_materials = [m for m in all_materials if m.get('category') == target_category]
   283|                    # 进一步按风格筛选
   284|                    style_materials = [m for m in cat_materials if request.style in m.get('style_match', [])]
   285|                    if not style_materials:
   286|                        style_materials = cat_materials
   287|                    if not style_materials:
   288|                        continue
   289|                    
   290|                    # 每个类别选择前 2-3 个材料（根据数据库大小）
   291|                    max_items = min(3, len(style_materials))
   292|                    selected_materials = style_materials[:max_items]
   293|                    
   294|                    # 为每个选中的材料创建条目
   295|                    for material in selected_materials:
   296|                        # 计算用量：根据房间面积
   297|                        area = areas.get(cat, 0)
   298|                        quantity = area if area > 0 else 1
   299|                        subtotal = material['price_per_unit'] * quantity
   300|                        
   301|                        materials.append(MaterialItem(
   302|                            name=material['name'],
   303|                            category=material['category'],
   304|                            estimated_cost=round(subtotal, 2),
   305|                            unit=material['unit'],
   306|                            brand=material.get('brand'),
   307|                            model=material.get('model'),
   308|                            room=room_name,
   309|                            supplier=material.get('supplier'),
   310|                            purchase_url=material.get('purchase_url')
   311|                        ))
   312|            
   313|            # 如果没有推荐到材料，使用备用模拟数据
   314|            if not materials:
   315|                materials = [
   316|                    MaterialItem(name="复合地板", category="地面", estimated_cost=128.0, unit="平方米", brand="圣象", model="F-2001", room="客厅", supplier="圣象地板"),
   317|                    MaterialItem(name="乳胶漆", category="墙面", estimated_cost=45.0, unit="平方米", brand="多乐士", model="Dulux-A991", room="客厅", supplier="多乐士官方旗舰店"),
   318|                    MaterialItem(name="集成吊顶", category="顶面", estimated_cost=180.0, unit="平方米", brand="奥普", model="AUPU-6001", room="客厅", supplier="奥普集成吊顶"),
   319|                ]
   320|            
   321|            return PlanResponse(
   322|                layout_id=request.layout_id,
   323|                style=request.style,
   324|                plans=plans,
   325|                materials=materials,
   326|                message=f"成功生成 {len(plans)} 套方案"
   327|            )
   328|    except HTTPException:
   329|        raise
   330|    except Exception as e:
   331|        print(f"generate_plan 发生异常: {type(e).__name__}: {str(e)}")
   332|        import traceback
   333|        traceback.print_exc()
   334|        raise HTTPException(status_code=500, detail=f"AI 服务调用失败: {type(e).__name__}: {str(e)}")
   335|
   336|@app.get("/api/v1/materials")
   337|async def get_materials():
   338|    """返回可选材料列表"""
   339|    return {
   340|        "materials": [
   341|            {"id": 1, "name": "实木地板", "category": "地面", "price_per_unit": 200},
   342|            {"id": 2, "name": "复合地板", "category": "地面", "price_per_unit": 120},
   343|            {"id": 3, "name": "瓷砖", "category": "地面", "price_per_unit": 80},
   344|            {"id": 4, "name": "乳胶漆", "category": "墙面", "price_per_unit": 45},
   345|            {"id": 5, "name": "壁纸", "category": "墙面", "price_per_unit": 60},
   346|        ],
   347|        "message": "材料列表获取成功"
   348|    }
   349|
   350|
   351|if __name__ == "__main__":
   352|    import uvicorn
   353|    uvicorn.run(app, host="0.0.0.0", port=8000)
   354|