如何设计产品功能表

产品功能表是业务的核心,决定了产品「能做什么」和「做了什么」。对于 AI 工具类产品来说,这一点尤其明显——用户每一次调用模型、每一次生成内容,都需要在数据库里留下完整的记录。这不仅是业务数据的存储问题,更直接影响产品的计费逻辑、用量统计、版本回溯和用户体验。

很多独立开发者在项目初期容易忽视表结构设计,用一个「万能表」或几个简陋的字段就把业务跑起来。等到用户量上来之后,才发现生成记录无法追溯、版本历史无法回滚、产品配置无法灵活调整,最终不得不做大规模的数据迁移。

本文以 AI 工具类产品为例,系统讲解三张核心表的设计思路:产品表、生成记录表、版本历史表。从字段定义到关联关系,从 SQL 建表到 Prisma Schema,从设计原则到实际案例,帮助你建立一套可扩展的产品功能表体系。

产品表设计

产品表是 AI 工具的「商品目录」,记录的是平台提供给用户的能力单元。它可以是一个 AI 写作助手、一个图片生成器、一个语音转文字工具,也可以是同一工具下的不同功能模式。

核心字段

产品表需要回答三个问题:这个产品是什么、它有什么能力、当前处于什么状态。

字段名类型必填说明
idUUID / BIGINT主键,推荐使用 UUID 避免枚举
nameVARCHAR(100)产品名称,如「AI 文章生成器」
slugVARCHAR(100)URL 友好标识,如 ai-article-writer
descriptionTEXT产品描述,面向用户展示
categoryVARCHAR(50)产品分类:writingimageaudiovideo
statusENUM产品状态:draftactivedeprecatedarchived
configJSONB产品配置,存储模型参数、功能开关等灵活字段
pricing_typeENUM计费方式:freeper_usesubscriptioncredit_based
unit_costDECIMAL(10,4)单次调用成本(按计费类型解释)
icon_urlVARCHAR(500)产品图标 URL
sort_orderINT排序权重,控制前端展示顺序
created_atTIMESTAMP创建时间
updated_atTIMESTAMP更新时间

为什么用 JSONB 存配置?

AI 产品的配置差异很大。一个写作工具可能需要配置 max_tokenstemperaturesupported_languages;一个图片生成工具可能需要配置 default_sizesupported_formatsstyle_presets。如果为每个配置项建一个列,表结构会随着功能增加不断膨胀。

使用 JSONB 类型(PostgreSQL)或 JSON 类型(MySQL),可以将这些灵活字段存放在一个列中,同时保留查询能力:

-- 产品配置示例
UPDATE products SET config = '{
  "model": "gpt-4o",
  "max_tokens": 4096,
  "temperature": 0.7,
  "supported_languages": ["en", "zh", "ja", "ko"],
  "features": {
    "streaming": true,
    "image_upload": true,
    "file_export": ["pdf", "docx", "md"]
  }
}' WHERE id = 'prod_001';
 
-- 查询特定配置
SELECT name, config->>'model' AS model
FROM products
WHERE config->'features'->>'streaming' = 'true';

在 Prisma 中,JSONB 字段的定义方式:

model Product {
  id          String   @id @default(uuid())
  name        String   @db.VarChar(100)
  slug        String   @unique @db.VarChar(100)
  description String?
  category    String   @db.VarChar(50)
  status      ProductStatus @default(draft)
  config      Json?
  pricingType PricingType @default(free)
  unitCost    Decimal? @db.Decimal(10, 4)
  iconUrl     String?  @map("icon_url") @db.VarChar(500)
  sortOrder   Int?     @map("sort_order")
  createdAt   DateTime @default(now()) @map("created_at")
  updatedAt   DateTime @updatedAt @map("updated_at")
 
  generations    Generation[]
  productVersions ProductVersion[]
 
  @@map("products")
}
 
enum ProductStatus {
  draft
  active
  deprecated
  archived
}
 
enum PricingType {
  free
  per_use
  subscription
  credit_based
}

