一个项目,三次重构,十个漏洞,两套部署方案,三十天上线实录
前言:为什么要写这篇博客
2026 年的夏天,我决定做一个 AI 香水概念生成器。用户输入一段文字描述,AI 就能生成香氛概念图和多角度展示图。听起来简单,做起来才发现——从 Coze 工作流设计、前后端状态机、安全加固到生产部署,每一步都有数不清的坑。
这篇文章,就是我从零到一搭建这个项目的完整记录。它不是一个“Hello World”教程,而是一个真实项目的演进史:从“一个巨无霸工作流”到“三步独立工作流 + 前端状态机”,从“能跑就行”到“生产就绪”。
我会把所有的坑、所有的修复、所有的决策过程都写出来。如果这篇文章能帮你少踩一个坑,我就没白写。
适合读者:
- 正在用 Coze 工作流做 AI 应用的后端/全栈开发者
- 想了解“工作流驱动”架构设计的前端工程师
- 即将上线但心里没底的技术负责人
阅读建议:
- 时间紧 → 看完“5分钟速览”即可抓住主干
- 时间充裕 → 从头读到尾,细节都在
- 遇到具体问题 → 直接跳到对应章节或附录
🎨 先看效果
用户输入一段文字描述,AI 生成如下结果:
🚀 5 分钟速览
最终架构(一张图看懂)
核心结论(记住这 3 条就够了)
| # | 结论 | 为什么重要 |
|---|---|---|
| 1 | 拆三步,不要一个巨无霸工作流 | 用户能“确认→修改→重新生成”,而不是全流程重跑,费用浪费 70% |
| 2 | 预览图工作流必须返回 prompt 字段 | 第三步多角度生成需要文本提示词来保持风格一致性 |
| 3 | CORS 必须配白名单,不能用 origin: true | 否则任何网站都能盗用你的 API,用户数据毫无安全可言 |
技术栈速览
| 层 | 技术 | 说明 |
|---|---|---|
| 前端 | 原生 JS + Tailwind CSS | 零构建,极致轻量,首屏加载 < 200ms |
| 后端 | Node.js + Express 5 | ESM 模块,异步非阻塞 |
| 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(内存)+ localStorage | tasks 表(SQLite / Supabase) |
| 状态管理 | 前端渲染(弹窗/进度条/卡片) | 工作流执行状态(queued/processing/done) |
| 隔离方式 | 通过 user_id 过滤 | 每个任务关联 user_id,所有 CRUD 带用户过滤 |
关键实现:
- 每个任务绑定
user_id:所有数据库操作都带user_id过滤,杜绝数据串扰 tasksMap按用户隔离:前端登录后,只拉取当前用户的tasks列表- 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 字段为什么如此重要
这是整个设计中容易被忽视但至关重要的一个细节。
第三步需要“基于预览图生成多角度图”,但图像生成节点需要的是文本提示词,而不是图片本身。所以第二步必须返回两个东西:
image_url(预览图 URL,作为视觉参考)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 层的对象。
解决方案:三层解析兜底。
| 层级 | 尝试解析的位置 | 优先级 |
|---|---|---|
| 1 | data(自动解析 JSON) | 最高 |
| 2 | data.output(字符串再解析) | 次高 |
| 3 | 正则提取 {...} 片段 | 兜底 |
完整实现见 附录 A:工具函数汇总。
3.2 坑二:图片 URL 不一定在 image_url 字段
WF_PREVIEW 返回的图片 URL 有时候在 image_url,有时候在 data.image_url,有时候在 output 的 Markdown 里。
解决方案:写通用提取函数,同时匹配 Markdown 语法和纯 URL。
// 核心思路:匹配两种模式
// 1. Markdown: 
// 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_id | reference_image_url: URL |
3.5 坑五:工作流超时
Coze 工作流有执行时间限制。WF_MULTI 生成多角度图耗时较长,偶尔超时。
解决方案:
- 服务端增加超时控制(30 秒)
- 前端增加友好提示:“生成超时,请稍后重试 ⏰”
- 前端增加“重新生成”按钮,不丢失已有上下文
第四章:安全审查——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) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[c]));
}
我检查了所有使用 innerHTML 和 insertAdjacentHTML 的地方,确保每个动态变量都被转义。
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-Options、X-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_prompt 和 reference_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 变量没有正确获取。
解决:检查事件绑定,确保 detail 从 previewDetailInput 读取。
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 图片语法:
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) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[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 |
PORT | ❌ | 3000 | 服务端口 |
NODE_ENV | ❌ | development | 运行环境 |
RATE_LIMIT_MAX | ❌ | 30 | 限流次数(每分钟) |
RATE_LIMIT_WINDOW_MS | ❌ | 60000 | 限流窗口(毫秒) |
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 月