内容版本控制Skill content-versioning

这个技能提供在内容管理系统(CMS)中实现版本控制、草稿/发布工作流和审计追踪的全面指导。包括版本策略、快照存储、差异比较和API设计,适用于无头CMS,方便SEO搜索:内容版本控制、CMS、API设计、审计追踪。

后端开发 0 次安装 0 次浏览 更新于 3/11/2026

名称: 内容版本控制 描述: 当实现草稿/发布工作流、版本历史、内容回滚或审计追踪时使用。涵盖版本控制策略、快照存储、差异生成和无头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端点