从被 Coze 坑到每天服务上千请求:AI 香水工坊 30 天上线实录

0 阅读22分钟

一个项目,三次重构,十个漏洞,两套部署方案,三十天上线实录


前言:为什么要写这篇博客

2026 年的夏天,我决定做一个 AI 香水概念生成器。用户输入一段文字描述,AI 就能生成香氛概念图和多角度展示图。听起来简单,做起来才发现——从 Coze 工作流设计、前后端状态机、安全加固到生产部署,每一步都有数不清的坑。

这篇文章,就是我从零到一搭建这个项目的完整记录。它不是一个“Hello World”教程,而是一个真实项目的演进史:从“一个巨无霸工作流”到“三步独立工作流 + 前端状态机”,从“能跑就行”到“生产就绪”。

我会把所有的坑、所有的修复、所有的决策过程都写出来。如果这篇文章能帮你少踩一个坑,我就没白写。

适合读者

  • 正在用 Coze 工作流做 AI 应用的后端/全栈开发者
  • 想了解“工作流驱动”架构设计的前端工程师
  • 即将上线但心里没底的技术负责人

阅读建议

  • 时间紧 → 看完“5分钟速览”即可抓住主干
  • 时间充裕 → 从头读到尾,细节都在
  • 遇到具体问题 → 直接跳到对应章节或附录

🎨 先看效果

用户输入一段文字描述,AI 生成如下结果: 在这里插入图片描述


🚀 5 分钟速览

最终架构(一张图看懂)

在这里插入图片描述

核心结论(记住这 3 条就够了)

#结论为什么重要
1拆三步,不要一个巨无霸工作流用户能“确认→修改→重新生成”,而不是全流程重跑,费用浪费 70%
2预览图工作流必须返回 prompt 字段第三步多角度生成需要文本提示词来保持风格一致性
3CORS 必须配白名单,不能用 origin: true否则任何网站都能盗用你的 API,用户数据毫无安全可言

技术栈速览

技术说明
前端原生 JS + Tailwind CSS零构建,极致轻量,首屏加载 < 200ms
后端Node.js + Express 5ESM 模块,异步非阻塞
AI 引擎Coze Workflow × 3三步独立,可单独调试/重试
数据库SQLite(本地)/ Supabase(生产)双适配层,一键切换
部署阿里云 ECS + Nginx主方案,稳定运行 30 天

第一章:起点——一个“巨无霸”工作流的诞生与崩溃

1.1 最初的设想

项目刚启动时,我的想法很简单:用户输入一段文字 → 调用一个 Coze 工作流 → 返回概念图和文档。

一个工作流,串起所有节点:

用户输入(文字描述)
    ↓
LLM 节点:解析设计规范(香调、氛围、视觉风格、颜色方案)
    ↓
图像生成节点:生成概念预览图
    ↓
LLM 节点:生成香氛文案(故事、描述)
    ↓
图像生成节点:生成多角度展示图
    ↓
输出:预览图 + 多角度图 + 文案

看起来完美。一个工作流搞定所有事,前端只需要调一个 API。

我花了两天时间在 Coze 平台上把这个工作流搭出来,测试了几次,效果不错。于是信心满满地发布了内测版本。

1.2 问题开始暴露

内测第一天,第一个用户反馈来了:

“预览图不错,但瓶身颜色太深了,能不能浅一点?”

我心想,改一下输入重新生成不就行了吗。但用户下一句话让我清醒了:

“可是我觉得规范解析得挺对的,我不想重新解析,只改颜色。”

这就是单体工作流的第一个致命问题:耦合太紧,牵一发而动全身

修改一个参数,整个流程都要重跑:

步骤操作费用消耗
1重新解析规范1 次 LLM 调用
2重新生成预览图1 次生图费用
3重新生成文案1 次 LLM 调用
4重新生成多角度图1 次生图费用

用户只想改颜色,结果所有步骤重来一遍,浪费了 70% 的计算资源和费用

更糟的是,AI 重新解析规范后,可能把其他参数也改了。用户只想改颜色,结果风格也变了、香调也变了。用户的 frustration 可想而知。

1.3 更深层的问题:无法实现“用户确认”

工作流是“一次性执行”的。一旦启动,就会跑到结束。

但用户交互的本质是“生成 → 确认 → 不满意 → 修改 → 重新生成”的循环。单体工作流根本做不到。

用户需要一个“看到预览图后决定是否满意”的环节。不满意要能提修改意见,然后只重新生成预览图,而不是重跑全流程。

Coze 工作流本身没有“暂停等用户输入”的机制,所以我必须把“执行”和“决策”分开——工作流负责执行,前端负责决策。

这意味着:必须拆


第二章:拆分——从“巨无霸”到“三步工作流”

2.0 先理清:用户态与工作流态的边界

在讲拆分之前,我必须先明确一个设计决策:多用户并发下,任务怎么隔离?

