无头API设计Skill headless-api-design

这个技能用于设计无头CMS架构中的内容交付API,支持多通道内容分发,涵盖REST和GraphQL API模式、内容预览端点、本地化策略、分页过滤、缓存头部和API版本化,关键词包括无头CMS、API设计、内容交付、REST、GraphQL、本地化、缓存、版本控制。

架构设计 0 次安装 0 次浏览 更新于 3/11/2026

名称: 无头API设计 描述: 用于为无头CMS架构设计内容交付API。涵盖REST和GraphQL API模式、内容预览端点、本地化策略、分页、过滤、缓存头部以及多通道内容交付的API版本化。 允许的工具: 读取、全局搜索、筛选、任务、技能

无头API设计

为无头CMS架构设计内容交付API的指南,实现多通道内容分发。

何时使用此技能

  • 设计用于内容交付的REST或GraphQL API
  • 实现草稿内容的预览端点
  • 向内容API添加本地化/国际化
  • 规划分页和过滤策略
  • 配置内容的缓存头部
  • 版本化内容API

API架构概述

无头CMS API层

┌─────────────────────────────────────────────────────────────┐
│                    内容消费者                               │
│  (Blazor, React, Next.js, 移动应用, IoT, 数字标牌)          │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                    内容交付API                              │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │  REST API   │  │ GraphQL API │  │  预览/草稿 API      │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                    内容服务                                 │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │   内容查询  │  │   媒体解析器│  │   本地化服务         │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                    内容仓库                                 │
│              (EF Core + JSON列 + 缓存)                      │
└─────────────────────────────────────────────────────────────┘

REST API设计

资源端点

GET    /api/content                    # 列出所有内容项
GET    /api/content/{id}               # 按ID获取内容
GET    /api/content/alias/{path}       # 按URL路径/别名获取内容
GET    /api/content/types/{type}       # 按类型列出内容

# 类型特定端点
GET    /api/articles                   # 列出文章
GET    /api/articles/{id}              # 获取文章
GET    /api/pages                      # 列出页面
GET    /api/pages/{id}                 # 获取页面

# 嵌套资源
GET    /api/articles/{id}/comments     # 获取文章评论
GET    /api/menus/{id}/items           # 获取菜单项

查询参数

# 分页
?page=1&pageSize=20                    # 偏移分页
?cursor=eyJpZCI6MTIz&limit=20         # 游标分页

# 过滤
?filter[status]=published
?filter[contentType]=Article
?filter[author.id]=abc123
?filter[createdUtc][gte]=2025-01-01

# 排序
?sort=-publishedUtc                    # 降序
?sort=title                            # 升序
?sort=category.name,-createdUtc        # 多字段

# 字段选择(稀疏字段集)
?fields=id,title,slug,publishedUtc
?fields[article]=title,body
?fields[author]=name,avatar

# 包含相关资源
?include=author,categories
?include=author.profile

响应结构

{
  "data": {
    "id": "abc123",
    "type": "Article",
    "attributes": {
      "title": "无头CMS入门",
      "slug": "getting-started-headless-cms",
      "body": "<p>内容在这里...</p>",
      "publishedUtc": "2025-01-15T10:30:00Z",
      "status": "Published"
    },
    "parts": {
      "titlePart": {
        "title": "无头CMS入门"
      },
      "seoPart": {
        "metaTitle": "无头CMS指南",
        "metaDescription": "学习如何..."
      }
    },
    "relationships": {
      "author": {
        "data": { "id": "author456", "type": "Author" }
      },
      "categories": {
        "data": [
          { "id": "cat1", "type": "Category" }
        ]
      }
    }
  },
  "included": [
    {
      "id": "author456",
      "type": "Author",
      "attributes": {
        "name": "Jane Doe",
        "bio": "技术写作者..."
      }
    }
  ],
  "meta": {
    "version": "1.0",
    "generatedAt": "2025-01-15T14:22:00Z"
  }
}

带分页的集合响应

{
  "data": [...],
  "meta": {
    "totalCount": 156,
    "pageSize": 20,
    "currentPage": 1,
    "totalPages": 8
  },
  "links": {
    "self": "/api/articles?page=1&pageSize=20",
    "first": "/api/articles?page=1&pageSize=20",
    "prev": null,
    "next": "/api/articles?page=2&pageSize=20",
    "last": "/api/articles?page=8&pageSize=20"
  }
}

GraphQL API设计

模式定义

type Query {
  # 单个项目查询
  content(id: ID!): ContentItem
  contentByPath(path: String!): ContentItem

  # 类型特定查询
  article(id: ID!): Article
  articles(
    filter: ArticleFilter
    sort: ArticleSort
    first: Int
    after: String
  ): ArticleConnection!

  page(id: ID!): Page
  pages(parentId: ID): [Page!]!

  menu(id: ID, name: String): Menu
}