产品状态的生命周期

产品状态不是随意设置的,它对应一个清晰的生命周期:

  • draft:产品正在开发或配置中,前端不展示
  • active:产品上线,用户可以使用
  • deprecated:产品即将下线,仍可使用但前端标注「即将停用」
  • archived:产品已下线,用户不可访问,历史数据保留

这个状态机直接影响 API 层的权限校验——只有 active 状态的产品才接受新的生成请求。

生成记录表设计

生成记录表是 AI 工具类产品的核心表。它记录用户每一次调用 AI 能力的完整信息,包括输入参数、输出结果、消耗的资源和执行状态。这张表的数据量和查询频率通常远高于其他业务表。

核心字段

字段名类型必填说明
idUUID / BIGINT主键
user_idUUID / BIGINT发起生成的用户
product_idUUID / BIGINT使用的产品
statusENUM执行状态:pendingprocessingcompletedfailedcancelled
input_paramsJSONB用户输入的参数(prompt、配置选项等)
output_resultJSONB生成结果(文本、URL、结构化数据)
model_usedVARCHAR(100)实际使用的模型标识
tokens_inputINT输入 token 数(文本类 AI)
tokens_outputINT输出 token 数
cost_creditsINT本次消耗的积分 / 点数
cost_amountDECIMAL(10,6)本次消耗的金额(美元)
latency_msINT请求耗时(毫秒)
error_messageTEXT失败时的错误信息
metadataJSONB扩展元数据(IP、User-Agent、来源页面等)
created_atTIMESTAMP创建时间
completed_atTIMESTAMP完成时间

为什么输入和输出都用 JSONB?

AI 工具的输入和输出格式高度不统一。写作工具的输入可能是一段 prompt 和几个风格参数,输出是一篇文章;图片生成工具的输入是 prompt 加尺寸参数,输出是图片 URL 列表;翻译工具的输入是文本加语言对,输出是翻译结果加置信度分数。

用 JSONB 存储输入和输出,可以让同一张表服务于不同类型的 AI 产品,不需要为每种产品建一张独立的记录表:

-- 写作工具的生成记录
INSERT INTO generations (user_id, product_id, status, input_params, output_result)
VALUES (
  'user_001', 'prod_writing_001', 'completed',
  '{"prompt": "Write a blog post about AI trends", "language": "en", "tone": "professional", "max_length": 1000}',
  '{"content": "## AI Trends in 2026\n\n...", "word_count": 980, "language": "en"}'
);
 
-- 图片生成工具的生成记录
INSERT INTO generations (user_id, product_id, status, input_params, output_result)
VALUES (
  'user_001', 'prod_image_001', 'completed',
  '{"prompt": "A futuristic city skyline at sunset", "size": "1024x1024", "style": "photorealistic", "count": 4}',
  '{"images": [{"url": "https://cdn.example.com/img1.png", "seed": 42}, {"url": "https://cdn.example.com/img2.png", "seed": 108}]}'
);

生成记录的状态机

生成记录的状态流转是 AI 工具的核心业务流程:

流程图画布 · 115%
Mermaid 流程图加载中...

状态流转中的关键约束:

  • pendingprocessing:任务被 Worker 取出时触发,记录开始时间
  • processingcompleted:模型返回结果后触发,记录 token 数和耗时
  • processingfailed:超时、模型错误、内容审核不通过等场景
  • failedprocessing:用户点击「重试」,创建新的记录而不是更新旧记录(保持审计轨迹)

索引设计建议

生成记录表的查询模式通常包括:按用户查看历史记录、按产品统计用量、按时间范围查询。索引设计应覆盖这些场景:

-- 按用户查询历史记录(最常见)
CREATE INDEX idx_generations_user_id ON generations(user_id, created_at DESC);
 
-- 按产品统计用量
CREATE INDEX idx_generations_product_id ON generations(product_id, created_at DESC);
 
