从图片生成任务到用户隔离:AIGC Creative Studio 后端与 PostgreSQL 建模实践

从图片生成任务到用户隔离:AIGC Creative Studio 后端与 PostgreSQL 建模实践

一篇面向全栈工程师面试与技术复盘的实践文章:使用 Express + TypeScript + PostgreSQL 搭建 AIGC 图片创作后端,处理任务状态、用户认证、图片文件存储、编辑作品回存,以及本地 Docker 可复现开发环境。

很多 AIGC 项目的第一版都很快:前端填一个 Prompt,后端调一次模型接口,把 URL 返回给页面。真正继续做下去时,问题才会出现:生成需要几十秒怎么办?链接过期怎么办?用户 A 能否访问用户 B 的图片?编辑后的图片存在哪里?重启服务后任务是否还在?新电脑如何快速启动开发环境?

本文基于 AIGC Creative Studio 的真实代码展开。项目不是生产级 SaaS,而是一个用于学习完整 AIGC 全栈链路、也能作为面试案例讲解的本地项目。文章会明确区分"当前已实现的能力"和"生产环境仍应补齐的能力"。

一、项目的后端目标与边界

后端要解决的不是"返回一张图片",而是维护以下闭环:

text 复制代码
用户登录
  ↓
提交生成参数
  ↓
创建本地任务(pending)并立即返回 taskId
  ↓
后台调用图片 Provider,更新 processing / succeeded / failed
  ↓
下载 Provider 临时图片,保存到本地文件系统
  ↓
任务和图片元数据写入 PostgreSQL
  ↓
用户在生成库查询、下载、编辑或删除自己的图片

当前后端技术选型:

目标 实现
HTTP API Express + TypeScript
数据库 PostgreSQL 17 + pg
密码 bcryptjs 哈希
登录态 JWT + HttpOnly Cookie,同时兼容 Bearer Token
真实生图 Provider 接口 + DashScope Wanx 实现
图片二进制 server/storage/images/
元数据 PostgreSQL usersgeneration_tasksimages
本地环境 Docker Compose PostgreSQL

这里有一个重要边界:浏览器直接导入的本地素材目前保存于 IndexedDB,不上传服务端;服务端 images 表管理的是 AI 生成图片以及保存回生成库的编辑 PNG。

二、不要让 index.ts 同时承担应用配置和端口监听

后端入口拆为两个文件:

text 复制代码
server/src/app.ts    创建 Express app,注册中间件和路由
server/src/index.ts  加载 .env,读取 PORT,调用 app.listen()

这样拆分的价值是测试。Supertest 可以直接请求导出的 app,无需占用 3001 端口:

ts 复制代码
import request from 'supertest'
import { app } from '../app.js'

const response = await request(app).get('/api/health')

app.ts 负责装配:

ts 复制代码
app.use(cors({ origin: true, credentials: true }))
app.use(express.json())
app.use('/api/auth', authRouter)
app.use('/api/generations', generationsRouter)

图片读取没有直接暴露 express.static,而是走受保护接口:

text 复制代码
GET /api/images/:filename

它先验证登录用户,再检查该文件是否属于该用户,最后才从受限目录读取二进制。这避免了"知道文件名就能看图"的资源越权问题。

三、数据库连接:连接池、环境变量与快速失败

数据库连接集中在 server/src/database/database.ts

ts 复制代码
let databasePool: Pool | null = null

export const getDatabasePool = (): Pool => {
  if (!databasePool) {
    databasePool = new Pool({ connectionString: getDatabaseUrl() })
  }

  return databasePool
}

这里使用延迟初始化连接池,而不是每次请求创建一个 PostgreSQL 客户端。DATABASE_URL 缺失时会立即抛错,而不是让业务路由在运行中出现难以定位的空连接错误。

本地 Docker 开发配置:

yaml 复制代码
services:
  postgres:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: aigc
      POSTGRES_PASSWORD: aigc_dev_password
      POSTGRES_DB: aigc_studio
    ports:
      - "5432:5432"

后端本机 .env 使用:

env 复制代码
DATABASE_URL=postgresql://aigc:aigc_dev_password@localhost:5432/aigc_studio
PORT=3001
JWT_SECRET=至少32位的本机随机字符串

面试时可以主动说明:仓库中的 Docker 密码仅用于本地学习;真实数据库连接串、JWT 密钥和 DashScope API Key 都在 .env 中,并被 .gitignore 排除,不能提交到 Git。