维度用户态(前端)工作流态(后端 + Coze)
数据存储tasksMap(内存)+ localStoragetasks 表(SQLite / Supabase)
状态管理前端渲染(弹窗/进度条/卡片)工作流执行状态(queued/processing/done)
隔离方式通过 user_id 过滤每个任务关联 user_id,所有 CRUD 带用户过滤

关键实现

  1. 每个任务绑定 user_id:所有数据库操作都带 user_id 过滤,杜绝数据串扰
  2. tasksMap 按用户隔离:前端登录后,只拉取当前用户的 tasks 列表
  3. SQLite 并发写入:使用 PRAGMA journal_mode = WAL + busy_timeout = 5000,实测 50 并发下表现稳定

💡 为什么选择 SQLite + WAL 模式?SQLite 的 WAL(Write-Ahead Logging)模式支持读写并发(多个读 + 一个写),且锁粒度是页级而不是表级。在日均 1000 请求的小型应用中绰绰有余。

每个任务绑定 user_id,所有 CRUD 操作都带 user_id 过滤” 在这里插入图片描述

2.1 拆分的决策

我花了三天时间重新设计了整个架构。核心原则只有一个:每一步的输出,是下一步的输入。每一步都可以独立运行、独立调试、独立重试。

拆成三个工作流:

工作流 ID名称输入输出对应步骤
WF_SPEC规范生成用户描述设计规范 JSON第一步
WF_PREVIEW预览图生成规范 + 参考图预览图 URL + 提示词第二步
WF_MULTI多角度生成预览图 URL + 提示词多角度图 URL第三步

2.2 三次重构的演进路径

版本架构问题触发事件
v1单体工作流修改一个参数必须重跑全流程用户反馈“只想改瓶身颜色”
v2三步独立工作流用户无法在预览阶段提修改意见产品评审:需要“确认→修改→重新生成”
v3前端状态机 + 弹窗系统多用户并发下状态混乱内测时两个用户同时操作导致数据错乱
v4生产加固 + 安全审查安全扫描发现 10 个漏洞上线前安全审查

本文以 v2 → v3 → v4 的演进为主线。v1 的教训在第一章已经交代。

2.3 三个工作流的设计细节

WF_SPEC:规范生成

这是最简单的,只有一个 LLM 节点:

输入:
  - input: string  # 用户描述

LLM 节点:
  prompt: |
    请从以下描述中提取香水设计规范,输出 JSON 格式:
    {input}

    {
      "香调": ["木质", "花香", ...],
      "氛围": ["静谧", "神秘", ...],
      "视觉风格": "极简奢华",
      "颜色方案": ["#2d1b12", "#c4a882"],
      "瓶身描述": "方肩玻璃瓶,金色瓶盖",
      "场景": "黄昏书房"
    }

输出:
  - spec: JSON

WF_PREVIEW:预览图生成

这个工作流接收三个参数。其中 detail 是支持“修改意见→重新生成”循环的关键:

输入:
  - input: string     # 设计规范 JSON 字符串
  - image: string     # 参考图 file_id(可选)
  - detail: string    # 修改意见(可选)

LLM 节点 1(整合提示词):
  prompt: |
    设计规范:{input}
    用户修改意见:{detail}
    请基于以上信息,生成详细的生图提示词。

图像生成节点:
  prompt: {LLM 节点 1 的输出}
  reference_image: {image}

LLM 节点 2(提取提示词):
  prompt: |
    从生图结果中提取实际使用的提示词。

输出:
  - image_url: string   # 预览图 URL
  - prompt: string      # 生图提示词(传给第三步)
  - file_id: string     # 预览图的 file_id

WF_MULTI:多角度生成

第三步接收预览图 URL 和提示词,生成多角度图:

输入:
  - reference_image_url: string   # 预览图 URL
  - reference_prompt: string      # 预览图提示词
  - input: string                 # 额外描述(可选)

LLM 节点:
  prompt: |
    基于参考提示词"{reference_prompt}",生成多角度展示的描述。
    包含:正面、45度侧面、俯视图。

图像生成节点:
  prompt: {LLM 节点的输出}
  reference_image: {reference_image_url}

输出:
  - image_url: string   # 多角度图 URL

2.4 核心设计:prompt 字段为什么如此重要

这是整个设计中容易被忽视但至关重要的一个细节。

第三步需要“基于预览图生成多角度图”,但图像生成节点需要的是文本提示词,而不是图片本身。所以第二步必须返回两个东西:

  1. image_url(预览图 URL,作为视觉参考)
  2. prompt(生成预览图的提示词,作为文本参考)
    WF_PREVIEW 返回:
    {
        image_url: "https://coze.cn/.../preview.png",
        prompt: "A luxury perfume bottle with amber liquid, gold cap, set against a moody dark background...",
        file_id: "file_xxx"
    }
                     ↓
           前端存储到 taskEntry:
           task.previewUrl = image_url
           task.previewPrompt = prompt  ← 关键!
                     ↓
    WF_MULTI 接收:
    {
        reference_image_url: task.previewUrl,
        reference_prompt: task.previewPrompt   ← 关键!
    }