-- 按状态筛选待处理任务(Worker 使用)
CREATE INDEX idx_generations_status ON generations(status) WHERE status IN ('pending', 'processing');
 
-- 按时间范围统计(计费和对账使用)
CREATE INDEX idx_generations_created_at ON generations(created_at DESC);

Prisma 中的索引定义:

model Generation {
  id           String   @id @default(uuid())
  userId       String   @map("user_id")
  productId    String   @map("product_id")
  status       GenerationStatus @default(pending)
  inputParams  Json     @map("input_params")
  outputResult Json?    @map("output_result")
  modelUsed    String?  @map("model_used") @db.VarChar(100)
  tokensInput  Int?     @map("tokens_input")
  tokensOutput Int?     @map("tokens_output")
  costCredits  Int?     @map("cost_credits")
  costAmount   Decimal? @map("cost_amount") @db.Decimal(10, 6)
  latencyMs    Int?     @map("latency_ms")
  errorMessage String?  @map("error_message")
  metadata     Json?
  createdAt    DateTime @default(now()) @map("created_at")
  completedAt  DateTime? @map("completed_at")
 
  user    User    @relation(fields: [userId], references: [id])
  product Product @relation(fields: [productId], references: [id])
 
  @@index([userId, createdAt(sort: Desc)])
  @@index([productId, createdAt(sort: Desc)])
  @@index([status])
  @@map("generations")
}
 
enum GenerationStatus {
  pending
  processing
  completed
  failed
  cancelled
}

版本历史表设计

AI 工具生成的内容往往不是一次成型的。用户可能先生成一篇草稿,然后要求改写、扩展、调整语气,每一次修改都会产生一个新的版本。版本历史表的任务就是记录内容的每一次变更,支持回滚和对比。

核心字段

字段名类型必填说明
idUUID / BIGINT主键
generation_idUUID / BIGINT关联的生成记录
version_numberINT版本号,从 1 开始递增
contentTEXT / JSONB该版本的内容快照
content_hashVARCHAR(64)内容哈希值,用于快速判断是否有变化
change_typeENUM变更类型:creatededitedregeneratedrestored
change_summaryVARCHAR(500)变更摘要,如「调整了语气为正式」
diff_dataJSONB与上一版本的差异数据
created_byUUID / BIGINT操作人(用户或系统)
is_currentBOOLEAN是否为当前生效版本
created_atTIMESTAMP创建时间

版本管理策略

版本历史表有三种常见的实现策略,各有适用场景:

策略一:全量快照

每个版本保存完整的内容副本。优点是查询简单——直接读取某个版本就能获得完整内容;缺点是存储空间消耗大,特别是当内容很长时。

INSERT INTO product_versions (generation_id, version_number, content, change_type, is_current)
VALUES ('gen_001', 1, '{"title": "AI Trends", "body": "Full article content..."}', 'created', true);
 
-- 创建新版本时,先将旧版本标记为非当前
UPDATE product_versions SET is_current = false WHERE generation_id = 'gen_001' AND is_current = true;
 
-- 插入新版本
INSERT INTO product_versions (generation_id, version_number, content, change_type, is_current)
VALUES ('gen_001', 2, '{"title": "AI Trends", "body": "Revised article content..."}', 'edited', true);

策略二:增量差异

每个版本只保存与上一版本的差异(diff)。优点是存储效率高;缺点是读取历史版本时需要从第一个版本开始逐步合并差异,查询复杂度随版本号线性增长。

策略三:快照 + 差异混合

每隔 N 个版本保存一次全量快照,中间的版本只存差异。兼顾存储效率和查询性能,但实现复杂度最高。

对于 AI 工具类产品,全量快照是最务实的选择。原因有三:一是 AI 生成的内容通常是文本或 JSON,单条记录的存储成本不高;二是用户查看历史版本时需要即时获取完整内容,不能等待差异合并;三是实现和维护成本最低。

版本号管理

版本号的递增逻辑看似简单,但在并发场景下需要注意:

