一、系统概述
文章加密系统支持两种加密模式,可独立使用或组合使用:
模式 | 说明 | 示例 |
|---|---|---|
全文加密 | 整篇文章需要密码才能查看,API 不返回 | 私密日记、付费文章 |
内容块加密 | 文章正文内部分区块独立加密,每个区块可有不同密码 | 教程中关键步骤加密、代码片段加密 |
混合模式 | 全文加密 + 部分内容块有独立密码 | 文章解锁后,部分高级内容仍需单独解锁 |
二、核心架构
PLAINTEXT
┌─────────────────────────────────────────────────────────────────────┐
│ 请求生命周期 │
│ │
│ 用户请求 ──▶ Gin Router ──▶ ArticleAccessToken 中间件 │
│ │ │
│ ┌───────────────┴───────────────┐ │
│ │ 解析凭证来源: │ │
│ │ 1. URL ?token=xxx │ │
│ │ 2. HttpOnly Cookie │ │
│ │ │ │
│ │ 验证通过后注入 Gin Context: │ │
│ │ • article_unlocked = true │ │
│ │ • unlocked_block_ids = [...] │ │
│ └───────────────┬───────────────┘ │
│ │ │
│ Handler 层桥接 Gin→Go Context │
│ │ │
│ Service 层读取 Context 决定内容处理策略 │
│ │ │
│ 返回最终 ArticleResponse │
└─────────────────────────────────────────────────────────────────────┘
三、数据模型
3.1 文章访问规则(AccessRule)
GO
type AccessRule struct {
Type string // "password" | "login" | ""
PasswordHash string // bcrypt 哈希
Hint string // 密码提示
}
3.2 内容块加密记录(PasswordContent)
GO
type PasswordContent struct {
ID uint // 数据库主键
ArticleID uint // 关联文章数据库ID
ContentID string // 内容块唯一标识(前端生成)
EncryptedContent string // AES 加密后的原文内容
PasswordHash string // bcrypt 哈希(空=继承文章密码)
Hint string // 密码提示
Title string // 区块标题
}
关键设计:PasswordHash 为空时表示该内容块继承文章密码,解锁文章后自动解锁此类块。
3.3 API 响应模型
GO
type ArticleResponse struct {
// ...
ContentHTML string `json:"content_html,omitempty"`
Encrypted bool `json:"encrypted,omitempty"` // 全文是否加密且未解锁
HasEncryptedBlocks bool `json:"has_encrypted_blocks,omitempty"` // 正文是否含加密内容块
AccessRule *AccessRuleResponse `json:"access_rule,omitempty"`
}
四、Token 机制
4.1 签名令牌(HMAC-SHA256)
不使用 JWT,采用更轻量的 HMAC-SHA256 签名令牌:
PLAINTEXT
Token = Base64URL(Identifier) + "." + Base64URL(HMAC-SHA256(Identifier + Expiry + Secret)) + "." + Base64URL(Expiry)
Identifier 格式:
全文解锁:
article:{articlePublicID}块解锁:
block:{articlePublicID}:{contentID}
4.2 Token 存储与传递
通道 | 格式 | 说明 |
|---|---|---|
URL 参数 |
| 分享链接,一次性验证 |
HttpOnly Cookie |
| 全文解锁凭证,7天有效 |
HttpOnly Cookie |
| 块解锁凭证,7天有效 |
Cookie 命名规则:
文章 token:
article_token_{sqids公共ID}内容块 token:
block_token_{sqids公共ID}--{contentID}分隔符使用
--(双横线),避免与 ID 本身可能包含的字符冲突
Cookie 属性:HttpOnly=true, Secure=true, SameSite=Strict, Path=/, MaxAge=7天
五、中间件 — ArticleAccessToken
5.1 执行流程
PLAINTEXT
请求进入
│
├─ 检查 URL ?token= 参数
│ └─ 验证 article:{id} 签名 → 设置 article_unlocked=true
│
├─ 遍历所有 Cookie
│ ├─ article_token_{id} → 验证签名 → 设置 article_unlocked=true
│ └─ block_token_{id}--{cid} → 验证签名 → 收集 contentID 到 unlockedBlockIDs
│
├─ 将 unlockedBlockIDs 注入 Context(非空时)
│
└─ c.Next()
5.2 Context 桥接
Gin 中间件使用 c.Set() 写入键值对,但 Service 层使用 Go 标准 context.Context 读取。 Handler 层负责桥接:
GO
func enrichContextWithUnlockState(c *gin.Context) context.Context {
ctx := c.Request.Context()
if unlocked, exists := c.Get("article_unlocked"); exists && unlocked == true {
ctx = context.WithValue(ctx, model.ArticleUnlockCtxKey{}, true)
}
if blockIDs, exists := c.Get("unlocked_block_ids"); exists {
if ids, ok := blockIDs.([]string); ok && len(ids) > 0 {
ctx = context.WithValue(ctx, model.UnlockedBlockIDsCtxKey{}, ids)
}
}
return ctx
}
六、内容处理 — finalizePublicArticleContent
这是核心的内容处理函数,根据解锁状态决定返回什么内容:
PLAINTEXT
输入: Article + ArticleResponse + Context(解锁状态)
│
├─ 清除 ContentMd(永远不返回原文)
│
├─ 检测 HasEncryptedBlocks(HTML 含 password-content-lock 占位符)
│
├─ 计算 needsBlockReplace 标志
│
├─ 条件解码 articleDBID(只在需要时调用一次 DecodePublicID)
│
├─ if 全文加密:
│ ├─ 已解锁 → encrypted=false, ReplaceInheritedBlocks(继承块)
│ └─ 未解锁 → encrypted=true, content_html="", return
│
├─ if 有已解锁的块ID → ReplaceBlocksByIDs
│
└─ 重新检测 HasEncryptedBlocks(所有块都替换后设为 false)
6.1 ReplaceInheritedBlocks vs ReplaceBlocksByIDs
两个方法共享内部函数 replaceBlocks,通过谓词区分行为:
GO
func (s *serviceImpl) replaceBlocks(ctx context.Context, articleID uint, contentHTML string,
shouldReplace func(pc *model.PasswordContent, contentID string) bool) string
// ReplaceInheritedBlocks: 替换 PasswordHash=="" 的块(继承文章密码)
func (s *serviceImpl) ReplaceInheritedBlocks(...) string {
return s.replaceBlocks(ctx, articleID, contentHTML, func(pc *model.PasswordContent, _ string) bool {
return pc.PasswordHash == ""
})
}
// ReplaceBlocksByIDs: 替换指定 ID 的块(通过独立密码解锁)
func (s *serviceImpl) ReplaceBlocksByIDs(..., blockIDs []string) string {
idSet := make(map[string]struct{}, len(blockIDs))
for _, id := range blockIDs { idSet[id] = struct{}{} }
return s.replaceBlocks(ctx, articleID, contentHTML, func(_ *model.PasswordContent, contentID string) bool {
_, ok := idSet[contentID]
return ok
})
}
七、前端流程
7.1 SSR 阶段(page.tsx)
TYPESCRIPT
// 1. 读取 URL token 和浏览器 Cookie
const urlToken = searchParams.token;
const cookieHeader = cookieStore.toString();
// 2. 转发给后端 API
const article = await getArticle(id, cookieHeader, urlToken);
// 后端根据凭证返回已解密或加密的内容
关键点:SSR 阶段转发 Cookie 和 URL token,使服务端直接返回已解密内容,避免客户端二次请求和内容闪烁。
7.2 客户端渲染(PostDetailContent.tsx)
TYPESCRIPT
// 判断加密状态
const isPasswordProtected = article.access_rule?.type === "password" && !article.content_html;
const hasEncryptedBlocks = !isPasswordProtected && (article.has_encrypted_blocks ?? false);
// URL token 自动解锁(仅当 URL 含 token 时执行)
useEffect(() => {
const urlToken = new URLSearchParams(window.location.search).get("token");
if (!urlToken) return;
// 调用 API 验证 token → 解锁内容
}, [article.id]);
// 条件渲染
if (isPasswordProtected && !isUnlocked) {
return <ArticlePasswordGate />; // 全文加密门控
}
return <PostContent content={contentWithCustomJS} />; // 正常内容(可能含加密块)
7.3 内容块解锁(PostContent/index.tsx)
TYPESCRIPT
// 监听加密块中的验证按钮点击
const handleVerify = async () => {
const result = await articleApi.verifyArticlePassword(slug, password, "block", contentId);
if (result.success && result.content_html) {
// DOM 操作:移除锁定 UI,插入解密内容
}
};
7.4 全文密码门控(ArticlePasswordGate.tsx)
TYPESCRIPT
// 验证成功后:
// 1. 设置 unlockedContent 状态
// 2. 获取 access_token 用于生成分享链接
// 3. Cookie 由后端 Set-Cookie 自动设置
const shareUrl = `${window.location.origin}${window.location.pathname}?token=${accessToken}`;
八、完整请求流程图
8.1 首次访问加密文章
PLAINTEXT
浏览器 ──GET /posts/abc123──▶ Next.js SSR
│
┌─────────────┴─────────────┐
│ getArticle(abc123, cookie, token?) │
└─────────────┬─────────────┘
│
┌─────────────▼─────────────┐
│ 后端 API /api/public/articles/abc123 │
│ ArticleAccessToken 中间件 │
│ (无有效凭证) │
└─────────────┬─────────────┘
│
┌─────────────▼─────────────┐
│ finalizePublicArticleContent │
│ encrypted=true, content_html="" │
└─────────────┬─────────────┘
│
◀── SSR 渲染 ArticlePasswordGate ──┘
8.2 密码验证
PLAINTEXT
浏览器 ──POST /api/password-content/verify──▶ 后端
body: { article_id, password, type: "full" }
│
┌─────────────▼─────────────┐
│ verifyFullArticle │
│ 1. 验证密码 (bcrypt) │
│ 2. ReplaceInheritedBlocks │
│ 3. 生成 HMAC-SHA256 token │
│ 4. Set-Cookie: article_token_abc123 │
│ 5. 返回 content_html + access_token │
└─────────────┬─────────────┘
│
◀── 前端 setUnlockedContent(contentHtml) ──┘
8.3 刷新页面(已解锁)
PLAINTEXT
浏览器 ──GET /posts/abc123──▶ Next.js SSR
Cookie: article_token_abc123=xxx
│
┌─────────────▼─────────────┐
│ ArticleAccessToken 中间件 │
│ 验证 Cookie → article_unlocked=true │
└─────────────┬─────────────┘
│
┌─────────────▼─────────────┐
│ finalizePublicArticleContent │
│ encrypted=false, 返回完整内容 │
│ ReplaceInheritedBlocks 执行 │
└─────────────┬─────────────┘
│
◀── SSR 直接渲染完整内容,无闪烁 ──┘
8.4 分享链接访问
PLAINTEXT
被分享者 ──GET /posts/abc123?token=yyy──▶ Next.js SSR
│
┌───────────────────┴───────────────────┐
│ getArticle(abc123, cookie, "yyy") │
│ → 后端 API /api/public/articles/abc123?token=yyy │
└───────────────────┬───────────────────┘
│
┌───────────────────▼───────────────────┐
│ ArticleAccessToken 中间件 │
│ 验证 URL token → article_unlocked=true│
└───────────────────┬───────────────────┘
│
┌───────────────────▼───────────────────┐
│ finalizePublicArticleContent │
│ encrypted=false, 返回完整内容 │
└───────────────────┬───────────────────┘
│
◀── SSR 直接渲染完整内容 ──┘
九、安全设计
措施 | 说明 |
|---|---|
AES 加密存储 | 内容块原文使用 AES 对称加密存储在数据库 |
bcrypt 密码哈希 | 密码使用 bcrypt 哈希存储,不可逆 |
HMAC-SHA256 签名 | Token 使用 HMAC-SHA256 签名,防伪造 |
HttpOnly Cookie | Token Cookie 设置 HttpOnly,JS 无法读取 |
Secure Cookie | Cookie 设置 Secure,仅 HTTPS 传输 |
7天有效期 | Token 和 Cookie 有效期 7 天 |
不返回原文 | API 永远不返回 |
Identifier 绑定 | Token 签名包含文章/块标识,不可跨文章使用 |
十、关键文件索引
文件 | 职责 |
|---|---|
| ArticleAccessToken 中间件,解析 URL token 和 Cookie |
| 文章 API Handler,Context 桥接 |
| 密码验证 Handler,Token 生成和 Cookie 设置 |
| 文章 Service, |
| 加密内容 Service,块替换和密码验证 |
| HMAC-SHA256 Token 生成与验证 |
| Context Key 定义和读取函数 |
| 请求/响应模型 |
| SSR 页面,Cookie 和 Token 转发 |
| 文章详情主组件,解锁状态管理 |
| 全文密码门控组件 |
| 内容渲染,块密码验证事件 |
| API 客户端 |
| Article 类型定义 |