如果少了 previewPrompt,第三步只能基于图片生成,效果会大打折扣。有了提示词,才能保证多角度图在风格上与原图保持一致。


第三章:联调噩梦——Node.js + Coze API 的那些坑

拆分工作流只是第一步。真正的挑战在联调阶段。

3.1 坑一:返回的 JSON 格式不稳定

WF_SPEC 有时候返回 {"spec": {...}},有时候直接返回 {...},有时候返回一个字符串,有时候返回嵌套了 3 层的对象。

解决方案:三层解析兜底。

层级尝试解析的位置优先级
1data(自动解析 JSON)最高
2data.output(字符串再解析)次高
3正则提取 {...} 片段兜底

完整实现见 附录 A:工具函数汇总

3.2 坑二:图片 URL 不一定在 image_url 字段

WF_PREVIEW 返回的图片 URL 有时候在 image_url,有时候在 data.image_url,有时候在 output 的 Markdown 里。

解决方案:写通用提取函数,同时匹配 Markdown 语法和纯 URL。

// 核心思路:匹配两种模式
// 1. Markdown: ![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/7c704b42478849dfaa14747552e73e0d~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5pmL6Zmi5oKN5Yyq5rS-5aSn5pif:q75.awebp?rk3s=f64ab15b&x-expires=1789178279&x-signature=GS85%2FmfXPGzkAsQSoNFEnhGyVpI%3D)
// 2. 纯 URL: https://...png

完整实现见 附录 A:工具函数汇总

3.3 坑三:image 参数传的是 file_id,不是 URL

这是最容易混淆的地方。

Coze 的 image 参数需要的是 file_id(上传到 Coze 空间后返回的 ID),不是 URL。

// ❌ 错误
const parameters = {
    input: specStr,
    image: "https://example.com/ref.jpg"  // 不行!
};

// ✅ 正确
const parameters = {
    input: specStr,
    image: "file_abc123"  // 必须用 file_id
};

所以我需要先调用上传接口获取 file_id,再传给工作流:

前端上传图片 → 服务端转发 Coze → 返回 file_id → 传给 WF_PREVIEW

3.4 坑四:第三步只需要 URL,不需要 file_id

WF_MULTI 接收的是 reference_image_url(在线图片 URL),不是 file_id。

所以第二步返回的 image_url 直接传给第三步,不需要再上传一次。

参数第二步(WF_PREVIEW)第三步(WF_MULTI)
参考图image: file_idreference_image_url: URL

3.5 坑五:工作流超时

Coze 工作流有执行时间限制。WF_MULTI 生成多角度图耗时较长,偶尔超时。

解决方案

  1. 服务端增加超时控制(30 秒)
  2. 前端增加友好提示:“生成超时,请稍后重试 ⏰”
  3. 前端增加“重新生成”按钮,不丢失已有上下文

第四章:安全审查——10 个漏洞的修复实录

功能跑通了,联调也过了,我准备上线。

但直觉告诉我——再检查一遍代码。这一检查,让我后背发凉。

4.1 漏洞全景图

编号严重程度漏洞类型影响范围
P0-1🔴 严重静态文件暴露根目录所有环境变量、源代码
P0-2🔴 严重CORS 允许任意域名任意网站可盗用 API
P0-3🔴 严重前端 XSS——动态渲染未转义用户账户被窃取
P1-4🟠 高危限流 Map 内存泄漏服务 OOM 崩溃
P1-5🟠 高危文件上传只有前端校验恶意文件上传
P1-6🟠 高危缺少 Helmet 安全头点击劫持/MIME 嗅探
P1-7🟠 高危前后端上传大小不一致用户体验差
P2-8🟡 中危SQLite 没有 busy_timeout并发写入死锁
P2-9🟡 中危没有优雅关机请求被强制中断
P2-10🟡 中危环境变量缺失时静默启动排查困难

4.2 P0-1:静态文件暴露了整个项目根目录

// ❌ 整个项目根目录暴露
app.use(express.static('.'));

攻击者访问 /.env 就能看到数据库密码和 API 密钥。

修复

// ✅ 只暴露前端目录
app.use(express.static(path.join(__dirname, 'frontend')));

同时移动所有前端资源到 frontend/ 目录,并在 _routes.json 中明确排除敏感文件。

4.3 P0-2:CORS 允许任意域名跨域

// ❌ 任何域名都能调用 API
app.use(cors({
    origin: (origin, cb) => cb(null, true),
    credentials: true,
}));

修复:白名单模式,从环境变量读取允许的域名。

// ✅ 白名单模式
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
    .split(',')
    .map(s => s.trim())
    .filter(Boolean);

app.use(cors({
    origin: function(origin, callback) {
        if (!origin) return callback(null, true);
        if (allowedOrigins.indexOf(origin) !== -1) {
            callback(null, true);
        } else {
            callback(new Error('不允许的跨域来源'));
        }
    },
    credentials: true,
}));

环境变量配置:

