fix: 大样图生成偶发失败加固(前端草图压缩1536px+后端超时5xx自动重试)

This commit is contained in:
HHY
2026-10-07 00:59:40 -07:00
parent acf1586ccc
commit 5795bf5352
4 changed files with 66 additions and 37 deletions
+1
View File
@@ -1,5 +1,6 @@
# CHANGELOG
- [2026-10-07 08:00] FIX: v2.46 大样图生成失败加固(用户报告偶发失败)。诊断: 后端 API 复现两页手绘登记表均 38~43s 生成成功——失败为中转偶发超时/慢响应叠加手机原图未压缩(现场拍照 5~10MB, 测试图仅 194KB, base64 后请求体过大加剧超时); 三重加固: ①前端草图上传压缩至 ≤1536px JPEG(toDataURL 0.9, 复用照片合成模式同款口径, 治本) ②后端生图请求超时/5xx 自动重试一次(4xx 快速失败不重试), 请求段重构 _post_image() ③重试前 3s 等待; 实测第 53 页登记表原图 38s 成功(4 窗逐窗转换: 5860 大窗+700 上亮+730 标注/1680/1775/1760, 尺寸与手稿全一致, 开启扇符号保留); 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/api/ai_render.py, frontend/src/views/AIRenderView.vue)
- [2026-10-07 06:35] FEAT: v2.45 工程大样图支持上传手绘量尺登记表(用户核心场景)。blueprint 底图扩展: 上传手绘草图照片(现场量尺登记表)/2D 图纸截图二选一, 纯草图模式(无样板)不要求图纸数据; 提示词升级: 手绘登记表逐窗转换(保留各窗相对布局/分格/开启扇对角三角/手写尺寸规范誊写, 不保留表格框线, 模糊尺寸按可辨识程度誊写不编造); 前端蓝图 Tab 加草图上传(预览+移除, el-upload http-request 的 file 兼容 raw 包裹), 上传草图时脱离样板上下文(避免无关窗型特征误导); blueprint 尺寸固定 1536x1024 横版(量尺登记表整页比例, 对齐 autocad 口径); 真实实测: 用户手绘登记表(第52页 5 窗)78s 转规范大样图——分格/开启扇/尺寸(2780x2035/2810x2040/1850x2440/650/1500/700/1600/300)全保留规范誊写, 手写房间位置(27#南阳)识别保留, 表格框线去除, 标题栏/横版布局完整; 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/api/ai_render.py, frontend/src/views/AIRenderView.vue, doc/API.md)
- [2026-10-06 07:17] FEAT: v2.44 AI 生成工程大样图(整合自 D:\Codeutocad 项目草图标准化能力)。后端 /api/ai-render 新增 blueprint 模式: 2D 图纸截图(sketch_b64, WindoorCanvas 新增 exportImage() 画布导出)为底图 images/edits 重绘黑白 CAD 工程大样图; 大样图提示词移植 autocad 项目风格迁移资产(正投影立面/细黑线框/玻璃淡灰填充/尺寸线/中文工程标注/标题栏+不编造尺寸/仅供审图与报价附件约束)并叠加 describe_window 窗型分格特征增强保真; /prompt 端点支持 blueprint 预览; 前端 AI 炫图页新增「工程大样图」Tab(样板选择+2D 图纸底图+一键生成+结果下载); 真实生成实测 59~68s: 样板704 → 黑白大样图(分格 F1/A1/F2 与原图一致, 开启扇对角线+把手保留, 尺寸标注 600/1200/400/1200 与几何一致, 中文引出线标注+说明+标题栏图名/规格/比例/日期); 修复 AIRenderView.loadTemplate 不回写 templateId(程序化自动选样板后 template_id=undefined 报未找到图纸数据, 手动下拉路径 v-model 自动更新故从未暴露); 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/api/ai_render.py, frontend/src/views/AIRenderView.vue, frontend/src/components/WindoorCanvas.vue, doc/API.md)
- [2026-10-06 05:57] FEAT: v2.43 部位级材料选择——对应部位使用对应材料。估算器 estimate() 增加 material_overrides(部位级覆盖: profile 按 _profile_role 名称归位 frame/mullion/sash/pressline, 命中行替换名称/单价/米重; glass/hardware 整类替换)+overrides 回显; 新增 GET /api/costbook/series-materials(聚合全部系列用料按部位分组去重, 供跨系列混搭: frame 3 种/mullion 3/sash 2/pressline 6/glass 5/hardware 1); offer-price 新增 materials 参数透传; QuoteDialog 部位选材面板(外框型材/中梃型材/扇框型材/玻璃配置/五金套件 五下拉, 选项带价格与系列来源, 改选即重算 offer-price 实时刷新系列成本); 端到端实测: 样板704+系列75, 外框改选 95 系列 33框(25.88元/kg) → 系列成本 1251.10→1303.57 实时重算(手算吻合), 报价总额独立对比; 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/services/costbook_service.py, src/huamenchuang/api/quote.py, src/huamenchuang/api/costbook.py, frontend/src/components/QuoteDialog.vue, doc/API.md)
+13 -2
View File
@@ -308,8 +308,19 @@ function onSketchUpload(e: { file: unknown }) {
if (!(file instanceof File)) return
const reader = new FileReader()
reader.onload = () => {
sketchPhoto.value = String(reader.result)
ElMessage.success('草图已上传')
// 手机原图过大(5~10MB)会导致生图中转超时/拒绝, 统一压至 ≤1536px JPEG
const img = new Image()
img.onload = () => {
const scale = Math.min(1, 1536 / Math.max(img.naturalWidth, img.naturalHeight))
const cv = document.createElement('canvas')
cv.width = Math.max(1, Math.round(img.naturalWidth * scale))
cv.height = Math.max(1, Math.round(img.naturalHeight * scale))
cv.getContext('2d')!.drawImage(img, 0, 0, cv.width, cv.height)
sketchPhoto.value = cv.toDataURL('image/jpeg', 0.9)
ElMessage.success('草图已上传')
}
img.onerror = () => ElMessage.error('图片读取失败,请重试')
img.src = String(reader.result)
}
reader.readAsDataURL(file)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+52 -35
View File
@@ -124,41 +124,58 @@ async def generate(body: AiRenderBody, user: CurrentUser, db: DbSession):
base = settings.ai_base_url.rstrip("/")
headers = {"Authorization": f"Bearer {settings.ai_api_key}"}
timeout = httpx.Timeout(settings.ai_timeout, connect=15)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
if body.mode == "blueprint":
img_bytes = base64.b64decode(body.sketch_b64.split(",")[-1])
mime = "image/jpeg" if img_bytes[:2] == b"\xff\xd8" else "image/png"
ext = "jpg" if mime == "image/jpeg" else "png"
resp = await client.post(
f"{base}/v1/images/edits", headers=headers,
data={"model": settings.ai_model, "prompt": prompt, "size": size, "n": "1"},
files={"image[]": (f"drawing.{ext}", img_bytes, mime)})
elif body.mode == "photo":
img_bytes = base64.b64decode(body.photo_b64.split(",")[-1])
mime = "image/jpeg" if img_bytes[:2] == b"\xff\xd8" else "image/png"
ext = "jpg" if mime == "image/jpeg" else "png"
resp = await client.post(
f"{base}/v1/images/edits", headers=headers,
data={"model": settings.ai_model, "prompt": prompt, "size": size, "n": "1"},
files={"image[]": (f"photo.{ext}", img_bytes, mime)})
else:
resp = await client.post(
f"{base}/v1/images/generations", headers=headers,
json={"model": settings.ai_model, "prompt": prompt, "n": 1, "size": size})
resp.raise_for_status()
data = resp.json()
item = (data.get("data") or [{}])[0]
b64 = item.get("b64_json")
if not b64:
return fail(f"生图服务返回异常: {str(data)[:200]}")
except httpx.TimeoutException:
return fail("AI 生图超时,请稍后重试")
except httpx.HTTPStatusError as e:
detail = e.response.text[:200] if e.response is not None else ""
return fail(f"生图服务错误({e.response.status_code if e.response else '?'}): {detail}")
except Exception as e:
return fail(f"生图失败: {str(e)[:150]}")
async def _post_image(client: httpx.AsyncClient) -> httpx.Response:
if body.mode == "blueprint":
img_bytes = base64.b64decode(body.sketch_b64.split(",")[-1])
mime = "image/jpeg" if img_bytes[:2] == b"\xff\xd8" else "image/png"
ext = "jpg" if mime == "image/jpeg" else "png"
return await client.post(
f"{base}/v1/images/edits", headers=headers,
data={"model": settings.ai_model, "prompt": prompt, "size": size, "n": "1"},
files={"image[]": (f"drawing.{ext}", img_bytes, mime)})
if body.mode == "photo":
img_bytes = base64.b64decode(body.photo_b64.split(",")[-1])
mime = "image/jpeg" if img_bytes[:2] == b"\xff\xd8" else "image/png"
ext = "jpg" if mime == "image/jpeg" else "png"
return await client.post(
f"{base}/v1/images/edits", headers=headers,
data={"model": settings.ai_model, "prompt": prompt, "size": size, "n": "1"},
files={"image[]": (f"photo.{ext}", img_bytes, mime)})
return await client.post(
f"{base}/v1/images/generations", headers=headers,
json={"model": settings.ai_model, "prompt": prompt, "n": 1, "size": size})
b64 = None
last_err = ""
# 中转偶发超时/5xx 自动重试一次(生图耗时长, 失败重试显著提升成功率)
for attempt in range(2):
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await _post_image(client)
resp.raise_for_status()
data = resp.json()
item = (data.get("data") or [{}])[0]
b64 = item.get("b64_json")
if b64:
break
last_err = f"生图服务返回异常: {str(data)[:200]}"
except httpx.TimeoutException:
last_err = "AI 生图超时,请稍后重试"
except httpx.HTTPStatusError as e:
detail = e.response.text[:200] if e.response is not None else ""
last_err = f"生图服务错误({e.response.status_code if e.response else '?'}): {detail}"
# 4xx(如鉴权/参数)重试无意义
if e.response is not None and e.response.status_code < 500:
break
except Exception as e: # noqa: BLE001
last_err = f"生图失败: {str(e)[:150]}"
if attempt == 0 and b64 is None:
stage_wait = 3 # 重试前短暂等待
import asyncio
await asyncio.sleep(stage_wait)
if not b64:
return fail(last_err or "生成失败")
return ok(data={"image_b64": b64, "prompt": prompt, "size": size,
"features": {"width_mm": info["width_mm"], "height_mm": info["height_mm"],
"summary": info["summary"]}},