名称: 内容版本控制 描述: 当实现草稿/发布工作流、版本历史、内容回滚或审计追踪时使用。涵盖版本控制策略、快照存储、差异生成和无头CMS的版本比较API。 允许工具: 读取, 全局, 搜索, 任务, 技能
内容版本控制
为CMS内容实现版本控制、草稿/发布工作流和审计追踪的指导。
何时使用此技能
- 实现草稿/发布工作流
- 为内容类型添加版本历史
- 构建内容回滚功能
- 创建合规审计追踪
- 比较内容版本
版本控制策略
策略1: 分离草稿/发布记录
public class ContentItem
{
public Guid Id { get; set; }
public string ContentType { get; set; } = string.Empty;
public ContentStatus Status { get; set; }
// 版本追踪
public int Version { get; set; }
public Guid? PublishedVersionId { get; set; }
public Guid? DraftVersionId { get; set; }
// 时间戳
public DateTime CreatedUtc { get; set; }
public DateTime ModifiedUtc { get; set; }
public DateTime? PublishedUtc { get; set; }
}
public class ContentVersion
{
public Guid Id { get; set; }
public Guid ContentItemId { get; set; }
public int VersionNumber { get; set; }
// 此版本的内容快照
public string DataJson { get; set; } = string.Empty;
// 元数据
public string CreatedBy { get; set; } = string.Empty;
public DateTime CreatedUtc { get; set; }
public string? ChangeNote { get; set; }
public bool IsPublished { get; set; }
}
public enum ContentStatus
{
草稿,
已发布,
未发布,
已归档
}
策略2: 历史表模式
// 当前内容(始终最新)
public class Article
{
public Guid Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public int CurrentVersion { get; set; }
public ContentStatus Status { get; set; }
}
// 自动历史追踪
public class ArticleHistory
{
public Guid Id { get; set; }
public Guid ArticleId { get; set; }
public int VersionNumber { get; set; }
// 此版本的所有字段副本
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
// 审计信息
public DateTime ValidFrom { get; set; }
public DateTime ValidTo { get; set; }
public string ModifiedBy { get; set; } = string.Empty;
public ChangeType ChangeType { get; set; }
}
public enum ChangeType
{
创建,
更新,
发布,
取消发布,
删除
}
策略3: 事件溯源
public abstract class ContentEvent
{
public Guid Id { get; set; }
public Guid ContentItemId { get; set; }
public DateTime OccurredUtc { get; set; }
public string UserId { get; set; } = string.Empty;
public int SequenceNumber { get; set; }
}
public class ContentCreatedEvent : ContentEvent
{
public string ContentType { get; set; } = string.Empty;
public string InitialDataJson { get; set; } = string.Empty;
}
public class ContentUpdatedEvent : ContentEvent
{
public Dictionary<string, FieldChange> Changes { get; set; } = new();
}
public class ContentPublishedEvent : ContentEvent
{
public int PublishedVersion { get; set; }
}
public class FieldChange
{
public object? OldValue { get; set; }
public object? NewValue { get; set; }
}
草稿/发布工作流
基本实现
public class ContentPublishingService
{
public async Task<ContentItem> CreateDraftAsync(
string contentType,
object data,
string userId)
{
var item = new ContentItem
{
Id = Guid.NewGuid(),
ContentType = contentType,
Status = ContentStatus.Draft,
Version = 1,
CreatedUtc = DateTime.UtcNow,
ModifiedUtc = DateTime.UtcNow
};
var version = new ContentVersion
{
Id = Guid.NewGuid(),
ContentItemId = item.Id,
VersionNumber = 1,
DataJson = JsonSerializer.Serialize(data),
CreatedBy = userId,
CreatedUtc = DateTime.UtcNow,
IsPublished = false
};
item.DraftVersionId = version.Id;
await _repository.AddAsync(item);
await _versionRepository.AddAsync(version);
return item;
}
public async Task PublishAsync(Guid contentItemId, string userId)
{
var item = await _repository.GetAsync(contentItemId);
if (item == null || item.DraftVersionId == null)
throw new InvalidOperationException("没有草稿可发布");
var draft = await _versionRepository.GetAsync(item.DraftVersionId.Value);
// 从草稿创建发布版本
var published = new ContentVersion
{
Id = Guid.NewGuid(),
ContentItemId = item.Id,
VersionNumber = item.Version + 1,
DataJson = draft!.DataJson,
CreatedBy = userId,
CreatedUtc = DateTime.UtcNow,
IsPublished = true
};
await _versionRepository.AddAsync(published);
// 更新内容项
item.Version = published.VersionNumber;
item.PublishedVersionId = published.Id;
item.Status = ContentStatus.Published;
item.PublishedUtc = DateTime.UtcNow;
item.ModifiedUtc = DateTime.UtcNow;
await _repository.UpdateAsync(item);
// 触发事件
await _mediator.Publish(new ContentPublishedEvent(item.Id));
}
public async Task UnpublishAsync(Guid contentItemId, string userId)
{
var item = await _repository.GetAsync(contentItemId);
if (item == null)
throw new InvalidOperationException("内容未找到");
item.Status = ContentStatus.Unpublished;
item.PublishedVersionId = null;
item.ModifiedUtc = DateTime.UtcNow;
await _repository.UpdateAsync(item);
await _mediator.Publish(new ContentUnpublishedEvent(item.Id));
}
}
同时存在草稿和发布
public class ContentQueryService
{
public async Task<ContentVersion?> GetPublishedAsync(Guid contentItemId)
{
var item = await _repository.GetAsync(contentItemId);
if (item?.PublishedVersionId == null)
return null;
return await _versionRepository.GetAsync(item.PublishedVersionId.Value);
}
public async Task<ContentVersion?> GetDraftAsync(Guid contentItemId)
{
var item = await _repository.GetAsync(contentItemId);
if (item?.DraftVersionId == null)
return null;
return await _versionRepository.GetAsync(item.DraftVersionId.Value);
}
public async Task<ContentVersion?> GetLatestAsync(
Guid contentItemId,
bool preferDraft = false)
{
var item = await _repository.GetAsync(contentItemId);
if (item == null) return null;
if (preferDraft && item.DraftVersionId != null)
return await _versionRepository.GetAsync(item.DraftVersionId.Value);
if (item.PublishedVersionId != null)
return await _versionRepository.GetAsync(item.PublishedVersionId.Value);
return null;
}
}
版本历史
检索历史
public async Task<List<ContentVersionSummary>> GetVersionHistoryAsync(
Guid contentItemId,
int page = 1,
int pageSize = 20)
{
return await _context.ContentVersions
.Where(v => v.ContentItemId == contentItemId)
.OrderByDescending(v => v.VersionNumber)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(v => new ContentVersionSummary
{
Id = v.Id,
VersionNumber = v.VersionNumber,
CreatedBy = v.CreatedBy,
CreatedUtc = v.CreatedUtc,
ChangeNote = v.ChangeNote,
IsPublished = v.IsPublished
})
.ToListAsync();
}
回滚
public async Task RollbackToVersionAsync(
Guid contentItemId,
int targetVersion,
string userId)
{
var item = await _repository.GetAsync(contentItemId);
var targetVersionRecord = await _versionRepository
.GetByVersionNumberAsync(contentItemId, targetVersion);
if (item == null || targetVersionRecord == null)
throw new InvalidOperationException("无效的回滚目标");
// 从旧数据创建新版本
var rollbackVersion = new ContentVersion
{
Id = Guid.NewGuid(),
ContentItemId = item.Id,
VersionNumber = item.Version + 1,
DataJson = targetVersionRecord.DataJson,
CreatedBy = userId,
CreatedUtc = DateTime.UtcNow,
ChangeNote = $"回滚到版本 {targetVersion}",
IsPublished = false
};
await _versionRepository.AddAsync(rollbackVersion);
item.Version = rollbackVersion.VersionNumber;
item.DraftVersionId = rollbackVersion.Id;
item.ModifiedUtc = DateTime.UtcNow;
await _repository.UpdateAsync(item);
}
版本比较
生成差异
public class VersionComparisonService
{
public VersionDiff Compare(ContentVersion older, ContentVersion newer)
{
var oldData = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
older.DataJson);
var newData = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
newer.DataJson);
var diff = new VersionDiff
{
OlderVersion = older.VersionNumber,
NewerVersion = newer.VersionNumber
};
// 查找新增字段
foreach (var key in newData!.Keys.Except(oldData!.Keys))
{
diff.Changes.Add(new FieldDiff
{
FieldName = key,
ChangeType = DiffChangeType.Added,
NewValue = newData[key].ToString()
});
}
// 查找移除字段
foreach (var key in oldData.Keys.Except(newData.Keys))
{
diff.Changes.Add(new FieldDiff
{
FieldName = key,
ChangeType = DiffChangeType.Removed,
OldValue = oldData[key].ToString()
});
}
// 查找修改字段
foreach (var key in oldData.Keys.Intersect(newData.Keys))
{
var oldJson = oldData[key].ToString();
var newJson = newData[key].ToString();
if (oldJson != newJson)
{
diff.Changes.Add(new FieldDiff
{
FieldName = key,
ChangeType = DiffChangeType.Modified,
OldValue = oldJson,
NewValue = newJson
});
}
}
return diff;
}
}
public class VersionDiff
{
public int OlderVersion { get; set; }
public int NewerVersion { get; set; }
public List<FieldDiff> Changes { get; set; } = new();
}
public class FieldDiff
{
public string FieldName { get; set; } = string.Empty;
public DiffChangeType ChangeType { get; set; }
public string? OldValue { get; set; }
public string? NewValue { get; set; }
}
public enum DiffChangeType
{
新增,
移除,
修改
}
审计追踪
审计日志条目
public class ContentAuditEntry
{
public Guid Id { get; set; }
public Guid ContentItemId { get; set; }
public string ContentType { get; set; } = string.Empty;
public string Action { get; set; } = string.Empty; // 创建, 更新, 发布等
public string UserId { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public DateTime OccurredUtc { get; set; }
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public string? ChangeSummary { get; set; }
public string? DataBefore { get; set; }
public string? DataAfter { get; set; }
}
自动审计日志记录
public class AuditInterceptor : SaveChangesInterceptor
{
private readonly ICurrentUserService _currentUser;
private readonly IHttpContextAccessor _httpContext;
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
var context = eventData.Context;
if (context == null) return result;
var entries = context.ChangeTracker.Entries<ContentItem>()
.Where(e => e.State is EntityState.Added
or EntityState.Modified
or EntityState.Deleted);
foreach (var entry in entries)
{
var audit = new ContentAuditEntry
{
Id = Guid.NewGuid(),
ContentItemId = entry.Entity.Id,
ContentType = entry.Entity.ContentType,
Action = entry.State.ToString(),
UserId = _currentUser.UserId,
UserName = _currentUser.UserName,
OccurredUtc = DateTime.UtcNow,
IpAddress = _httpContext.HttpContext?.Connection.RemoteIpAddress?.ToString()
};
if (entry.State == EntityState.Modified)
{
audit.DataBefore = JsonSerializer.Serialize(
entry.OriginalValues.ToObject());
audit.DataAfter = JsonSerializer.Serialize(
entry.CurrentValues.ToObject());
}
context.Set<ContentAuditEntry>().Add(audit);
}
return result;
}
}
API设计
版本端点
GET /api/content/{id}/versions # 列出版本历史
GET /api/content/{id}/versions/{version} # 获取特定版本
GET /api/content/{id}/versions/compare?v1=1&v2=2 # 比较版本
POST /api/content/{id}/versions/{version}/restore # 回滚
GET /api/content/{id}/audit # 审计追踪
相关技能
content-type-modeling- 可版本化的内容类型content-workflow- 编辑审批工作流headless-api-design- 版本API端点