ALLOWED_ORIGINS=https://your-domain.com,https://your-project.edgeone.app

4.4 P0-3:前端 XSS——所有动态渲染都没转义

// ❌ 用户输入直接拼接到 HTML
const cardHtml = `
    <div class="task-card">
        <span>${task.status}</span>
        <div>${task.error}</div>  <!-- 如果 error 包含 <script>... -->
    </div>
`;

修复:全面转义。

// ✅ 通用转义函数
function escapeHtml(s) {
    return String(s).replace(/[&<>"']/g, (c) => ({
        '&': '&', '<': '<', '>': '>', '"': '"', "'": '&#39;'
    }[c]));
}

我检查了所有使用 innerHTMLinsertAdjacentHTML 的地方,确保每个动态变量都被转义。

4.5 P1-4:限流 Map 导致内存泄漏

// ❌ 只添加不删除,Map 无限增长
const rateLimitMap = new Map();

修复:每 5 分钟清理一次过期记录。

// ✅ 定期清理过期 IP
setInterval(() => {
    const now = Date.now();
    const windowMs = CONFIG.RATE_LIMIT.windowMs;
    let cleared = 0;

    for (const [ip, timestamps] of rateLimitMap.entries()) {
        const valid = timestamps.filter(t => now - t < windowMs);
        if (valid.length === 0) {
            rateLimitMap.delete(ip);
            cleared++;
        } else {
            rateLimitMap.set(ip, valid);
        }
    }

    if (cleared > 0) {
        logger.info({ cleared }, '已清理过期 IP 限流记录');
    }
}, 5 * 60 * 1000);

4.6 P1-5:文件上传只有前端校验

前端限制了大小和类型,但服务端直接信任了前端数据。攻击者可以伪造请求绕过前端限制。

修复:服务端双重校验。

Multer 中间件校验(MIME 类型 + 文件大小)
    ↓
file-type 库校验(文件头魔数,防止伪造)
    ↓
通过 → 存储

4.7 P1-6:缺少 Helmet 安全头

响应头缺少 X-Content-Type-OptionsX-Frame-Options 等关键安全头。

修复

import helmet from 'helmet';

app.use(helmet({
    contentSecurityPolicy: false,  // 内联脚本较多,暂时禁用
}));

4.8 P1-7:前端上传大小和后端不一致

前端限制 10MB,后端限制 5MB。用户上传 6MB 的图片通过前端但被后端拦截,体验极差。

修复:统一为 5MB。

// 前端
if (file.size > 5 * 1024 * 1024) {
    showErrorInline('参考图不能超过 5MB');
    return;
}

// 后端(multer 配置)
limits: { fileSize: 5 * 1024 * 1024 }

4.9 P2-8:SQLite 并发写入没有超时

// ❌ 没有超时配置
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');

修复

// ✅ 增加 busy_timeout
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');  // 等待 5 秒后超时

4.10 P2-9:没有优雅关机

直接 Ctrl+C 会杀死进程,正在处理的请求会中断。

修复:实现优雅关机,收到 SIGTERM/SIGINT 后等待现有请求完成(最长 10 秒),再退出。

完整实现见 附录 A:工具函数汇总

4.11 P2-10:环境变量缺失时静默启动

如果 .env 里漏了配置,服务照常启动,但调用 Coze 时报错,排查很痛苦。

修复:启动时强制校验关键变量。

function validateEnv() {
    const required = ['KM_COZE_TOKEN', 'WF_SPEC_ID', 'WF_PREVIEW_ID', 'WF_MULTI_ID'];
    const missing = required.filter(key => !process.env[key] || process.env[key].trim() === '');

    if (missing.length > 0) {
        console.error('\n❌ 缺少必需的环境变量:');
        missing.forEach(key => console.error(`   - ${key}`));
        console.error('\n请在 .env 文件中设置以上变量后重新启动。\n');
        process.exit(1);
    }
}

经过修复后,现在的请求是这样被安全处理的 在这里插入图片描述


第五章:前端状态机——三步交互的设计与实现

5.1 任务状态定义

const STATUS = {
    spec_confirming: '规范确认中',
    spec_ready: '规范已确认',
    generating_preview: '生成预览图中',
    preview_ready: '预览图已就绪',
    generating_multi: '生成多角度中',
    multi_ready: '多角度已就绪',
    completed: '已完成',
    failed: '失败',
    no_response: '无应答(用户超时)'
};

5.2 状态流转图

spec_confirming
    ↓(用户确认 spec)
spec_ready
    ↓(自动调用 WF_PREVIEW)
generating_preview
    ↓(WF_PREVIEW 成功)
preview_ready  ← 核心交互点
    ↓(用户点击"满意")      ↓(用户点击"不满意")
generating_multi          填写修改意见 → 回到 generating_preview
    ↓(WF_MULTI 成功)
multi_ready
    ↓(用户点击"满意,完成")
completed

回退路径

preview_ready
    ↓(用户点击"返回上一步")