interface ContentItem {
  id: ID!
  contentType: String!
  displayText: String
  createdUtc: DateTime!
  modifiedUtc: DateTime!
  publishedUtc: DateTime
  status: ContentStatus!
}

type Article implements ContentItem {
  id: ID!
  contentType: String!
  displayText: String
  createdUtc: DateTime!
  modifiedUtc: DateTime!
  publishedUtc: DateTime
  status: ContentStatus!

  # 部分
  titlePart: TitlePart
  autoroutePart: AutoroutePart
  seoPart: SeoMetaPart

  # 字段
  body: String!
  featuredImage: MediaField
  author: Author
  categories: [Category!]!
  tags: [String!]!
  readTimeMinutes: Int
}

type ArticleConnection {
  edges: [ArticleEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type ArticleEdge {
  node: Article!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

input ArticleFilter {
  status: ContentStatus
  categoryId: ID
  authorId: ID
  tags: [String!]
  publishedAfter: DateTime
  publishedBefore: DateTime
  search: String
}

input ArticleSort {
  field: ArticleSortField!
  direction: SortDirection!
}

enum ArticleSortField {
  TITLE
  PUBLISHED_UTC
  CREATED_UTC
  READ_TIME
}

内容部分作为类型

type TitlePart {
  title: String!
  displayTitle: String
}

type AutoroutePart {
  path: String!
  isCustom: Boolean!
}

type SeoMetaPart {
  metaTitle: String
  metaDescription: String
  metaKeywords: String
  noIndex: Boolean!
  noFollow: Boolean!
}

type MediaField {
  paths: [String!]!
  urls: [String!]!
  alt: String
  caption: String
  mediaItems: [MediaItem!]!
}

type MediaItem {
  id: ID!
  url: String!
  mimeType: String!
  width: Int
  height: Int
  alt: String
}

预览API

草稿内容端点

# 需要身份验证/预览令牌
GET /api/preview/content/{id}
GET /api/preview/content/{id}?version={versionId}

# 预览令牌在头部
Authorization: Bearer <preview-token>
X-Preview-Mode: true

预览实现

[ApiController]
[Route("api/preview")]
public class PreviewController : ControllerBase
{
    private readonly IContentService _contentService;
    private readonly IPreviewTokenService _tokenService;

    [HttpGet("content/{id}")]
    public async Task<ActionResult<ContentItemDto>> GetPreview(
        string id,
        [FromHeader(Name = "X-Preview-Token")] string? previewToken,
        [FromQuery] string? version)
    {
        // 验证预览令牌
        if (!await _tokenService.ValidateTokenAsync(previewToken))
        {
            return Unauthorized();
        }

        // 获取草稿或特定版本
        var content = version != null
            ? await _contentService.GetVersionAsync(id, version)
            : await _contentService.GetDraftAsync(id);

        if (content == null)
        {
            return NotFound();
        }

        return Ok(content);
    }
}

预览令牌生成

public class PreviewTokenService : IPreviewTokenService
{
    public string GenerateToken(string contentId, TimeSpan validity)
    {
        var payload = new
        {
            ContentId = contentId,
            ExpiresAt = DateTime.UtcNow.Add(validity),
            Nonce = Guid.NewGuid().ToString("N")
        };

        // 使用HMAC或JWT签名
        return SignPayload(payload);
    }

    public async Task<bool> ValidateTokenAsync(string? token)
    {
        if (string.IsNullOrEmpty(token))
            return false;

        var payload = VerifyAndDecodeToken(token);
        if (payload == null)
            return false;

        return payload.ExpiresAt > DateTime.UtcNow;
    }
}

本地化策略

基于URL的本地化

# 路径前缀(推荐)
GET /api/en/articles
GET /api/fr/articles
GET /api/de-DE/articles

# 查询参数
GET /api/articles?locale=en
GET /api/articles?locale=fr

# Accept-Language头部
Accept-Language: en-US, en;q=0.9, fr;q=0.8

本地化响应结构

{
  "data": {
    "id": "abc123",
    "type": "Article",
    "locale": "en-US",
    "attributes": {
      "title": "入门指南",
      "body": "英文内容..."
    },
    "localizations": {
      "available": ["en-US", "fr-FR", "de-DE"],
      "links": {
        "fr-FR": "/api/fr/articles/abc123",
        "de-DE": "/api/de/articles/abc123"
      }
    }
  }
}

回退链

public class LocalizationService
{
    public async Task<ContentItem?> GetLocalizedContentAsync(
        string id,
        string requestedLocale)
    {
        // 定义回退链
        var fallbackChain = GetFallbackChain(requestedLocale);
        // 例如:["en-GB", "en", "default"]

        foreach (var locale in fallbackChain)
        {
            var content = await _repository
                .GetByIdAndLocaleAsync(id, locale);

            if (content != null)
            {
                return content;
            }
        }

        return null;
    }

    private List<string> GetFallbackChain(string locale)
    {
        var chain = new List<string> { locale };

        // 添加不带区域的语种
        if (locale.Contains('-'))
        {
            chain.Add(locale.Split('-')[0]);
        }

        // 添加默认
        chain.Add("default");

        return chain;
    }
}

缓存策略

缓存头部

[HttpGet("{id}")]
public async Task<ActionResult<ContentItemDto>> Get(string id)
{
    var content = await _contentService.GetAsync(id);
    if (content == null)
    {
        return NotFound();
    }

    // 设置缓存头部
    Response.Headers["Cache-Control"] = "public, max-age=300"; // 5分钟
    Response.Headers["ETag"] = $"\"{content.Version}\"";
    Response.Headers["Last-Modified"] = content.ModifiedUtc
        .ToString("R"); // RFC 1123格式

    return Ok(content);
}

条件GET

[HttpGet("{id}")]
public async Task<ActionResult<ContentItemDto>> Get(
    string id,
    [FromHeader(Name = "If-None-Match")] string? ifNoneMatch,
    [FromHeader(Name = "If-Modified-Since")] string? ifModifiedSince)
{
    var content = await _contentService.GetAsync(id);
    if (content == null)
    {
        return NotFound();
    }

    var etag = $"\"{content.Version}\"";

    // 检查ETag
    if (ifNoneMatch == etag)
    {
        return StatusCode(304); // 未修改
    }

    // 检查最后修改时间
    if (DateTime.TryParse(ifModifiedSince, out var modifiedSince))
    {
        if (content.ModifiedUtc <= modifiedSince)
        {
            return StatusCode(304); // 未修改
        }
    }

    Response.Headers["ETag"] = etag;
    return Ok(content);
}

缓存失效

public class ContentPublishHandler : INotificationHandler<ContentPublishedEvent>
{
    private readonly ICacheInvalidationService _cache;

    public async Task Handle(ContentPublishedEvent notification,
        CancellationToken cancellationToken)
    {
        // 失效特定内容
        await _cache.InvalidateAsync($"content:{notification.ContentId}");

        // 失效集合缓存
        await _cache.InvalidateByTagAsync($"type:{notification.ContentType}");

        // 失效CDN缓存
        await _cache.PurgeCdnAsync($"/api/content/{notification.ContentId}");
    }
}

API版本化

URL路径版本化

GET /api/v1/content/{id}
GET /api/v2/content/{id}

头部版本化

GET /api/content/{id}
Api-Version: 2.0

实现

// Program.cs
builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0);
    options.AssumeDefaultVersionWhenUnspecified = true;
    options.ReportApiVersions = true;
    options.ApiVersionReader = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader(),
        new HeaderApiVersionReader("Api-Version")
    );
});