-- 使用子查询确保版本号递增正确
INSERT INTO product_versions (generation_id, version_number, content, change_type, is_current)
SELECT
  'gen_001',
  COALESCE(MAX(version_number), 0) + 1,
  '{"title": "Updated", "body": "..."}',
  'edited',
  true
FROM product_versions
WHERE generation_id = 'gen_001';

在高并发场景下,可以使用数据库序列或在应用层使用乐观锁来避免版本冲突。

Prisma Schema 定义:

model ProductVersion {
  id            String   @id @default(uuid())
  generationId  String   @map("generation_id")
  versionNumber Int      @map("version_number")
  content       Json
  contentHash   String?  @map("content_hash") @db.VarChar(64)
  changeType    VersionChangeType @default(created)
  changeSummary String?  @map("change_summary") @db.VarChar(500)
  diffData      Json?    @map("diff_data")
  createdBy     String   @map("created_by")
  isCurrent     Boolean  @default(false) @map("is_current")
  createdAt     DateTime @default(now()) @map("created_at")
 
  generation Generation @relation(fields: [generationId], references: [id])
 
  @@unique([generationId, versionNumber])
  @@index([generationId, isCurrent])
  @@index([generationId, createdAt(sort: Desc)])
  @@map("product_versions")
}
 
enum VersionChangeType {
  created
  edited
  regenerated
  restored
}

关联关系设计

三张核心表通过外键形成清晰的关联链路:用户创建生成记录,生成记录属于某个产品,每次变更产生版本历史。

关联关系总览

关联类型说明
Product → Generation一对多一个产品可以有无数次生成记录
Product → ProductVersion间接一对多(通过 Generation)产品的每个生成记录可以有多个版本
Generation → ProductVersion一对多一次生成可以产生多个版本
User → Generation一对多一个用户可以有多次生成记录
User → Product(通过 Generation)多对多用户可以多次使用同一产品

Mermaid 关联关系图

流程图画布 · 115%
Mermaid 流程图加载中...

这个关系图揭示了一个重要的设计决策:版本历史表关联的是 Generation(生成记录),而不是 Product(产品)。原因是同一个产品的不同用户使用方式不同,生成的内容也不同,版本管理的粒度应该在「单次生成的内容」上,而不是「产品」上。

查询示例

关联关系建好之后,常见的业务查询可以这样写:

-- 查询某用户最近 10 次生成记录及其关联的产品信息
SELECT
  g.id,
  p.name AS product_name,
  g.status,
  g.created_at,
  g.cost_credits
FROM generations g
JOIN products p ON g.product_id = p.id
WHERE g.user_id = 'user_001'
ORDER BY g.created_at DESC
LIMIT 10;
 
-- 查询某次生成的所有版本历史
SELECT
  pv.version_number,
  pv.change_type,
  pv.change_summary,
  pv.content,
  pv.created_at
FROM product_versions pv
WHERE pv.generation_id = 'gen_001'
ORDER BY pv.version_number ASC;
 
-- 统计某产品过去 30 天的生成量和成功率
SELECT
  p.name,
  COUNT(*) AS total_generations,
  COUNT(*) FILTER (WHERE g.status = 'completed') AS completed,
  COUNT(*) FILTER (WHERE g.status = 'failed') AS failed,
  ROUND(
    COUNT(*) FILTER (WHERE g.status = 'completed')::numeric / COUNT(*) * 100, 2
  ) AS success_rate
FROM generations g
JOIN products p ON g.product_id = p.id
WHERE g.created_at >= NOW() - INTERVAL '30 days'
  AND p.id = 'prod_001'
GROUP BY p.name;
 
-- 查询某用户在各产品上的消费汇总
SELECT
  p.name AS product_name,
  COUNT(*) AS generation_count,
  COALESCE(SUM(g.cost_credits), 0) AS total_credits,
  COALESCE(SUM(g.cost_amount), 0) AS total_amount
FROM generations g
JOIN products p ON g.product_id = p.id
WHERE g.user_id = 'user_001'
  AND g.status = 'completed'