spec_ready(清空 previewUrl / previewPrompt)

multi_ready
    ↓(用户点击"返回上一步")
preview_ready(清空 multiUrl,保留 previewUrl / previewPrompt)

前端复杂逻辑的可视化缩影 在这里插入图片描述

5.3 三个弹窗的交互设计

下面是三个核心交互弹窗的 UI 示意:

Modal 1:规范确认弹窗

在这里插入图片描述

用户在这里可以:

  • 查看 AI 从描述中提取的设计规范
  • 直接编辑任意字段(想改就改)
  • 上传/更换参考图(在弹窗内补传)
  • 点击“直接确认”进入下一步

Modal 2:预览图确认弹窗

在这里插入图片描述

用户在这里可以:

  • 看到生成的预览图
  • 点击“满意”进入多角度生成
  • 点击“不满意”→ 展开修改意见输入框 → 提交后重新生成预览图
  • 点击“返回上一步”回退到规范确认

“不满意→修改意见→重新生成”的闭环

这是整个交互设计中最核心的闭环。用户不需要重新输入描述,只需要说“哪里不满意”,AI 就只重新生成预览图。

Modal 3:多角度确认弹窗

用户在这里可以:

  • 看到生成的多角度图
  • 点击“满意,完成”结束流程
  • 点击“不满意,重新生成”重新调用 WF_MULTI
  • 点击“返回上一步”回退到预览图确认

5.4 核心 JS 逻辑

状态管理

const tasksMap = new Map();  // 内存中的任务状态
let selectedTaskId = null;

// 持久化到 localStorage(刷新页面不丢失)
function saveTasksToStorage() {
    const arr = Array.from(tasksMap.entries()).map(([id, task]) => {
        return [id, { 
            taskId, prompt, spec, previewUrl, previewPrompt, 
            previewFileId, multiUrl, status, createdAt, 
            statusChangedAt, refFileId, error 
        }];
    });
    localStorage.setItem('pcs_tasks_map', JSON.stringify(arr));
}

// 多标签页同步
window.addEventListener('storage', (e) => {
    if (e.key === 'pcs_tasks_map') {
        loadTasksFromStorage();
        renderTaskProgress();
    }
});

并发执行 StepSpec + 图片上传

window.confirmGenerate = function() {
    // ...创建任务...

    const refFileInput = document.getElementById('refFile');
    const hasPendingFile = refFileInput && refFileInput.files && refFileInput.files[0];

    let uploadPromise = Promise.resolve(null);
    if (hasPendingFile) {
        const file = refFileInput.files[0];
        uploadPromise = uploadRefImageAndGetId(file).catch(err => {
            console.warn('图片上传失败:', err.message);
            return null;  // 不阻塞主流程
        });
    }

    // 并发执行:规范生成 + 图片上传
    const [specResult, uploadedFileId] = await Promise.all([
        stepSpec(taskId, inputValue),
        uploadPromise
    ]);

    if (uploadedFileId) {
        taskEntry.refFileId = uploadedFileId;
    }

    window.openSpecModal(taskId, specResult.spec);
};

预览图循环逻辑

// 点击"不满意" → 展开修改意见输入区
document.addEventListener('click', function(e) {
    const btn = e.target.closest('#previewRetryBtn');
    if (!btn) return;

    document.getElementById('previewDetailSection').classList.remove('hidden');
    document.getElementById('previewSubmitDetailBtn').classList.remove('hidden');
    btn.classList.add('hidden');
    document.getElementById('previewSatisfiedBtn').disabled = true;
    document.getElementById('previewDetailInput').focus();
});

// 提交修改意见 → 重新生成预览图
document.addEventListener('click', function(e) {
    const btn = e.target.closest('#previewSubmitDetailBtn');
    if (!btn) return;

    const taskId = window._previewTaskId;
    const detail = document.getElementById('previewDetailInput').value;

    // 显示加载状态
    document.getElementById('previewRegenLoading').classList.remove('hidden');
    btn.disabled = true;

    window.regeneratePreviewWithDetail(taskId, detail).then(result => {
        if (result.success) {
            // 重置状态:隐藏修改区,恢复按钮
            document.getElementById('previewDetailSection').classList.add('hidden');
            document.getElementById('previewSubmitDetailBtn').classList.add('hidden');
            document.getElementById('previewSatisfiedBtn').disabled = false;
            document.getElementById('previewRetryBtn').classList.remove('hidden');
        }
    });
});

超时清理