四、三张表如何关联

当前 Schema 的核心是三张表:usersgeneration_tasksimages

text 复制代码
users (1)
  └── generation_tasks (N)
         └── images (N)
                └── source_image_id → images.id(编辑作品的来源)

1. users:认证主体

sql 复制代码
CREATE TABLE users (
  id UUID PRIMARY KEY,
  email VARCHAR(255) NOT NULL UNIQUE,
  display_name VARCHAR(100) NOT NULL,
  password_hash TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
  • id 是内部用户主键;
  • email 有唯一约束,避免重复注册;
  • password_hash 保存 bcrypt 哈希,绝不保存明文密码;
  • 创建、更新时间使用 TIMESTAMPTZ,减少时区歧义。

2. generation_tasks:一次生成请求,而不是一张图片

sql 复制代码
CREATE TABLE generation_tasks (
  id UUID PRIMARY KEY,
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  status VARCHAR(20) NOT NULL CHECK (
    status IN ('pending', 'processing', 'succeeded', 'failed')
  ),
  prompt TEXT NOT NULL,
  negative_prompt TEXT,
  aspect_ratio VARCHAR(5) NOT NULL,
  image_count SMALLINT NOT NULL,
  seed INTEGER,
  style VARCHAR(20) NOT NULL,
  error_code VARCHAR(100),
  error_message TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  completed_at TIMESTAMPTZ
);

为什么任务表不直接把图片 URL 写成一个字符串?因为"生成请求"和"生成结果"并不是一对一:一次任务可能多图,也可能失败、取消或后续增加编辑作品。任务表保存请求参数、状态和错误;图片表保存每个图片实体。

user_id 外键采用 ON DELETE CASCADE,删除用户时可级联删除其任务。项目还建立了:

sql 复制代码
CREATE INDEX generation_tasks_user_created_at_index
  ON generation_tasks (user_id, created_at DESC);

它对应生成库最常见的访问模式:按当前用户读取最新任务。

3. images:图片元数据、来源关系和稳定顺序

sql 复制代码
CREATE TABLE images (
  id UUID PRIMARY KEY,
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  generation_task_id UUID REFERENCES generation_tasks(id) ON DELETE SET NULL,
  kind VARCHAR(20) NOT NULL CHECK (
    kind IN ('generated', 'edited', 'imported')
  ),
  storage_key TEXT NOT NULL,
  mime_type VARCHAR(100) NOT NULL,
  source_image_id UUID REFERENCES images(id) ON DELETE SET NULL,
  position INTEGER NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

字段设计中有三个值得讲的点:

  1. storage_key 不是外部临时 URL,而是服务端本地文件名;API 对外构造 /api/images/{storage_key}
  2. source_image_id 是图片自关联。编辑作品可追溯到原图,但原图被删除后使用 SET NULL,不强制删除编辑作品;
  3. position 表示同一任务中的稳定显示顺序。

第三点非常容易忽略。前端下载和编辑使用 imageIndex。如果仅按 created_at, id 排序,批量生成的图片可能有相同时间,而 UUID 排序又是随机的,下一次读取时"第 0 张图"可能变了。项目因此在写入时按数组下标保存 position,读取时 ORDER BY position ASC,并创建唯一索引:

sql 复制代码
CREATE UNIQUE INDEX images_generation_task_position_index
  ON images (generation_task_id, position);

五、任务状态机:先返回 taskId,再后台执行

创建任务接口是:

text 复制代码
POST /api/generations

请求校验完成后,服务端先创建 pending 任务并返回 HTTP 202:

json 复制代码
{
  "success": true,
  "message": "Generation request accepted",
  "data": {
    "taskId": "UUID",
    "status": "pending",
    "request": {}
  }
}

然后后台状态按以下路径推进:

text 复制代码
pending → processing → succeeded
                     ↘ failed

这比让 HTTP 请求一直等待 Provider 返回更合理:浏览器不会被长请求阻塞;用户可以切换页面;前端只需轮询 GET /api/generations/:taskId 即可获取最新状态。

当前项目通过 ImageGenerationProvider 进行平台隔离:

ts 复制代码
interface ImageGenerationProvider {
  readonly name: string
  generate(input: GenerateImageInput): Promise<GenerateImageResult>
}

Wanx Provider 处理比例到实际尺寸映射、异步任务查询、超时、网络错误和安全错误转换。业务路由并不需要知道 DashScope 的具体 HTTP 协议。

首次真实调用还会强制按 1 张图片处理,避免前端传入多图时产生不可控成本。这是一种很适合学习项目和云模型接入初期的成本保护策略。

六、Provider 成功后,为什么还要下载图片

Provider 返回的图片 URL 往往是临时地址。若直接写入数据库,过期后历史图片库会失效。

因此 Provider 成功后的流程是:

text 复制代码
外部临时 URL
  → Node.js 原生 fetch 下载二进制
  → 写入 server/storage/images/{taskId}-{index}.png
  → 图片表保存 storage_key
  → 对外暴露受认证保护的 /api/images/{filename}

本地存储模块会严格校验文件名:只接受 .png,要求文件名等于 basename(filename),并确认 resolve 后路径仍在 storage/images 目录下。这样下载、删除和读取接口都不能被客户端传入的路径穿越攻击利用。

值得注意的是:服务端不会接受客户端传入任意图片 URL 再去 fetch。编辑、下载和删除都只通过已保存的任务与图片索引定位资源,从源头避免 SSRF 风险。

七、登录态与资源授权

认证流程分为三步:

  1. 注册或登录时,用 bcrypt 比较或生成密码哈希;
  2. 使用 JWT_SECRET 创建包含 sub(用户 ID)和邮箱的 JWT;
  3. 将 token 放入 HttpOnly Cookie,并在 JSON 响应中返回 token 以兼容 Bearer 请求。

认证中间件优先读取:

text 复制代码
Authorization: Bearer <token>

没有 Bearer Token 时,再解析 Cookie 中的 aigc_access_token。验证成功后把用户信息挂到 request.authUser

任务查询的核心不是"先按 taskId 查出来再判断",而是在 Repository 查询条件中带上 user_id

sql 复制代码
WHERE id = $1 AND user_id = $2

因此即使用户猜到另一个 taskId,也只能得到 404。图片访问同样通过:

sql 复制代码
SELECT 1 FROM images WHERE storage_key = $1 AND user_id = $2 LIMIT 1

确认归属后才读文件。这样数据库授权与文件授权保持同一条用户边界。

前端编辑器加载受保护图片时也有一个容易踩坑的细节:图片 API 和 Vite 开发服务器通常端口不同。Canvas 使用 new Image() 加载时必须设置:

ts 复制代码
image.crossOrigin = 'use-credentials'

anonymous 不会携带 Cookie,会导致受保护的 /api/images/... 返回 401,最终 Canvas 只会触发 onerror。后端 CORS 需要允许 credentials,登录 Cookie 也要按本地/生产环境正确设置 sameSitesecure

八、Repository:为什么读取需要一致性快照

生成任务和图片分别存于两张表。若先查任务、再查图片,后台恰好在两次查询之间完成生成,就可能出现"状态还是 processing,但结果中已有图片"的混合响应。

当前 Repository 将两次读取放进 PostgreSQL 的只读一致性事务:

ts 复制代码
await client.query('BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY')

const taskResult = await client.query(/* generation_tasks */)
const imageResult = await client.query(/* images */)

await client.query('COMMIT')

这让一次 API 响应对应同一数据库快照。任务与图片的写入也使用事务:先锁定已有任务并检查 owner,再 upsert 任务记录,更新图片元数据,最后提交或回滚。

九、图片编辑作品如何回存

编辑器导出当前 Canvas 为 PNG Blob。保存到生成库时,前端向后端发送:

text 复制代码
POST /api/generations/:taskId/images/:imageIndex/edits
Content-Type: image/png
Body: PNG 二进制

后端校验顺序包括:

  • 当前用户是否拥有任务;
  • 任务是否已经 succeeded
  • 图片索引是否为非负整数且确实存在;
  • Content-Type 是否为 image/png
  • 请求体是否为空、是否符合 PNG 文件头。

PNG 文件头是:

text 复制代码
89 50 4E 47 0D 0A 1A 0A

通过后使用 crypto.randomUUID() 生成编辑文件名,保存本地文件,并将 kind='edited'source_image_idposition 等元数据写回数据库。原图并不会被覆盖。

十、如何在新电脑快速复现

项目不提交数据库实际数据和图片文件,只提交代码、docker-compose.ymlschema.sql.env.example。新电脑启动流程:

powershell 复制代码
git clone <repository>
Set-Location aigc-creative-studio

npm ci
Set-Location server
npm ci
Set-Location ..

docker compose up -d postgres
Copy-Item .\.env.example .\.env
Copy-Item .\server\.env.example .\server\.env

Get-Content .\server\sql\schema.sql |
  docker compose exec -T postgres psql -U aigc -d aigc_studio

之后分别启动 server 和前端,重新注册账号即可。schema.sql 使用 CREATE ... IF NOT EXISTS 与必要的 ALTER ... IF NOT EXISTS,既能创建空数据库,也能补齐例如 images.position 这样的后续字段。

十一、面试全栈工程师时,建议主动讲清的点

1. 为什么不用"同步调用模型并直接返回图片"?

回答重点:生图是长耗时异步任务。先返回 taskId 可以降低 HTTP 超时风险,让前端切页后继续轮询,也便于后续替换为队列、SSE 或 WebSocket。

2. 为什么任务和图片要分表?

回答重点:任务表示请求生命周期,图片表示结果实体。一对多建模支持多图、失败记录、编辑作品、自关联来源和后续扩展。

3. 为什么图片要有 position?

回答重点:前端编辑和下载使用 imageIndex;数据库默认返回顺序不可靠,UUID 和同一时间戳都不能表达用户看到的顺序。position 将 UI 数组下标变成持久化业务约束。

4. 如何避免用户越权访问图片?

回答重点:每次任务和图片查询都带 user_id 条件;图片文件不是公开静态目录,读取前先检查数据库归属;无权限返回 404。

5. 如何避免 Provider 临时链接过期?

回答重点:成功后立即由服务端下载图片,保存本地文件,数据库只保存内部 storage key,对外通过受保护 API 访问。

6. Canvas 特效如何保证能导出?

回答重点:不只做 CSS 显示效果。所有滤镜和动画都以 Canvas 像素为结果源,PNG 通过 toBlob,WebM 通过 captureStream,因此预览和导出一致。

7. 如何测试而不消耗真实模型额度?

回答重点:Provider 测试 Mock 全局 fetch;轮询和超时使用 fake timers;Supertest 直接请求 app;CI 使用占位 Key、禁用真实生成、不会请求外部模型平台。

十二、当前实现的真实局限,以及下一步演进

面试中不要把本地项目包装成生产系统。当前版本仍有明确边界:

  • 图片保存在本地文件系统,未接对象存储和 CDN;
  • Provider 调用由当前 Node 进程后台执行,未接 Redis、消息队列或分布式任务系统;
  • 同一任务的编辑/删除在单 Node 进程内串行化,多实例部署仍应使用乐观锁、版本号 CAS 或数据库级事务变更;
  • 数据库测试目前主要 Mock Repository,生产前应增加独立 PostgreSQL 集成测试;
  • 登录是账号密码模式,未接 OAuth、验证码、限流、审计与密码找回;
  • Docker Compose 密码仅适用于本地开发,生产必须使用密钥管理和隔离网络。

比较合理的下一步不是立即增加更多页面,而是:补 PostgreSQL 集成测试、为任务引入版本控制、将图片文件迁移到对象存储、将生图调用迁移到持久化任务队列。

结语

一个 AIGC 全栈项目的难点,往往不在于发出一次模型请求,而在于把任务、结果、权限、文件、编辑和本地开发环境连接成稳定闭环。

在 AIGC Creative Studio 中,我把这条链路拆成了可解释的层次:Express 路由负责协议和授权、Provider 负责外部平台差异、Repository 负责 PostgreSQL 一致性、Storage 负责文件安全、Canvas 负责编辑结果一致性。对学习项目而言,这比堆砌更多效果更有价值;对全栈面试而言,这些取舍也比"我调用过某个模型 API"更能体现工程能力。

相关推荐
Zane19941 小时前
别再手写 try/finally 了:一文讲透 with 语句背后的上下文管理器协议
后端·python
进击的程序猿~1 小时前
Go 内存分配与垃圾回收源码深度学习手册
开发语言·后端·golang
geovindu2 小时前
go:Bit Operation Algorithm
开发语言·后端·算法·golang·位运算法
小黑技术栈2 小时前
关于前端的一些话
前端
元界metalite2 小时前
Spring-AOP切面越写越多怎么办-8类场景的一条有序处理器链
后端
有米9792 小时前
Logstash的安装和Elasticsearch的整合
后端
newerp2 小时前
reflect.Value 与动态值操作
后端
jinzhe2 小时前
终端bash&zsh中实现基于前缀的历史命令搜索
后端
用户8181870627462 小时前
二、多线程并发篇
后端·程序员