GROUP BY p.name
ORDER BY total_credits DESC;

完整建表 SQL

以下是一个可以直接在 PostgreSQL 中执行的完整建表脚本:

-- 产品表
CREATE TABLE products (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name          VARCHAR(100) NOT NULL,
  slug          VARCHAR(100) NOT NULL UNIQUE,
  description   TEXT,
  category      VARCHAR(50) NOT NULL,
  status        VARCHAR(20) NOT NULL DEFAULT 'draft'
                  CHECK (status IN ('draft', 'active', 'deprecated', 'archived')),
  config        JSONB,
  pricing_type  VARCHAR(20) NOT NULL DEFAULT 'free'
                  CHECK (pricing_type IN ('free', 'per_use', 'subscription', 'credit_based')),
  unit_cost     DECIMAL(10, 4),
  icon_url      VARCHAR(500),
  sort_order    INT DEFAULT 0,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
-- 生成记录表
CREATE TABLE generations (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id        UUID NOT NULL REFERENCES users(id),
  product_id     UUID NOT NULL REFERENCES products(id),
  status         VARCHAR(20) NOT NULL DEFAULT 'pending'
                   CHECK (status IN ('pending', 'processing', 'completed', 'failed', 'cancelled')),
  input_params   JSONB NOT NULL,
  output_result  JSONB,
  model_used     VARCHAR(100),
  tokens_input   INT,
  tokens_output  INT,
  cost_credits   INT,
  cost_amount    DECIMAL(10, 6),
  latency_ms     INT,
  error_message  TEXT,
  metadata       JSONB,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  completed_at   TIMESTAMPTZ
);
 
CREATE INDEX idx_gen_user_created ON generations(user_id, created_at DESC);
CREATE INDEX idx_gen_product_created ON generations(product_id, created_at DESC);
CREATE INDEX idx_gen_status_pending ON generations(status) WHERE status IN ('pending', 'processing');
 
-- 版本历史表
CREATE TABLE product_versions (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  generation_id   UUID NOT NULL REFERENCES generations(id),
  version_number  INT NOT NULL,
  content         JSONB NOT NULL,
  content_hash    VARCHAR(64),
  change_type     VARCHAR(20) NOT NULL DEFAULT 'created'
                    CHECK (change_type IN ('created', 'edited', 'regenerated', 'restored')),
  change_summary  VARCHAR(500),
  diff_data       JSONB,
  created_by      UUID NOT NULL REFERENCES users(id),
  is_current      BOOLEAN NOT NULL DEFAULT false,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
 
  UNIQUE(generation_id, version_number)
);
 
CREATE INDEX idx_pv_gen_current ON product_versions(generation_id, is_current);
CREATE INDEX idx_pv_gen_created ON product_versions(generation_id, created_at DESC);

案例:AI 写作工具

一个面向海外市场的 AI 写作助手,支持文章生成、改写、翻译和续写。

产品配置

INSERT INTO products (id, name, slug, category, status, config, pricing_type, unit_cost)
VALUES (
  'prod_writer_001',
  'AI Article Writer',
  'ai-article-writer',
  'writing',
  'active',
  '{
    "models": ["gpt-4o", "claude-3-5-sonnet"],
    "default_model": "gpt-4o",
    "features": {
      "generate": true,
      "rewrite": true,
      "translate": true,
      "continue_writing": true
    },
    "limits": {
      "max_tokens_per_request": 8192,
      "max_words_per_request": 3000,
      "supported_languages": ["en", "zh", "ja", "ko", "es", "fr", "de"]
    },
    "defaults": {
      "tone": "professional",
      "language": "en",
      "output_format": "markdown"
    }
  }'::jsonb,
  'credit_based',
  0.0020
);

生成记录示例

用户使用写作工具生成一篇文章:

-- 用户提交生成请求
INSERT INTO generations (id, user_id, product_id, status, input_params)
VALUES (
  'gen_writer_001',
  'user_001',
  'prod_writer_001',
  'processing',
  '{
    "action": "generate",
    "prompt": "Write a comprehensive guide about remote work productivity tips",
    "language": "en",
    "tone": "professional",
    "max_words": 2000,
    "include_outline": true
  }'::jsonb
);
 
-- 模型返回结果后更新记录
UPDATE generations SET
  status = 'completed',
  output_result = '{
    "content": "## Remote Work Productivity: A Complete Guide\n\n...",
    "word_count": 1856,
    "language": "en",
    "reading_time_minutes": 7
  }'::jsonb,
  model_used = 'gpt-4o',
  tokens_input = 156,
  tokens_output = 2340,
  cost_credits = 5,
  cost_amount = 0.010000,
  latency_ms = 8500,
  completed_at = now()
WHERE id = 'gen_writer_001';

版本历史示例

用户对生成的文章进行改写:

-- 初始版本(自动生成时创建)
INSERT INTO product_versions (generation_id, version_number, content, change_type, change_summary, created_by, is_current)
VALUES (
  'gen_writer_001', 1,
  '{"content": "## Remote Work Productivity: A Complete Guide\n\n...original version..."}',
  'created', 'Initial generation', 'system', true
);
 
-- 用户要求改写语气
UPDATE product_versions SET is_current = false WHERE generation_id = 'gen_writer_001' AND is_current = true;
 
INSERT INTO product_versions (generation_id, version_number, content, change_type, change_summary, created_by, is_current)
VALUES (
  'gen_writer_001', 2,
  '{"content": "## Remote Work Productivity: A Complete Guide\n\n...casual tone version..."}',
  'edited', 'Changed tone from professional to casual', 'user_001', true
);
 
-- 用户要求扩展某个章节
UPDATE product_versions SET is_current = false WHERE generation_id = 'gen_writer_001' AND is_current = true;
 
INSERT INTO product_versions (generation_id, version_number, content, change_type, change_summary, created_by, is_current)
VALUES (
  'gen_writer_001', 3,
  '{"content": "## Remote Work Productivity: A Complete Guide\n\n...expanded version..."}',
  'edited', 'Expanded the time management section with more examples', 'user_001', true
);

此时用户可以查看完整的版本历史,对比三个版本的差异,也可以回滚到任何一个历史版本。

案例:AI 图片生成工具

一个 AI 图片生成平台,支持文生图、图生图、风格迁移。与写作工具相比,图片生成的输入输出格式完全不同,记录表的设计需要适配这些差异。

产品配置

INSERT INTO products (id, name, slug, category, status, config, pricing_type, unit_cost)
VALUES (
  'prod_image_001',
  'AI Image Generator',
  'ai-image-generator',
  'image',
  'active',
  '{
    "models": ["dall-e-3", "stable-diffusion-xl", "midjourney-v6"],
    "default_model": "dall-e-3",
    "features": {
      "text_to_image": true,
      "image_to_image": true,
      "style_transfer": true,
      "inpainting": false
    },
    "limits": {
      "max_images_per_request": 4,
      "supported_sizes": ["512x512", "1024x1024", "1792x1024"],
      "supported_formats": ["png", "webp", "jpg"],
      "max_upload_size_mb": 10
    },
    "defaults": {
      "size": "1024x1024",
      "format": "png",
      "style": "photorealistic",
      "count": 1
    }
  }'::jsonb,
  'credit_based',
  0.0400
);

生成记录示例

-- 文生图请求
INSERT INTO generations (id, user_id, product_id, status, input_params)
VALUES (
  'gen_image_001',
  'user_002',
  'prod_image_001',
  'processing',
  '{
    "action": "text_to_image",
    "prompt": "A serene Japanese garden with cherry blossoms, koi pond, and traditional wooden bridge",
    "negative_prompt": "blurry, low quality, distorted",
    "size": "1792x1024",
    "style": "photorealistic",
    "count": 4,
    "seed": -1
  }'::jsonb
);
 