function checkStaleTasks() {
    const STALE_TIMEOUT_MS = 10 * 60 * 1000;  // 10 分钟
    const waitingStates = ['spec_confirming', 'preview_ready', 'multi_ready'];
    const terminalStates = ['completed', 'failed', 'no_response'];
    const now = Date.now();

    for (const [id, task] of tasksMap.entries()) {
        if (terminalStates.includes(task.status)) continue;
        if (!waitingStates.includes(task.status)) continue;

        const lastChange = task.statusChangedAt || task.createdAt || 0;
        if (now - lastChange > STALE_TIMEOUT_MS) {
            // 标记为超时
            task.status = 'no_response';
            task.error = '等待用户响应超时';
            task.statusChangedAt = now;

            // 关闭所有弹窗
            closeAllModals();
            renderTaskProgress();
            saveTasksToStorage();

            // 通知后端取消任务
            fetch(`/api/tasks/${id}`, {
                method: 'PUT',
                headers: { 'Authorization': `Bearer ${getAuthToken()}` },
                body: JSON.stringify({ status: 'cancelled', error: '无应答超时' })
            });
        }
    }
}

第六章:部署上线——阿里云 + EdgeOne 双方案实战

6.1 部署方案选择

我最终选择了两个部署方案:

对比维度阿里云 ECS(主)EdgeOne Pages(备)
架构传统服务器无服务器(Serverless)
冷启动❌ 无(常驻进程)✅ 约 200ms
费用约 100 元/月免费额度 10 万请求/月
数据库SQLite(本地文件)Supabase(托管 PostgreSQL)
适用场景稳定生产环境高可用灾备 / 流量突增

决策依据:主方案用 ECS 保证稳定性,备用方案用 EdgeOne 应对流量高峰。两套方案的 db.js 做了适配层,切换只需改环境变量。

6.2 阿里云 ECS 部署

目录结构

/root/parfum-studio/
├── index.js              # 入口
├── db.js                 # SQLite 适配层
├── db-cli.js             # 数据库命令行工具
├── package.json
├── data.db               # SQLite 数据库
├── frontend/             # 前端静态资源
│   ├── index.html
│   ├── studio.html
│   ├── script.js
│   └── ...
├── src/                  # 后端源码
│   ├── config.js
│   ├── routes/
│   └── ...
└── .env

Nginx 配置/etc/nginx/sites-available/parfum-studio):

server {
    listen 80;
    server_name your-domain.com;

    # 静态资源由 Nginx 直接返回
    location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?|ttf|eot)$ {
        root /root/parfum-studio/frontend;
        expires 7d;
        add_header Cache-Control "public, immutable";
    }

    # API 请求转发到 Node.js
    location /api/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # SSE 支持(流式响应)
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 86400s;
    }

    # 其他请求转发到 Node.js
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

安全组配置(阿里云控制台):

端口公网说明
22❌ 关闭(或仅限公司 IP)SSH
80✅ 开放HTTP
443✅ 开放(配 SSL)HTTPS
3000❌ 关闭Node.js(Nginx 内网转发)

PM2 进程管理

pm2 start index.js --name parfum-studio
pm2 save
pm2 startup  # 开机自启

安全组开放 80/443,内网安全隔离在这里插入图片描述

6.3 EdgeOne Pages 部署

EdgeOne Pages 是无服务环境,不支持 SQLite,需要使用 Supabase。

Supabase 建表 SQL(在 Supabase SQL Editor 执行):

-- 用户表
CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    designer_id TEXT UNIQUE NOT NULL,
    password_hash TEXT NOT NULL,
    email TEXT,
    display_name TEXT,
    is_admin INTEGER NOT NULL DEFAULT 0,
    is_disabled INTEGER NOT NULL DEFAULT 0,
    gen_count INTEGER NOT NULL DEFAULT 0,
    last_gen_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- 会话表