// 控制器
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/content")]
public class ContentController : ControllerBase
{
    [HttpGet("{id}")]
    [MapToApiVersion("1.0")]
    public async Task<ActionResult<ContentItemDtoV1>> GetV1(string id)
    {
        // V1响应形状
    }

    [HttpGet("{id}")]
    [MapToApiVersion("2.0")]
    public async Task<ActionResult<ContentItemDtoV2>> GetV2(string id)
    {
        // V2响应形状,包含重大变化
    }
}

安全考虑

API密钥认证

public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue("X-Api-Key", out var apiKey))
        {
            return AuthenticateResult.NoResult();
        }

        var client = await _clientService.ValidateApiKeyAsync(apiKey!);
        if (client == null)
        {
            return AuthenticateResult.Fail("无效API密钥");
        }

        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, client.Id),
            new Claim("client_name", client.Name),
            new Claim("scope", string.Join(" ", client.Scopes))
        };

        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);

        return AuthenticateResult.Success(ticket);
    }
}

速率限制

builder.Services.AddRateLimiter(options =>
{
    options.AddPolicy("content-api", context =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: context.Request.Headers["X-Api-Key"].ToString(),
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 1000,
                Window = TimeSpan.FromHours(1),
                QueueLimit = 0
            }));
});

相关技能

  • content-type-modeling - API响应的内容结构
  • dynamic-schema-design - 灵活API的JSON列存储
  • content-versioning - 版本历史API端点
  • cdn-media-delivery - 媒体API的CDN集成