-- 生成完成后更新
UPDATE generations SET
  status = 'completed',
  output_result = '{
    "images": [
      {"url": "https://cdn.example.com/gen/gen_image_001/img_1.webp", "seed": 48291},
      {"url": "https://cdn.example.com/gen/gen_image_001/img_2.webp", "seed": 48292},
      {"url": "https://cdn.example.com/gen/gen_image_001/img_3.webp", "seed": 48293},
      {"url": "https://cdn.example.com/gen/gen_image_001/img_4.webp", "seed": 48294}
    ],
    "revised_prompt": "A serene Japanese garden with cherry blossoms in full bloom...",
    "model": "dall-e-3"
  }'::jsonb,
  model_used = 'dall-e-3',
  cost_credits = 16,
  cost_amount = 0.064000,
  latency_ms = 12000,
  completed_at = now()
WHERE id = 'gen_image_001';

版本历史示例

用户对其中一张图片做风格迁移:

-- 原始版本
INSERT INTO product_versions (generation_id, version_number, content, change_type, change_summary, created_by, is_current)
VALUES (
  'gen_image_001', 1,
  '{"selected_image": 0, "url": "https://cdn.example.com/gen/gen_image_001/img_1.webp", "seed": 48291}',
  'created', 'Initial generation - selected image 1', 'user_002', true
);
 
-- 风格迁移
UPDATE product_versions SET is_current = false WHERE generation_id = 'gen_image_001' AND is_current = true;
 
INSERT INTO product_versions (generation_id, version_number, content, change_type, change_summary, created_by, is_current)
VALUES (
  'gen_image_001', 2,
  '{"selected_image": 0, "url": "https://cdn.example.com/gen/gen_image_001/img_1_v2.webp", "style": "watercolor", "seed": 48291}',
  'regenerated', 'Applied watercolor style transfer', 'user_002', true
);

图片类产品的版本管理有一个特殊之处:每个版本关联的是具体的图片 URL,而不是内容文本。因此版本对比不是文字 diff,而是图片的视觉对比。前端需要展示缩略图和时间线,让用户直观地看到每次变更的效果。

产品功能表字段对比

三张表核心字段对比

维度产品表生成记录表版本历史表
主键id (UUID)id (UUID)id (UUID)
核心外键user_id, product_idgeneration_id
状态字段status (产品生命周期)status (任务执行状态)无状态字段(不可变记录)
内容存储config (JSONB)input_params + output_result (JSONB)content (JSONB)
时间字段created_at, updated_atcreated_at, completed_atcreated_at
更新频率低(产品配置变更少)高(每次 AI 调用一条)中(用户编辑时新增)
数据量级小(几十到几百条)大(随用户量线性增长)中(与编辑频率相关)
是否需要索引按 slug 查询按用户、产品、时间查询按 generation_id 查询

三张表 JSONB 字段对比

维度产品表 config生成记录表 input_params生成记录表 output_result版本历史表 content
写入时机产品创建 / 配置变更用户提交请求时模型返回结果时每次内容变更时
内容特征模型参数、功能开关用户输入、风格选项生成结果、资源 URL完整内容快照
格式稳定性高(配置结构相对固定)中(不同功能输入不同)中(不同功能输出不同)高(同一生成记录的版本格式一致)
是否可修改可修改不可修改(审计记录)不可修改(审计记录)不可修改(历史快照)
典型大小0.5 - 2 KB0.2 - 1 KB1 - 100 KB(文本 / 图片 URL)1 - 50 KB

索引策略对比

索引用途类型
productsslug UNIQUE按 slug 查询产品详情唯一索引
productscategory, status按分类和状态筛选产品列表复合索引
generationsuser_id, created_at DESC查询用户历史记录复合索引
generationsproduct_id, created_at DESC按产品统计用量复合索引
generationsstatus WHERE pending/processingWorker 轮询待处理任务部分索引
product_versionsgeneration_id, version_number按版本号查询特定版本唯一复合索引
product_versionsgeneration_id, is_current快速找到当前版本复合索引