CREATE TABLE IF NOT EXISTS sessions (
    token TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    expires_at TIMESTAMPTZ NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- 历史记录表
CREATE TABLE IF NOT EXISTS history (
    id TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    prompt TEXT, title TEXT, thumbnail TEXT,
    image_urls TEXT, doc_id TEXT, status TEXT, options TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- 任务表
CREATE TABLE IF NOT EXISTS tasks (
    id TEXT PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'queued',
    params TEXT NOT NULL DEFAULT '{}',
    result TEXT DEFAULT '{}',
    conversation TEXT DEFAULT '{}',
    error TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- 索引
CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
CREATE INDEX idx_history_user ON history(user_id);
CREATE INDEX idx_history_created ON history(created_at DESC);
CREATE INDEX idx_tasks_user ON tasks(user_id);
CREATE INDEX idx_tasks_status ON tasks(status);

两套 db.js 的适配

本地版(SQLite):

import Database from 'better-sqlite3';
const db = new Database(DB_PATH);
export function getUserByDesignerId(designer_id) {
    return db.prepare('SELECT * FROM users WHERE designer_id = ?').get(designer_id);
}

EdgeOne 版(Supabase):

import { createClient } from '@supabase/supabase-js';
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
export async function getUserByDesignerId(designer_id) {
    const { data } = await supabase
        .from('users')
        .select('*')
        .eq('designer_id', designer_id)
        .maybeSingle();
    return data;
}

6.4 环境变量配置

# ===== 必需 =====
KM_COZE_TOKEN=pat_xxxxxxxxxxxxxxxx
WF_SPEC_ID=7679265032790327336
WF_PREVIEW_ID=7679271460304764991
WF_MULTI_ID=7679274717424959522

# ===== 可选 =====
PORT=3000
NODE_ENV=production
ALLOWED_ORIGINS=https://your-domain.com
ADMIN_DESIGNER_ID=admin
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX=30

# ===== EdgeOne 部署需要 =====
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=your-anon-key

第七章:踩坑实录——那些没写在文档里的坑

7.1 Coze 工作流返回的 JSON 格式不稳定

WF_SPEC 有时候返回字符串,有时候返回对象,有时候返回嵌套对象。

解决:写三层解析兜底(见附录 A)。

7.2 图片 URL 的字段名不一致

WF_PREVIEW 的返回中,图片 URL 有时在 image_url,有时在 data.image_url

解决:写通用的 extractImageUrls 函数(见附录 A)。

7.3 image 参数需要 file_id 不是 URL

WF_PREVIEW 的 image 参数需要的是上传到 Coze 空间后返回的 file_id。

解决:先调用 /api/upload/image 获取 file_id 再传参。

7.4 第三步不需要 prompt_text,只需要 reference_prompt

最开始我试图把用户原始描述传给第三步,但发现完全没用。

解决:移除 prompt_text,只用 reference_promptreference_image_url

7.5 前端上传大小和后端不一致

前端限制 10MB,后端限制 5MB。

解决:统一为 5MB。

7.6 用户上传图片后,工作流并发执行但图片还没传完

stepSpec 和图片上传并发执行,但图片上传可能比 stepSpec 慢。

解决:使用 Promise.all 等待两者都完成,再打开弹窗。

7.7 图片上传失败后,用户无法补传

最初的设计中,图片上传失败了用户只能取消重来。

解决:在 specModal 中增加图片上传区域,支持补传/更换/重试。

7.8 Nginx 配置指向了旧目录

coze-nginx.conf 里写的是 root /root/coze-public,但前端资源在 frontend/

解决:改为 root /root/parfum-studio/frontend

7.9 阿里云安全组只开了 3000 端口但 Nginx 需要 80

一开始只开了 3000 端口,但 Nginx 监听的是 80。

解决:安全组开放 80 端口,Node.js 只监听 127.0.0.1:3000。

7.10 SQLite 在 EdgeOne 上无法运行

EdgeOne Pages 不支持 better-sqlite3。

解决:维护两套 db.js,本地用 SQLite,部署用 Supabase。

7.11 用户点击“不满意”后,修改意见没传给工作流

previewRetryBtn 的点击事件中,detail 变量没有正确获取。

解决:检查事件绑定,确保 detailpreviewDetailInput 读取。

7.12 回退后 previewPrompt 没清空,导致第三步用了旧数据

回退到第一步时,previewPrompt 没有被清空。

解决:在 rollbackTask 中,回退到 spec 时清空 previewPrompt

7.13 checkStaleTasks 持续扫描已完成任务

终态任务(completed/failed/no_response)仍然被扫描。

解决:增加 terminalStates 判断,跳过终态任务。

7.14 用户连续快速点击导致 displayResults 重复触发

displayResults 在 2 秒内被多次调用,导致历史记录重复保存。

解决:增加防抖逻辑,2 秒内同一任务重复调用直接忽略。

7.15 CORS 配置用了 origin: true 导致安全风险

任何域名都能调用 API。

解决:改为白名单模式,从环境变量读取允许的域名。


附录 A:工具函数汇总

A.1 三层解析兜底

async function callCozeWorkflow(workflowId, parameters) {
    const res = await callCozeFallback(workflowId, parameters);
    const txt = await res.text();
    let j = null;
    try { j = JSON.parse(txt); } catch (_) {
        console.warn('响应非 JSON:', txt.slice(0, 200));
    }

    if (!res.ok) {
        throw new Error('工作流调用失败');
    }

    // 提取 data 字段
    let data = j.data;
    if (typeof data === 'string') {
        try { data = JSON.parse(data); } catch (_) {}
    }

    // 如果 data 有 output 字段
    if (data && data.output) {
        try { 
            const output = typeof data.output === 'string' ? JSON.parse(data.output) : data.output;
            data = output;
        } catch (_) {}
    }

    // 如果是字符串,尝试提取 JSON 片段
    if (typeof data === 'string') {
        const jsonMatch = data.match(/\{[\s\S]*\}/);
        if (jsonMatch) {
            try { data = JSON.parse(jsonMatch[0]); } catch (_) {}
        }
    }

    return data;
}

A.2 提取图片 URL

function extractImageUrls(content) {
    if (!content) return [];
    const urls = [];

    // 匹配 Markdown 图片语法:![外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/67ee48f884ef411792f88e246c2f846c~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5pmL6Zmi5oKN5Yyq5rS-5aSn5pif:q75.awebp?rk3s=f64ab15b&x-expires=1789178279&x-signature=tjKxiGC9nIDoBEC3377p4X0YKlA%3D)
    const mdRe = /!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g;
    let m;
    while ((m = mdRe.exec(content)) !== null) {
        if (!urls.includes(m[1])) urls.push(m[1]);
    }

    // 匹配纯 URL(带图片扩展名)
    const urlRe = /https?:\/\/[^\s"'<>\\]+\.(?:png|jpe?g|webp|gif)(?:\?[^\s"'<>\\]*)?/gi;
    while ((m = urlRe.exec(content)) !== null) {
        if (!urls.includes(m[0])) urls.push(m[0]);
    }

    return urls;
}

A.3 XSS 转义

function escapeHtml(s) {
    return String(s).replace(/[&<>"']/g, (c) => ({
        '&': '&', '<': '<', '>': '>', '"': '"', "'": '&#39;'
    }[c]));
}

A.4 优雅关机

function gracefulShutdown(signal) {
    logger.info({ signal }, '收到终止信号,正在优雅关闭...');

    if (server) {
        server.close(() => {
            logger.info('所有请求已处理完成');
            db.close();
            if (redis) redis.quit();
            process.exit(0);
        });

        setTimeout(() => {
            logger.error('强制关闭:等待请求超时(10 秒)');
            process.exit(1);
        }, 10000);
    }
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

附录 B:环境变量清单

变量名必需默认值说明
KM_COZE_TOKEN-Coze API Token
WF_SPEC_ID-规范生成工作流 ID
WF_PREVIEW_ID-预览图生成工作流 ID
WF_MULTI_ID-多角度生成工作流 ID
ALLOWED_ORIGINS-CORS 白名单(逗号分隔)
ADMIN_DESIGNER_ID-管理员账号 ID
PORT3000服务端口
NODE_ENVdevelopment运行环境
RATE_LIMIT_MAX30限流次数(每分钟)
RATE_LIMIT_WINDOW_MS60000限流窗口(毫秒)
SUPABASE_URL❌(EdgeOne)-Supabase 项目 URL
SUPABASE_ANON_KEY❌(EdgeOne)-Supabase anon key

附录 C:部署检查清单

上线前逐项确认:

  • express.static 指向 frontend/ 目录
  • CORS 配置为白名单模式(ALLOWED_ORIGINS 已配置)
  • 所有动态渲染使用 escapeHtml
  • 限流 Map 有定期清理机制
  • 文件上传有服务端校验(MIME + 大小 + file-type)
  • 已启用 Helmet 安全头
  • SQLite 配置了 busy_timeout = 5000
  • 实现了优雅关机
  • 启动时验证关键环境变量
  • 使用结构化日志(Pino)
  • 前端上传大小与后端一致(5MB)
  • Nginx 配置指向正确的静态资源目录
  • 安全组只开放必要的端口(80/443)
  • Node.js 只监听 127.0.0.1(通过 Nginx 转发)
  • .env 文件已排除在 Git 之外
  • PM2 已配置开机自启
  • 健康检查端点 /health 可访问

🚀 快速启动

# 1. 克隆项目
git clone https://github.com/paidaxin-12138/parfum-studio.git
cd parfum-studio

# 2. 安装依赖
npm install

# 3. 配置环境变量
cp .env.example .env
# 编辑 .env,填入你的 Coze Token 和 Supabase 配置

# 4. 启动服务(开发模式)
npm run dev
# 访问 http://localhost:3000

# 5. 生产部署
pm2 start index.js --name parfum-studio
pm2 save

📦 完整项目地址:GitHub - parfum-studio📖 详细部署文档见项目 README


写在最后

从“一个巨无霸工作流”到“三步独立工作流 + 前端状态机”,从“能跑就行”到“生产就绪”,这个项目的演进过程让我深刻体会到:

1. 拆,是解决复杂问题最有效的手段

一个工作流做所有事看起来很“简洁”,但耦合带来的维护成本远超想象。拆成三步后,每一步都可以独立调试、独立重试、独立优化。

2. 用户交互设计决定产品体验的上限

AI 很强大,但如果用户不能表达“我不满意”并让 AI 按照自己的意愿修改,再强的 AI 也没用。三步工作流 + 前端状态机的设计,本质上是把“用户决策”插入了 AI 执行的流程中。

3. 安全不是“锦上添花”,是“生死攸关”

10 个漏洞,每一个都可能让系统被攻破。花两天时间做安全审查,比花两个月时间处理数据泄露划算得多。

4. 部署不是最后一步,是新的起点

从阿里云到 EdgeOne,从 SQLite 到 Supabase,部署方案的选择会影响整个系统的架构设计。早想清楚部署方案,可以少走很多弯路。

5. 真实的坑,都不在文档里

联调、传参、安全、部署——每个环节都有“官方文档没说清楚”的细节。最好的学习方式就是亲自踩坑,然后把坑填平。


如果你在搭建类似项目的过程中遇到了问题,欢迎在评论区留言讨论。如果这篇文章帮到了你,不妨点个 Star 支持一下 ❤️


📖 本文基于 Parfum Studio 项目的真实开发经验撰写。📅 最后更新:2026 年 9 月