如何设计产品功能表
产品功能表是业务的核心,决定了产品「能做什么」和「做了什么」。对于 AI 工具类产品来说,这一点尤其明显——用户每一次调用模型、每一次生成内容,都需要在数据库里留下完整的记录。这不仅是业务数据的存储问题,更直接影响产品的计费逻辑、用量统计、版本回溯和用户体验。
很多独立开发者在项目初期容易忽视表结构设计,用一个「万能表」或几个简陋的字段就把业务跑起来。等到用户量上来之后,才发现生成记录无法追溯、版本历史无法回滚、产品配置无法灵活调整,最终不得不做大规模的数据迁移。
本文以 AI 工具类产品为例,系统讲解三张核心表的设计思路:产品表、生成记录表、版本历史表。从字段定义到关联关系,从 SQL 建表到 Prisma Schema,从设计原则到实际案例,帮助你建立一套可扩展的产品功能表体系。
产品表设计
产品表是 AI 工具的「商品目录」,记录的是平台提供给用户的能力单元。它可以是一个 AI 写作助手、一个图片生成器、一个语音转文字工具,也可以是同一工具下的不同功能模式。
核心字段
产品表需要回答三个问题:这个产品是什么、它有什么能力、当前处于什么状态。
| 字段名 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | UUID / BIGINT | 是 | 主键,推荐使用 UUID 避免枚举 |
name | VARCHAR(100) | 是 | 产品名称,如「AI 文章生成器」 |
slug | VARCHAR(100) | 是 | URL 友好标识,如 ai-article-writer |
description | TEXT | 否 | 产品描述,面向用户展示 |
category | VARCHAR(50) | 是 | 产品分类:writing、image、audio、video |
status | ENUM | 是 | 产品状态:draft、active、deprecated、archived |
config | JSONB | 否 | 产品配置,存储模型参数、功能开关等灵活字段 |
pricing_type | ENUM | 是 | 计费方式:free、per_use、subscription、credit_based |
unit_cost | DECIMAL(10,4) | 否 | 单次调用成本(按计费类型解释) |
icon_url | VARCHAR(500) | 否 | 产品图标 URL |
sort_order | INT | 否 | 排序权重,控制前端展示顺序 |
created_at | TIMESTAMP | 是 | 创建时间 |
updated_at | TIMESTAMP | 是 | 更新时间 |
为什么用 JSONB 存配置?
AI 产品的配置差异很大。一个写作工具可能需要配置 max_tokens、temperature、supported_languages;一个图片生成工具可能需要配置 default_size、supported_formats、style_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 能力的完整信息,包括输入参数、输出结果、消耗的资源和执行状态。这张表的数据量和查询频率通常远高于其他业务表。
核心字段
| 字段名 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | UUID / BIGINT | 是 | 主键 |
user_id | UUID / BIGINT | 是 | 发起生成的用户 |
product_id | UUID / BIGINT | 是 | 使用的产品 |
status | ENUM | 是 | 执行状态:pending、processing、completed、failed、cancelled |
input_params | JSONB | 是 | 用户输入的参数(prompt、配置选项等) |
output_result | JSONB | 否 | 生成结果(文本、URL、结构化数据) |
model_used | VARCHAR(100) | 否 | 实际使用的模型标识 |
tokens_input | INT | 否 | 输入 token 数(文本类 AI) |
tokens_output | INT | 否 | 输出 token 数 |
cost_credits | INT | 否 | 本次消耗的积分 / 点数 |
cost_amount | DECIMAL(10,6) | 否 | 本次消耗的金额(美元) |
latency_ms | INT | 否 | 请求耗时(毫秒) |
error_message | TEXT | 否 | 失败时的错误信息 |
metadata | JSONB | 否 | 扩展元数据(IP、User-Agent、来源页面等) |
created_at | TIMESTAMP | 是 | 创建时间 |
completed_at | TIMESTAMP | 否 | 完成时间 |
为什么输入和输出都用 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 工具的核心业务流程:
状态流转中的关键约束:
pending→processing:任务被 Worker 取出时触发,记录开始时间processing→completed:模型返回结果后触发,记录 token 数和耗时processing→failed:超时、模型错误、内容审核不通过等场景failed→processing:用户点击「重试」,创建新的记录而不是更新旧记录(保持审计轨迹)
索引设计建议
生成记录表的查询模式通常包括:按用户查看历史记录、按产品统计用量、按时间范围查询。索引设计应覆盖这些场景:
-- 按用户查询历史记录(最常见)
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 工具生成的内容往往不是一次成型的。用户可能先生成一篇草稿,然后要求改写、扩展、调整语气,每一次修改都会产生一个新的版本。版本历史表的任务就是记录内容的每一次变更,支持回滚和对比。
核心字段
| 字段名 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | UUID / BIGINT | 是 | 主键 |
generation_id | UUID / BIGINT | 是 | 关联的生成记录 |
version_number | INT | 是 | 版本号,从 1 开始递增 |
content | TEXT / JSONB | 是 | 该版本的内容快照 |
content_hash | VARCHAR(64) | 否 | 内容哈希值,用于快速判断是否有变化 |
change_type | ENUM | 是 | 变更类型:created、edited、regenerated、restored |
change_summary | VARCHAR(500) | 否 | 变更摘要,如「调整了语气为正式」 |
diff_data | JSONB | 否 | 与上一版本的差异数据 |
created_by | UUID / BIGINT | 是 | 操作人(用户或系统) |
is_current | BOOLEAN | 是 | 是否为当前生效版本 |
created_at | TIMESTAMP | 是 | 创建时间 |
版本管理策略
版本历史表有三种常见的实现策略,各有适用场景:
策略一:全量快照
每个版本保存完整的内容副本。优点是查询简单——直接读取某个版本就能获得完整内容;缺点是存储空间消耗大,特别是当内容很长时。
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 关联关系图
这个关系图揭示了一个重要的设计决策:版本历史表关联的是 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_id | generation_id |
| 状态字段 | status (产品生命周期) | status (任务执行状态) | 无状态字段(不可变记录) |
| 内容存储 | config (JSONB) | input_params + output_result (JSONB) | content (JSONB) |
| 时间字段 | created_at, updated_at | created_at, completed_at | created_at |
| 更新频率 | 低(产品配置变更少) | 高(每次 AI 调用一条) | 中(用户编辑时新增) |
| 数据量级 | 小(几十到几百条) | 大(随用户量线性增长) | 中(与编辑频率相关) |
| 是否需要索引 | 按 slug 查询 | 按用户、产品、时间查询 | 按 generation_id 查询 |
三张表 JSONB 字段对比
| 维度 | 产品表 config | 生成记录表 input_params | 生成记录表 output_result | 版本历史表 content |
|---|---|---|---|---|
| 写入时机 | 产品创建 / 配置变更 | 用户提交请求时 | 模型返回结果时 | 每次内容变更时 |
| 内容特征 | 模型参数、功能开关 | 用户输入、风格选项 | 生成结果、资源 URL | 完整内容快照 |
| 格式稳定性 | 高(配置结构相对固定) | 中(不同功能输入不同) | 中(不同功能输出不同) | 高(同一生成记录的版本格式一致) |
| 是否可修改 | 可修改 | 不可修改(审计记录) | 不可修改(审计记录) | 不可修改(历史快照) |
| 典型大小 | 0.5 - 2 KB | 0.2 - 1 KB | 1 - 100 KB(文本 / 图片 URL) | 1 - 50 KB |
索引策略对比
| 表 | 索引 | 用途 | 类型 |
|---|---|---|---|
| products | slug UNIQUE | 按 slug 查询产品详情 | 唯一索引 |
| products | category, status | 按分类和状态筛选产品列表 | 复合索引 |
| generations | user_id, created_at DESC | 查询用户历史记录 | 复合索引 |
| generations | product_id, created_at DESC | 按产品统计用量 | 复合索引 |
| generations | status WHERE pending/processing | Worker 轮询待处理任务 | 部分索引 |
| product_versions | generation_id, version_number | 按版本号查询特定版本 | 唯一复合索引 |
| product_versions | generation_id, is_current | 快速找到当前版本 | 复合索引 |
不同 AI 产品类型的表结构适配
| 产品类型 | 产品表 config 重点 | 生成记录表 input_params 重点 | 生成记录表 output_result 重点 | 版本历史表 content 重点 |
|---|---|---|---|---|
| 写作工具 | 模型、token 限制、支持语言 | prompt、语气、语言、字数 | 文本内容、字数、阅读时间 | 完整文章 JSON |
| 图片生成 | 模型、尺寸、格式、风格 | prompt、尺寸、风格、数量 | 图片 URL 列表、seed 值 | 图片 URL + 参数 |
| 语音转文字 | 模型、支持语言、采样率 | 音频 URL、语言、格式 | 转录文本、时间戳、置信度 | 完整转录文本 |
| 代码生成 | 模型、编程语言、框架 | prompt、语言、上下文代码 | 代码、解释、文件结构 | 完整代码 + 说明 |
| 数据分析 | 模型、数据源配置 | 查询问题、数据集 ID | SQL 语句、图表、结论 | 分析报告 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 工具的产品规模,全量快照的存储成本完全可以接受,换来的是最简单的查询逻辑和最低的维护成本。
第四,索引设计跟着查询走。先搞清楚业务会怎么查数据,再决定建什么索引。不要用「建一堆索引总没坏处」的思路,每个索引都有写入成本和存储开销。
下一篇文章,我们讨论如何设计计费系统的数据表——将产品表中的定价配置与生成记录中的消耗数据关联起来,实现完整的计费闭环。
参考资料
- PostgreSQL JSON/JSONB 数据类型文档
- Prisma Schema 关系定义文档
- Stack Overflow: SQL History Table Design
- Dev.to: Design a Table to Keep Historical Changes in Database
- SchematicHQ: AI Credits - How They Work, Pricing Models, and Implementation
- FinOps Foundation: How to Build a Generative AI Cost and Usage Tracker
- Artie: History Table Design Guide For CDC Pipelines
- C# Corner: Designing a Data Versioning System