如何为文章翻译建版本并保持关联关系?
结论
建议将文章的稳定身份与各个版本分开存储,并让每条翻译关联到具体版本。这样,正式版本和草稿都有各自完整的翻译集合,提交或丢弃草稿时,也可以在同一事务中处理文章和翻译。
题目中的 post_translations.post_id 指向文章身份,因此每种语言只能有一条翻译。若要同时保留翻译的草稿和正式版本,翻译应改为关联版本记录,例如使用 post_version_id。
表结构
posts 表只保存文章的稳定 ID。标题和删除标记放在版本表中,翻译则通过版本 ID 关联到对应版本。
CREATE TABLE posts (
id int4 PRIMARY KEY
);
CREATE TABLE post_versions (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
post_id int4 NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
version_no int4 NOT NULL,
state text NOT NULL CHECK (state IN ('draft', 'live', 'archived')),
title varchar,
is_deleted boolean NOT NULL DEFAULT false,
UNIQUE (post_id, version_no)
);
CREATE UNIQUE INDEX post_versions_one_draft_per_post
ON post_versions (post_id)
WHERE state = 'draft';
CREATE UNIQUE INDEX post_versions_one_live_per_post
ON post_versions (post_id)
WHERE state = 'live';
CREATE TABLE post_translations (
post_version_id bigint NOT NULL
REFERENCES post_versions(id) ON DELETE CASCADE,
language_code char(2) NOT NULL,
content text DEFAULT NULL,
PRIMARY KEY (post_version_id, language_code)
);
两个部分唯一索引分别保证每篇文章最多有一个草稿和一个正式版本。version_no 表示同一篇文章的版本顺序;archived 用来保留被新正式版本替换的旧版本。若不需要保留历史版本,可以在提交时删除旧正式版本,并相应调整状态和索引设计。
创建草稿
创建草稿时,应复制当前正式版本的文章字段和全部翻译。草稿以完整快照为起点,之后增删改草稿翻译都不会影响正式版本。
下面的 SQL 展示了如何复制翻译。实际应用中,还需要在同一事务里插入草稿版本,并为 :draft_version_id 提供新版本 ID:
INSERT INTO post_translations (post_version_id, language_code, content)
SELECT :draft_version_id, language_code, content
FROM post_translations
WHERE post_version_id = :live_version_id;
修改草稿内容时,按 (post_version_id, language_code) 更新对应翻译。删除某种语言的草稿翻译时,只删除与草稿版本关联的行:
DELETE FROM post_translations
WHERE post_version_id = :draft_version_id
AND language_code = :language_code;
文章已有草稿时,应继续更新这份草稿,不要再插入第二条。数据库中的部分唯一索引可以防止并发操作意外创建多个草稿。
查询正式版本及其翻译
使用 LEFT JOIN 后,没有翻译的正式文章也会出现在结果中:
SELECT v.post_id, v.id AS version_id, v.title,
t.language_code, t.content
FROM post_versions AS v
LEFT JOIN post_translations AS t
ON t.post_version_id = v.id
WHERE v.state = 'live'
AND NOT v.is_deleted;
如果需要查看正式版本中的删除标记,可以去掉 AND NOT v.is_deleted,再由调用方决定如何呈现已删除的文章。
查询最新版本及其全部翻译
按题目中的规则,有草稿时选草稿,否则选正式版本。先选出每篇文章的版本,再关联翻译,可以避免版本选择受到翻译行数的影响:
WITH current_versions AS (
SELECT DISTINCT ON (post_id)
id, post_id, title, is_deleted, state
FROM post_versions
WHERE state IN ('draft', 'live')
ORDER BY post_id,
CASE state WHEN 'draft' THEN 0 ELSE 1 END,
version_no DESC
)
SELECT v.post_id, v.id AS version_id, v.state, v.title,
t.language_code, t.content
FROM current_versions AS v
LEFT JOIN post_translations AS t
ON t.post_version_id = v.id
WHERE NOT v.is_deleted;
WHERE NOT v.is_deleted 会隐藏最新版本标记为删除的文章。若管理界面需要显示待删除草稿,可以移除这个条件,并在结果中检查 is_deleted。
提交或丢弃草稿
提交草稿时,在一个事务中先将旧正式版本归档,再把草稿设为正式版本。可以先锁定 posts 中对应的行,让同一篇文章的并发编辑按预期串行处理:
BEGIN;
SELECT id
FROM posts
WHERE id = :post_id
FOR UPDATE;
UPDATE post_versions
SET state = 'archived'
WHERE post_id = :post_id
AND state = 'live';
UPDATE post_versions
SET state = 'live'
WHERE post_id = :post_id
AND state = 'draft';
COMMIT;
如果草稿带有删除标记,提交后它会成为 is_deleted = true 的正式版本;读取有效文章时应过滤该标记。丢弃草稿时,删除对应的草稿版本即可。由于外键设置了 ON DELETE CASCADE,相关翻译也会一并删除:
DELETE FROM post_versions
WHERE post_id = :post_id
AND state = 'draft';
如果新文章可以在还没有正式版本时创建草稿,就需要明确如何处理丢弃唯一草稿后的空 posts 记录。可以在事务中一并清理,也可以保留记录供后续创建使用。
注意事项
post_version_id表示翻译所属的具体版本。查询正式版本时使用正式版本 ID,查询最新版本时使用选出的版本 ID。- 创建草稿时应复制完整的翻译集合,这样删除某种语言只会影响草稿。若改用按需复制的差异存储,查询和删除逻辑会更复杂。
- 提交时应在同一事务中变更状态。部分唯一索引会限制每篇文章同时最多有一个正式版本和一个草稿。
- 现有的
draft_id自引用设计也可以保留,但翻译表仍需区分翻译属于正式文章行还是草稿文章行。明确区分稳定文章 ID、版本 ID 和翻译外键,通常更容易表达查询和生命周期规则。
备注:内容仅供参考。