不同 AI 产品类型的表结构适配

产品类型产品表 config 重点生成记录表 input_params 重点生成记录表 output_result 重点版本历史表 content 重点
写作工具模型、token 限制、支持语言prompt、语气、语言、字数文本内容、字数、阅读时间完整文章 JSON
图片生成模型、尺寸、格式、风格prompt、尺寸、风格、数量图片 URL 列表、seed 值图片 URL + 参数
语音转文字模型、支持语言、采样率音频 URL、语言、格式转录文本、时间戳、置信度完整转录文本
代码生成模型、编程语言、框架prompt、语言、上下文代码代码、解释、文件结构完整代码 + 说明
数据分析模型、数据源配置查询问题、数据集 IDSQL 语句、图表、结论分析报告 JSON

设计检查清单

在产品功能表上线之前,逐项检查以下内容:

产品表

  • 产品状态机是否完整(draft → active → deprecated → archived)
  • config 字段是否有 JSON Schema 校验,防止非法配置写入
  • slug 字段是否设置了唯一约束,且全局不可重复
  • 计费类型(pricing_type)是否与计费系统的扣费逻辑对齐

生成记录表

  • 状态机流转是否有数据库层面的 CHECK 约束或应用层守卫
  • input_params 是否在写入后不可修改(审计要求)
  • 是否为 user_id + created_at 建立了复合索引
  • 是否为 Worker 轮询设置了部分索引(WHERE status IN ('pending', 'processing')
  • 是否有超时机制:processing 状态超过一定时间自动标记为 failed
  • token 数和成本字段是否在 completed 状态时必填

版本历史表

  • 版本号是否通过 UNIQUE(generation_id, version_number) 保证唯一
  • is_current 标记是否在创建新版本时正确更新(同一 generation_id 下有且仅有一个 true)
  • 版本内容是否为全量快照(而非增量差异),确保查询时不需要合并
  • created_by 是否区分了用户操作和系统操作
  • 是否有版本数量上限策略,防止单条生成记录关联过多版本

关联与性能

  • 外键约束是否在开发环境开启、生产环境根据性能需求决定是否关闭
  • 是否有定期归档策略(如 90 天前的 completed 记录迁移到归档表)
  • 大表是否考虑了分区(按时间分区 generations 表)

小结

产品功能表的设计决定了 AI 工具产品的数据根基。产品表管理「能做什么」,生成记录表记录「做了什么」,版本历史表追溯「怎么变的」。三者通过清晰的关联关系形成完整的数据链路,支撑起计费、用量统计、历史回溯等核心业务。

几个关键的设计原则值得反复确认:

第一,灵活字段用 JSONB,稳定字段用列。产品配置、输入参数、输出结果的结构会随功能迭代不断变化,用 JSONB 存储可以避免频繁的 schema 变更。

第二,生成记录一旦写入就不修改。输入参数和输出结果都是审计数据,如果需要重试,创建新的记录而不是更新旧记录。

第三,版本历史用全量快照。对于 AI 工具的产品规模,全量快照的存储成本完全可以接受,换来的是最简单的查询逻辑和最低的维护成本。

第四,索引设计跟着查询走。先搞清楚业务会怎么查数据,再决定建什么索引。不要用「建一堆索引总没坏处」的思路,每个索引都有写入成本和存储开销。

下一篇文章,我们讨论如何设计计费系统的数据表——将产品表中的定价配置与生成记录中的消耗数据关联起来,实现完整的计费闭环。

参考资料

  1. PostgreSQL JSON/JSONB 数据类型文档
  2. Prisma Schema 关系定义文档
  3. Stack Overflow: SQL History Table Design
  4. Dev.to: Design a Table to Keep Historical Changes in Database
  5. SchematicHQ: AI Credits - How They Work, Pricing Models, and Implementation
  6. FinOps Foundation: How to Build a Generative AI Cost and Usage Tracker
  7. Artie: History Table Design Guide For CDC Pipelines
  8. C# Corner: Designing a Data Versioning System