渐进式发布实战:知光平台的内容发布系统设计
从一个真实痛点说起
假设你在做一个知识社区平台(类似知乎、CSDN),用户可以在上面发布文章、分享经验。
某天,产品经理找到你:"我们的用户反馈说,写了一半的文章不小心关掉页面就丢了;还有用户说上传图片太慢了,尤其是高清大图;另外,能不能自动帮他们生成文章摘要?"
你一听,需求很明确:
- 草稿保存:防止内容丢失
- 高效上传:支持大文件快速上传
- AI 辅助:自动生成摘要
但作为后端工程师,你立刻意识到这些需求的背后隐藏着一系列工程挑战:
- 文件是直接存数据库还是对象存储?
- 上传过程如果中断了怎么办?
- 草稿和已发布的文章如何区分?
- 如何保证发布过程的原子性(要么全成功,要么全失败)?
- AI 生成摘要失败会不会阻塞主流程?
这就是今天要聊的主题------渐进式发布流程(Progressive Publishing Workflow)。
什么是渐进式发布?
先给个定义:
渐进式发布是一种将复杂的"创建→编辑→发布"过程拆分为多个原子操作的设计模式,每个操作都有明确的状态转换、权限控制和容错机制。
听起来有点抽象?我们用生活中的例子类比:
想象你在写一本书:
- 构思阶段(DRAFT):在笔记本上随便写,想到什么写什么,不讲究格式
- 整理阶段(EDITING):把笔记整理成章节,调整结构,补充配图
- 审校阶段(REVIEW):请朋友帮忙看一遍,修改错别字
- 出版阶段(PUBLISHED):交给印刷厂,正式上架销售
- 下架阶段(DEPRECATED):过时了,从书店撤下来(但可能还有库存)
每个阶段都是独立且不可逆 的(你不能从"出版"回到"构思"),每个阶段都有明确的准入条件(比如必须完成整理才能进入审校)。
软件系统的内容发布也是一样的道理------只不过我们用状态机 和事务来保证这个过程的一致性。
核心架构:双模式文件上传策略
在设计渐进式发布之前,我们先解决一个基础问题:文件怎么上传到服务器?
传统方案的痛点
方案A:前端 → 后端 → OSS(代理上传)
复制代码
前端(浏览器)──HTTP POST──▶ 后端服务器 ──SDK调用──▶ 阿里云 OSS
↑
(文件流经后端内存)
问题:
- 后端服务器带宽压力大(假设 100 个用户同时上传 10MB 的图片,后端带宽就要承受 1GB/s)
- 后端内存占用高(需要缓存整个文件再转发给 OSS)
- 上传速度受限于后端服务器的出口带宽
- 如果后端是集群部署,还涉及负载均衡的问题
方案B:前端直接上传到 OSS(直传)
复制代码
前端(浏览器)──HTTP PUT──▶ 阿里云 OSS
↑
(绕过后端,直接传OSS)
问题:
- 安全性差(OSS 的 AccessKey 暴露在前端)
- 无法做权限控制(谁都能往你的 Bucket 里传东西)
- 无法记录上传日志或触发业务逻辑
知光平台的解决方案:预签名 URL 直传
核心思路:后端生成带签名的临时 URL,前端用这个 URL 直接上传到 OSS,既安全又高效。
完整流程图
图:预签名URL上传时序图(4个阶段)
阿里云OSS MySQL 后端服务 前端 阿里云OSS MySQL 后端服务 前端 第一阶段:创建草稿 第二阶段:获取预签名URL 第三阶段:前端直传文件到OSS 文件直接存储到OSS 不经过后端服务器 第四阶段:确认上传成功 POST /api/v1/knowposts/drafts INSERT (status=draft) 返回 postId 返回 {postId} POST /api/v1/storage/presign {scene, postId} 校验权限 生成预签名PUT URL (10分钟有效) 返回 putUrl + objectKey 返回 {putUrl, objectKey, expiresIn} PUT {putUrl} 文件二进制流 200 OK + ETag POST /api/v1/knowposts/{id}/content/confirm {objectKey, etag, size, sha256} UPDATE content_object_key等字段 [afterCommit] 触发RAG索引 204 No Content
文字版流程说明(防止平台不渲染):
第一阶段:创建草稿
- 前端调用
POST /api/v1/knowposts/drafts
- 后端在 MySQL 插入一条
status='draft' 的记录
- 返回唯一的
postId 给前端
第二阶段:获取预签名URL
- 前端调用
POST /api/v1/storage/presign,传入场景和帖子ID
- 后端校验用户权限(是否是文章作者)
- 后端调用阿里云 SDK 生成临时 PUT URL(10分钟有效)
- 返回
{putUrl, objectKey, expiresIn: 600} 给前端
第三阶段:前端直传文件到OSS
- 前端用预签名 URL 直接 HTTP PUT 上传文件到阿里云 OSS
- 文件不经过后端服务器,节省带宽
- OSS 返回
200 OK + ETag
第四阶段:确认上传成功
- 前端调用
POST /api/v1/knowposts/{id}/content/confirm,回传元数据
- 后端更新数据库记录(objectKey、contentUrl、ETag、SHA256等)
- 事务提交后异步触发 RAG 向量化索引
为什么这样设计?
| 维度 |
代理上传 |
预签名直传 |
| 后端带宽压力 |
高(所有文件都经后端) |
低(只传输元数据) |
| 上传速度 |
受限于后端出口带宽 |
接近用户本地带宽上限 |
| 安全性 |
高(AccessKey在后端) |
高(临时签名+过期机制) |
| 业务逻辑整合 |
容易(可在上传时处理) |
需要额外的确认步骤 |
| 适用场景 |
小文件(<5MB,如头像) |
大文件(如文章配图、视频) |
核心代码实现
1. 预签名 URL 生成([OssStorageServiceImpl.java](file:///d:/ideaProject/zhiguang/src/main/java/com/xiaoce/zhiguang/oss/service/Impl/OssStorageServiceImpl.java#L59-L82))
java
复制代码
public String generatePresignedPutUrl(String objectKey, String contentType, int expiresInSeconds) {
ensureConfigured(); // 校验 OSS 配置完整性
OSS client = new OSSClientBuilder().build(
props.getEndpoint(),
props.getAk(), // AccessKey ID
props.getSk() // AccessKey Secret
);
try {
Date expiration = new Date(System.currentTimeMillis() + expiresInSeconds * 1000L);
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(
props.getBucket(), // 存储桶名称
objectKey, // 对象路径(如 posts/12345/images/20260516/abc12345.jpg)
HttpMethod.PUT // HTTP 方法(PUT = 上传)
);
request.setExpiration(expiration); // 过期时间
if (contentType != null && !contentType.isBlank()) {
request.setContentType(contentType); // ⚠️ 关键:限制上传文件的 MIME 类型
}
URL url = client.generatePresignedUrl(request); // 生成签名URL
return url.toString();
} finally {
client.shutdown(); // 及时释放资源,避免连接泄漏
}
}
关键设计点:
- 有效期控制 :
expiresInSeconds = 600(10分钟),防止 URL 泄露后被滥用
- Content-Type 限制 :强制指定 MIME 类型(如
image/jpeg),防止用户上传恶意文件(如 HTML/JS)
- 资源释放 :
finally 块中关闭 OSS Client,避免连接池耗尽
2. ObjectKey 生成策略(按场景分类)
OssStorageServiceImpl.createPresign()\](file:///d:/ideaProject/zhiguang/src/main/java/com/xiaoce/zhiguang/oss/service/Impl/OssStorageServiceImpl.java#L123-L168)
```java
String objectKey;
if ("knowpost_content".equals(scene)) {
// 场景1:文章正文(Markdown/HTML/TXT)
// 路径格式:posts/{postId}/content.md
objectKey = "posts/" + postId + "/content" + ext;
} else if ("knowpost_image".equals(scene)) {
// 场景2:文章配图(JPG/PNG)
// 路径格式:posts/{postId}/images/{yyyyMMdd}/{uuid8位}.jpg
String date = DateTimeFormatter.ofPattern("yyyyMMdd")
.withZone(ZoneId.of("UTC"))
.format(Instant.now());
String rand = UUID.randomUUID()
.toString()
.replaceAll("-", "")
.substring(0, 8); // 取 UUID 前8位,足够随机且不过长
objectKey = "posts/" + postId + "/images/" + date + "/" + rand + ext;
}
```
**为什么这样设计 ObjectKey?**
1. **层级清晰** :按 `posts/{postId}/images/{date}/` 分层存储,便于管理和清理
2. **避免冲突**:UUID + 日期组合几乎不会重复
3. **便于追溯**:通过 ObjectKey 就能知道这个文件属于哪个帖子的哪天上传的
4. **利于 CDN 缓存**:静态资源 URL 包含时间戳,便于配置缓存策略
##### 3. 权限校验(防止越权访问)
```java
// 在生成预签名URL之前,必须校验用户是否有权操作这个帖子
KnowPosts post = knowPostMapper.selectById(postId);
if (post == null || post.getCreatorId() == null || !post.getCreatorId().equals(userId)) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "草稿不存在或无权限");
}
```
**这步很重要!** 否则恶意用户可以伪造 `postId`,往别人的帖子里上传非法内容。
*** ** * ** ***
#### 如果预签名URL被截取了怎么办?
这是一个**生产级的关键安全问题**。很多面试官会追问:"你的预签名URL方案看起来不错,但如果URL被人截取到了,会发生什么?"
让我们系统性地分析这个问题。
##### 一、URL 可能被截取的所有场景
| 场景 | 攻击者 | 难度 | 风险等级 |
|-------------------------------|-------------|------------|--------|
| **① 浏览器开发者工具** | 前端用户自己 | 极低(按F12即可) | 中 |
| **② 网络抓包(Wireshark/Charles)** | 同局域网攻击者 | 低 | 高 |
| **③ 前端XSS漏洞泄露** | 远程攻击者 | 中 | **极高** |
| **④ 前端日志打印** | 运维人员/攻击者 | 低 | 高 |
| **⑤ HTTP Referer 泄露** | 第三方网站 | 低 | 中 |
| **⑥ 服务端日志泄露** | 内部人员/攻击数据库者 | 中 | 高 |
##### 二、每种场景的详细分析和防护
###### 场景①:浏览器开发者工具(最常见)
**攻击方式:**
1. 用户A正常操作:创建草稿 → 获取预签名URL → 上传图片
2. 用户A打开浏览器 DevTools → Network 标签 → 找到 /storage/presign 的响应
3. 复制响应中的 putUrl 字段
4. 在 Postman/curl 中使用这个 putUrl 上传非法文件
**危害评估:**
* 危害程度:**中等**
* 因为URL有10分钟过期限制,且绑定了特定的 ObjectKey
* 但在有效期内,攻击者可以覆盖用户的文件或上传垃圾数据
**当前防护措施:**
```java
// 防护1:ObjectKey 由服务端生成(前端无法指定路径)
objectKey = "posts/" + postId + "/images/" + date + "/" + rand + ext;
// 即使拿到URL,也只能上传到预定义的路径下
// 防护2:Content-Type 限制
request.setContentType(contentType); // 只允许 image/jpeg 等合法类型
// 防护3:确认机制(最关键!)
// 前端上传成功后必须调用 confirm 接口回传元数据
// 后端会校验 ETag、SHA256 等信息,确保文件合法性
```
**增强防护方案(推荐):**
```java
/**
* 方案A:绑定用户指纹到签名策略
*
* 思路:在生成预签名URL时,将用户的 SessionID 或 Token 加入签名计算,
* 这样即使URL被截取,没有合法的Session也无法完成后续确认步骤。
*/
public String generatePresignedPutUrl(String objectKey, String contentType,
int expiresInSeconds, String userSessionId) {
// 1. 生成带用户标识的自定义Header
Map customHeaders = new HashMap<>();
customHeaders.put("X-User-Session", DigestUtils.md5Hex(userSessionId)); // MD5哈希
// 2. 将自定义Header加入签名计算
request.setCustomHeaders(customHeaders);
// 3. 生成URL
URL url = client.generatePresignedUrl(request);
return url.toString();
}
/**
* 方案B:使用短期一次性Token(最安全但复杂度较高)
*
* 思路:
* 1. 后端生成一个随机的 uploadToken(UUID),存入Redis(TTL=10分钟)
* 2. 将 uploadToken 作为 ObjectKey 的一部分或查询参数
* 3. 前端上传时必须携带这个 token
* 4. OSS 回调或 confirm 接口时验证 token 是否存在且未使用
* 5. 使用后立即删除 token(保证一次性)
*/
public PresignResponse createPresignWithToken(Long userId, Long postId, String scene) {
// 1. 生成一次性token
String uploadToken = UUID.randomUUID().toString();
// 2. 存入Redis(10分钟过期)
stringRedisTemplate.opsForValue()
.set("upload:token:" + uploadToken,
userId + ":" + postId + ":" + scene,
10, TimeUnit.MINUTES);
// 3. 将token嵌入ObjectKey(或作为查询参数)
String objectKey = "posts/" + postId + "/images/" + date + "/" + uploadToken + ext;
// 4. 生成预签名URL
String putUrl = generatePresignedPutUrl(objectKey, contentType, 600);
return new PresignResponse(objectKey, putUrl, 600, uploadToken);
}
```
*** ** * ** ***
###### 场景②:网络抓包(中间人攻击)
**攻击方式:**
1. 攻击者和受害者连接同一WiFi(如咖啡厅公共WiFi)
2. 攻击者开启 ARP 欺骗或 DNS 劫持
3. 受害者的所有HTTP流量经过攻击者的电脑
4. 攻击者用 Wireshark 抓包,过滤出 presign 接口的响应
5. 提取 putUrl 并滥用
**危害评估:**
* 危害程度:**高**
* 可以完全控制上传的内容
* 可以在上传的图片中注入恶意代码(如果是HTML/SVG)
**防护方案:全链路HTTPS**
```yaml
# application.yml - 强制HTTPS配置
server:
ssl:
enabled: true
key-store: classpath:keystore.p12
key-store-password: ${SSL_KEYSTORE_PASSWORD}
key-store-type: PKCS12
# 强制重定向到HTTPS(如果收到HTTP请求)
# Spring Security 配置
security:
require-ssl: true
# OSS 配置:只允许HTTPS回调
oss:
callback-url: https://api.yourdomain.com/api/v1/storage/callback
```
**额外防护:HSTS(HTTP Strict Transport Security)**
```java
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ... 其他配置 ...
.headers(headers -> headers
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000) // 1年
.preload(true)
)
);
return http.build();
}
}
```
**HSTS的作用:**
* 告诉浏览器:以后访问这个域名只能用 HTTPS,不能用 HTTP
* 有效期通常设置为 1 年(31536000 秒)
* 即使攻击者成功劫持了第一次请求,浏览器也会拒绝后续的 HTTP 连接
*** ** * ** ***
###### 场景③:前端 XSS 漏洞泄露(最危险)
**攻击方式:**
1. 攻击者在评论框中写入恶意脚本:
'});
});
2. 用户B浏览包含这段脚本的页面
3. 脚本自动执行,窃取用户B的预签名URL并上传恶意内容
**危害评估:**
* 危害程度:**极高(生产事故级)**
* 可以冒充任何用户上传内容
* 可能在其他用户的文章中植入恶意代码
* 可能导致存储型XSS攻击链式反应
**防护方案:多层防御**
```java
/**
* 防护1:Content-Security-Policy (CSP) 头
* 限制前端只能加载指定来源的脚本
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
// ...
}
@Bean
public FilterRegistrationBean cspFilter() {
FilterRegistrationBean registration = new FilterRegistrationBean<>();
CspFilter cspFilter = new CspFilter();
// CSP 策略:只允许加载同源脚本,禁止内联脚本
cspFilter.setPolicy("default-src 'self'; script-src 'self' 'unsafe-inline'; "
+ "connect-src 'self' https://api.deepseek.com; "
+ "img-src 'self' data: https:; "
+ "style-src 'self' 'unsafe-inline'");
registration.setFilter(cspFilter);
registration.addUrlPatterns("/*");
return registration;
}
}
/**
* 防护2:对输出内容进行 HTML 转义
*/
@Service
public class XssProtectionService {
/**
* 清理用户输入,防止XSS
* 使用 OWASP Java HTML Sanitizer 库
*/
public String sanitize(String input) {
if (input == null) return null;
// PolicyFactory 定义允许的HTML标签和属性
PolicyFactory policy = Sanitizers.FORMATTING
.and(Sanitizers.LINKS)
.and(Sanitizers.BLOCKS)
.and(Sanitizers.STYLES); // 允许基本样式
return policy.sanitize(input);
}
/**
* 对JSON响应中的字符串进行转义
*/
public String escapeJson(String input) {
if (input == null) return null;
return StringEscapeUtils.escapeJson(input); // Apache Commons Text
}
}
/**
* 防护3:预签名URL设置 HttpOnly 和 SameSite Cookie
*/
@PostMapping("/presign")
public ResponseEntity presign(
@AuthenticationPrincipal Jwt jwt,
@Valid @RequestBody StoragePresignRequest request,
HttpServletResponse response) {
// 设置安全Cookie(防止CSRF和XSS窃取Cookie)
ResponseCookie sessionCookie = ResponseCookie.from("SESSION_ID")
.value(generateSecureSessionId())
.httpOnly(true) // 防止JavaScript读取
.secure(true) // 只在HTTPS下传输
.path("/")
.sameSite("Strict") // 严格的同站策略
.maxAge(Duration.ofHours(1))
.build();
response.addHeader("Set-Cookie", sessionCookie.toString());
// ... 正常业务逻辑 ...
}
```
*** ** * ** ***
###### 场景④:前端日志打印(开发环境遗留)
**问题代码示例:**
```javascript
// 错误做法:在生产环境中打印敏感信息
async function uploadImage(file, postId) {
const res = await fetch('/api/v1/storage/presign', {
method: 'POST',
body: JSON.stringify({scene: 'knowpost_image', postId})
});
const data = await res.json();
// 危险!把完整的预签名URL打印到控制台
console.log('预签名URL:', data.putUrl); // ❌ 生产环境不应该有这行
console.log('完整响应:', data); // ❌ 包含 objectKey 等敏感信息
// 上传文件...
}
```
**风险:**
* 如果用户开启了"保存日志"功能(某些浏览器插件),这些日志会被持久化到磁盘
* 前端错误监控服务(如 Sentry)可能收集到这些日志
* 如果控制台日志被第三方脚本读取(通过 console.log 的 hook),就会泄露
**防护方案:**
```javascript
// 正确做法:区分开发和生产环境
const isProduction = process.env.NODE_ENV === 'production';
async function uploadImage(file, postId) {
const res = await fetch('/api/v1/storage/presign', {...});
const data = await res.json();
// 只在开发环境打印日志
if (!isProduction && window.location.hostname === 'localhost') {
console.log('[DEBUG] 预签名URL已获取,长度:', data.putUrl?.length);
// 不打印完整URL,只打印长度用于调试
}
// 生产环境下完全不打印敏感信息
// 即使要记录错误,也要脱敏处理
}
```
**构建时的自动化清理(Webpack/Vite 配置):**
```javascript
// vite.config.js
export default defineConfig({
build: {
minify: 'terser',
terserOptions: {
compress: {
// 自动移除所有 console.log 语句
drop_console: true,
// 移除 debugger 语句
drop_debugger: true,
// 移除纯函数的死代码
pure_funcs: ['console.info', 'console.debug'],
},
},
},
});
```
*** ** * ** ***
###### 场景⑤:HTTP Referer 头泄露
**攻击方式:**
1. 用户在你的平台上获取了预签名URL
2. 用户点击了一个外部链接(比如某个论坛帖子)
3. 外部网站的访问日志会记录 Referer 头
4. Referer 中可能包含完整的URL参数(包括putUrl)
**实际案例:**
很多网站的搜索结果页、论坛帖子、短链接服务都会记录 Referer。如果预签名URL出现在 Referer 中,就相当于公开了这个临时凭证。
**防护方案:Referrer-Policy 头**
```java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Bean
public FilterRegistrationBean referrerPolicyFilter() {
FilterRegistrationBean registration = new FilterRegistrationBean<>();
ReferrerPolicyFilter filter = new ReferrerPolicyFilter();
// strict-origin-when-cross-origin: 跨域请求只发送来源(不含路径)
// no-referrer: 从不发送 Referer(最安全,但会影响统计分析)
filter.setPolicy("strict-origin-when-cross-origin");
registration.setFilter(filter);
registration.addUrlPatterns("/*");
return registration;
}
}
```
**或者在前端 meta 标签中设置:**
```html
```
**四种 Referrer-Policy 策略对比:**
| 策略值 | 行为 | 安全性 | 对SEO的影响 |
|-----------------------------------|-----------------|--------|----------|
| `no-referrer` | 不发送任何 Referer | 最高 | 无法统计流量来源 |
| `no-referrer-when-downgrade` | HTTPS→HTTP 时不发送 | 高 | 影响较小 |
| `origin` | 只发送域名(不含路径) | 较高 | 影响较小 |
| `strict-origin-when-cross-origin` | 跨域时只发送域名 | **推荐** | 平衡安全和统计 |
| `unsafe-url` | 发送完整URL(默认行为) | 最低 | 最利于统计 |
**推荐使用 `strict-origin-when-cross-origin`**:既能保护预签名URL不被泄露,又不影响正常的流量来源统计。
*** ** * ** ***
###### 场景⑥:服务端日志泄露(内部威胁)
**问题场景:**
```java
// 错误做法:在日志中记录完整的预签名URL
log.info("生成预签名URL成功: userId={}, putUrl={}", userId, putUrl);
// 日志输出示例:
// [2026-05-16 10:23:45] INFO OssStorageService - 生成预签名URL成功: userId=12345,
// putUrl=https://your-bucket.oss-cn-hangzhou.aliyuncs.com/posts/12345/images/20260516/abc12345.jpg?
// OSSAccessKeyId=LTAI***&Signature=xxx%3D&Expires=1715840000
```
**风险:**
* 如果日志系统被入侵(如 ELK 集群未授权访问),攻击者可以批量提取历史 URL
* 如果日志被发送到第三方 APM 服务(如 Sentry、Datadog),可能被员工误查看
* 符合合规要求(如 GDPR)的组织不能记录此类敏感信息
**防护方案:日志脱敏**
```java
/**
* 工具类:敏感信息脱敏
*/
@Component
public class LogSanitizer {
/**
* 脱敏URL:隐藏签名参数,只保留路径
*
* 示例:
* 输入:https://bucket.oss...com/posts/123/img.jpg?OSSAccessKeyId=xxx&Signature=yyy&Expires=zzz
* 输出:https://bucket.oss...com/posts/123/img.jpg?***(已脱敏)
*/
public String sanitizeUrl(String url) {
if (url == null || url.isBlank()) return "(null)";
try {
URL u = new URL(url);
// 只保留协议+主机+路径,隐藏查询参数(签名信息都在query里)
return u.getProtocol() + "://" + u.getHost() + u.getPath() + "?***";
} catch (MalformedURLException e) {
return "(invalid url)";
}
}
/**
* 脱敏AccessKey:只显示前4位和后4位
*
* 示例:LTAI5tCx****RtEqP3
*/
public String sanitizeAccessKey(String ak) {
if (ak == null || ak.length() <= 8) return "***";
return ak.substring(0, 4) + "****" + ak.substring(ak.length() - 4);
}
}
// 使用示例
log.info("生成预签名URL成功: userId={}, path={}",
userId, logSanitizer.sanitizeUrl(putUrl));
// 日志输出(安全版本):
// [2026-05-16 10:23:45] INFO OssStorageService - 生成预签名URL成功: userId=12345,
// path=https://bucket.oss...com/posts/12345/images/20260516/abc12345.jpg?***
```
*** ** * ** ***
##### 三、即使URL被截取后的"损害控制"
假设最坏的情况发生了------攻击者拿到了一个有效的预签名URL(还在10分钟有效期内)。他最多能做什么?我们如何把损失降到最低?
###### 攻击者能做的:
| 攻击方式 | 危害 | 我们的防御 |
|------------------|-------------------|----------------------------------|
| **覆盖原文件** | 用户上传的图片被替换成恶意图片 | confirm接口会校验SHA256,不匹配则拒绝 |
| **上传垃圾数据** | OSS 存储成本增加 | 定期清理孤儿文件(无关联DB记录的OSS对象) |
| **上传恶意HTML/SVG** | 如果被当作图片展示,可能导致XSS | Content-Type限制为 image/\*,浏览器不会执行 |
| **消耗带宽** | 上传超大文件耗尽带宽 | 限制 Content-Length(如最大10MB) |
###### 攻击者做不到的:
1. **无法修改 ObjectKey 路径**(由服务端硬编码生成)
2. **无法让文件出现在别人的文章中**(confirm 接口校验权限)
3. **无法获取文件的公开访问URL**(confirm 成功后才生成 content_url)
4. **无法长期滥用**(10分钟后URL失效)
###### "损害控制"的最佳实践:
```java
/**
* confirm 接口的多层校验(最后一道防线)
*/
@Transactional
@DelayedDoubleDelete(id = "#id", delay = 500)
public void confirmContent(long creatorId, long id, String objectKey,
String etag, Long size, String sha256) {
// 第一层:基础校验
KnowPosts post = knowPostMapper.selectById(id);
if (post == null || !post.getCreatorId().equals(creatorId)) {
throw new BusinessException("无权限");
}
// 第二层:ObjectKey 格式校验(防止路径遍历)
if (!objectKey.startsWith("posts/" + id + "/")) {
log.warn("可疑的ObjectKey: userId={}, objectKey={}", creatorId, objectKey);
throw new BusinessException("非法的对象路径");
}
// 第三层:文件大小合理性校验
if (size != null && (size < 100 || size > 10 * 1024 * 1024)) { // 100B ~ 10MB
log.warn("异常的文件大小: userId={}, size={}", creatorId, size);
throw new BusinessException("文件大小异常");
}
// 第四层:SHA256 完整性校验(核心!)
if (sha256 != null && !sha256.isBlank()) {
// 前端上传前会计算文件的 SHA256,这里做二次校验
// 如果 SHA256 不匹配,说明文件在传输过程中被篡改
String actualSha256 = calculateOssFileSha256(objectKey); // 调用OSS API获取文件哈希
if (!actualSha256.equalsIgnoreCase(sha256)) {
log.error("SHA256不匹配! expected={}, actual={}, userId={}",
sha256, actualSha256, creatorId);
// 可选:删除可疑文件
deleteOssObject(objectKey);
throw new BusinessException("文件完整性校验失败");
}
}
// 第五层:ETag 一致性校验
if (etag != null && !etag.isBlank()) {
// ETag 是 OSS 返回的文件唯一标识(通常是MD5)
String actualEtag = getOssObjectEtag(objectKey);
if (!actualEtag.equals(etag)) {
throw new BusinessException("ETag不匹配");
}
}
// 所有校验通过,更新数据库
lambdaUpdate()
.eq(KnowPosts::getId, id)
.set(KnowPosts::getContentObjectKey, objectKey)
.set(KnowPosts::getContentEtag, etag)
.set(KnowPosts::getContentSize, size)
.set(KnowPosts::getContentSha256, sha256)
.set(KnowPosts::getContentUrl, publicUrl(objectKey))
.update();
// 异步触发 RAG 索引...
}
```
**这个多层校验机制的意义:**
即使攻击者截获了预签名URL并上传了恶意文件,只要:
* 文件大小不在合理范围 → 被拦截
* 文件的 SHA256 与前端计算的值不匹配 → 被拦截(说明文件被篡改)
* ObjectKey 路径不符合规则 → 被拦截
那么这个恶意文件虽然存在于 OSS 中,但**永远不会与任何文章关联**,也不会对用户可见(因为 confirm 接口拒绝了)。
*** ** * ** ***
### 状态机设计:渐进式发布的灵魂
有了文件上传的基础设施,现在我们来设计核心的**状态机**------这是渐进式发布的灵魂所在。
#### 知光平台的文章状态定义
根据 \[KnowPosts.java\](file:///d:/ideaProject/zhiguang/src/main/java/com/xiaoce/zhiguang/knowpost/domain/po/KnowPosts.java#L72-L73) 实体类和 \[db.sql\](file:///d:/ideaProject/zhiguang/db/db.sql#L50) 建表语句:
```sql
CREATE TABLE know_posts (
id BIGINT PRIMARY KEY,
creator_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'draft'
COMMENT '状态: draft(草稿), published(已发布), deleted(已删除)',
title VARCHAR(255),
content_object_key VARCHAR(512) COMMENT 'OSS 对象路径',
content_url VARCHAR(1024) COMMENT '公开访问URL',
publish_time DATETIME COMMENT '发布时间',
-- ... 其他字段
);
```
#### 状态流转图
**图:文章状态机流转图**
POST /drafts
创建草稿
POST /{id}/publish
正式发布
DELETE /{id}
删除草稿
DELETE /{id}
下架文章(软删除)
Draft
Published
Deleted
草稿状态:
- 只有作者可见
- 可多次编辑内容和元数据
- 不计入用户的"文章数"
已发布状态:
- 所有用户可见
- 出现在Feed流和搜索结果中
- 触发计数器更新 (+1)
- 触发RAG向量化索引
已删除状态:
- 软删除(非物理删除)
- 对所有用户不可见
- 计数器更新 (-1)
- 数据保留一段时间后可物理清理
**文字版状态说明(防止平台不渲染):**
**三种状态定义:**
1. **DRAFT(草稿)**:
* 初始状态,只有作者自己能看到
* 可以反复编辑内容(上传文件、修改标题、调整标签等)
* 不出现在 Feed 流、搜索结果中
* 不计入用户的"文章数"统计
2. **PUBLISHED(已发布)**:
* 正式对外展示,所有符合条件的用户都能看到
* 出现在首页 Feed 流、搜索结果、个人主页中
* 触发副作用:计数器 +1、RAG 向量化索引、粉丝通知(未来可扩展)
3. **DELETED(已删除)**:
* 软删除(数据库记录保留,但标记为 deleted)
* 对所有用户不可见(查询时会过滤 `status != 'deleted'`)
* 触发副作用:计数器 -1
* 数据保留一段时间后可物理清理或归档
**关键约束(必须遵守):**
* 只能从 DRAFT → PUBLISHED(不能跳过草稿直接发布)
* DRAFT → DELETED(草稿可以随时删除)
* PUBLISHED → DELETED(已发布的文章可以下架)
* **PUBLISHED 和 DELETED 都是终态**(不能再回到 DRAFT)
* 每次状态变更都必须校验当前状态(防止并发重复操作)
*** ** * ** ***
### 完整生命周期:5个阶段的实现细节
#### 阶段一:创建草稿
**接口:** `POST /api/v1/knowposts/drafts`
**实现:** \[KnowPostsServiceImpl.createDraft()\](file:///d:/ideaProject/zhiguang/src/main/java/com/xiaoce/zhiguang/knowpost/service/impl/KnowPostsServiceImpl.java#L163-L183)
```java
@Transactional
public long createDraft(long creatorId) {
// 1. 生成全局唯一ID(雪花算法)
long id = idGen.nextId();
Instant now = Instant.now();
// 2. 构建实体对象
KnowPosts knowPosts = KnowPosts.builder()
.id(id)
.creatorId(creatorId)
.status("draft") // 初始状态:草稿
.type("image_text")
.visible("public")
.isTop(false)
.createTime(now)
.updateTime(now)
.build();
// 3. 持久化到数据库
this.save(knowPosts);
return id; // 返回帖子ID,供后续操作使用
}
```
**设计要点:**
* 使用**雪花算法**生成分布式唯一ID(避免主键冲突)
* 初始状态固定为 `draft`(不允许自定义)
* 返回 ID 给前端,后续所有操作都基于这个 ID
*** ** * ** ***
#### 阶段二:内容确认(OSS 文件关联)
**接口:** `POST /api/v1/knowposts/{id}/content/confirm`
**实现:** \[KnowPostsServiceImpl.confirmContent()\](file:///d:/ideaProject/zhiguang/src/main/java/com/xiaoce/zhiguang/knowpost/service/impl/KnowPostsServiceImpl.java#L205-L243)
这是最复杂的一个阶段,因为它涉及到**文件上传后的回调确认**。
```java
@Transactional(rollbackFor = Exception.class)
@DelayedDoubleDelete(id = "#id", delay = 500) // 自定义注解:延迟双删缓存
public void confirmContent(long creatorId, long id, String objectKey,
String etag, Long size, String sha256) {
// 1. 清除旧缓存(防止脏读)
invalidateCache(id);
// 2. 条件更新(确保权限 + 原子性)
boolean success = lambdaUpdate()
.eq(KnowPosts::getId, id)
.eq(KnowPosts::getCreatorId, creatorId) // 权限校验:只有作者能操作
.set(objectKey != null, KnowPosts::getContentObjectKey, objectKey)
.set(etag != null, KnowPosts::getContentEtag, etag)
.set(size != null, KnowPosts::getContentSize, size)
.set(sha256 != null, KnowPosts::getContentSha256, sha256)
.set(KnowPosts::getContentUrl, publicUrl(objectKey)) // 生成公开访问URL
.set(KnowPosts::getUpdateTime, Instant.now())
.update();
// 3. 【核心】事务提交后异步触发副作用
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
try {
// 触发 RAG 向量化索引(用于AI智能问答)
ragIndexService.ensureIndexed(id);
} catch (Exception e) {
log.warn("Pre-index after content confirm failed, post {}: {}", id, e.getMessage());
// 容错:索引失败不影响主流程
}
}
});
}
```
**三个关键设计点解析:**
##### 设计点1:延迟双删缓存(@DelayedDoubleDelete)
**问题场景:**
T=0s 用户A读取帖子详情(命中缓存,返回旧数据)
T=1s 用户B更新了帖子内容(数据库更新成功,缓存被删除)
T=2s 用户A再次读取(缓存未重建,可能读到旧缓存或新查库)
**解决方案:**
* 更新数据库前,先删除一次缓存(第一次删除)
* 事务提交后,延迟 500ms 再删除一次缓存(第二次删除)
* 这能解决"缓存一致性"的经典问题
##### 设计点2:条件更新(防止并发冲突)
```java
.eq(KnowPosts::getCreatorId, creatorId) // 必须匹配作者ID
```
**为什么这样写?**
* 防止 CSRF 攻击(伪造请求操作别人的帖子)
* 防止竞态条件(两个请求同时更新同一条记录)
* 符合**最小权限原则**(只允许作者修改自己的内容)
##### 设计点3:事务同步回调(TransactionSynchronization)
**为什么要用 `afterCommit()`?**
因为 RAG 索引是一个**耗时操作**(可能需要几秒钟),如果放在事务内:
* 会延长数据库事务持有时间(增加锁竞争)
* 如果索引失败,会导致整个事务回滚(即使数据库更新已经成功了)
所以把它放到事务提交后异步执行,**即使失败也不影响主流程**。
*** ** * ** ***
#### 阶段三:元数据编辑
**接口:** `PATCH /api/v1/knowposts/{id}`
**实现:** \[KnowPostsServiceImpl.updateMetadata()\](file:///d:/ideaProject/zhiguang/src/main/java/com/xiaoce/zhiguang/knowpost/service/impl/KnowPostsServiceImpl.java#L261-L280)
这一阶段允许用户编辑文章的**非内容型元数据**:
```java
@Transactional
@DelayedDoubleDelete(id = "#id", delay = 500)
public void updateMetadata(long userId, long id, KnowPostUpdateRequest request) {
invalidateCache(id);
boolean success = lambdaUpdate()
.eq(KnowPosts::getId, id)
.eq(KnowPosts::getCreatorId, userId) // 权限校验
.set(request.title() != null, KnowPosts::getTitle, request.title())
.set(request.tagId() != null, KnowPosts::getTagId, request.tagId())
.set(request.tags() != null, KnowPosts::getTags, toJson(request.tags()))
.set(request.imgUrls() != null, KnowPosts::getImgUrls, toJson(request.imgUrls()))
.set(request.visible() != null, KnowPosts::getVisible, request.visible())
.set(request.isTop() != null, KnowPosts::getIsTop, request.isTop())
.set(request.description() != null, KnowPosts::getDescription, request.description()) // AI摘要
.set(KnowPosts::getUpdateTime, Instant.now())
.update();
if (!success) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "帖子不存在或无权限");
}
}
```
**可编辑的字段:**
| 字段 | 类型 | 说明 |
|---------------|---------|-----------------------------------------------|
| `title` | String | 文章标题 |
| `tagId` | Long | 分类ID(如"Java"、"Python") |
| `tags` | List | 标签列表(如\["Spring Boot","微服务"\]) |
| `imgUrls` | List | 封面图/配图URL列表 |
| `visible` | String | 可见性(public/private/followers/school/unlisted) |
| `isTop` | Boolean | 是否置顶 |
| `description` | String | AI生成的摘要(50字以内) |
**注意:** 这里没有更新 `content_object_key` 和 `content_url`,因为它们有专门的确认接口(阶段二)。这体现了**单一职责原则**。
*** ** * ** ***
#### 阶段四:正式发布
**接口:** `POST /api/v1/knowposts/{id}/publish`
**实现:** \[KnowPostsServiceImpl.publish()\](file:///d:/ideaProject/zhiguang/src/main/java/com/xiaoce/zhiguang/knowpost/service/impl/KnowPostsServiceImpl.java#L292-325)
这是整个流程的**高潮部分**------从"草稿"变成"已发布",文章开始对所有人可见。
```java
@Transactional
@DelayedDoubleDelete(id = "#id", delay = 500)
public void publish(long creatorId, long id) {
invalidateCache(id);
// 核心操作:状态机转换(draft → published)
boolean success = lambdaUpdate()
.eq(KnowPosts::getId, id)
.eq(KnowPosts::getCreatorId, creatorId)
.eq(KnowPosts::getStatus, "draft") // ⚠️ 状态守卫:只有草稿才能发布
.set(KnowPosts::getStatus, "published")
.set(KnowPosts::getPublishTime, Instant.now()) // 记录发布时间
.set(KnowPosts::getUpdateTime, Instant.now())
.update();
if (!success) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "草稿不存在或无权限");
}
// 发布后的副作用(异步执行,不阻塞响应)
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
// 1. 增加用户文章计数(Redis 计数器)
userCounterService.incrementPosts(creatorId, 1);
// 2. 触发 RAG 向量化索引(幂等操作,重复调用无害)
ragIndexService.ensureIndexed(id);
// 3. (未来可扩展)发送通知给粉丝
// notificationService.notifyFollowers(creatorId, id);
}
});
}
```
**关键设计点:状态守卫**
```java
.eq(KnowPosts::getStatus, "draft") // 只有 status=draft 的记录才能被更新
```
**为什么必须有这行?**
防止**并发发布** 和**重复操作**:
正常流程:
用户点击"发布" → status=draft → status=published ✅
异常场景(没有状态守卫):
用户快速点击两次"发布"
→ 第一次请求:status=draft → status=published ✅
→ 第二次请求:status=published → status=published ❓(语义错误)
有了状态守卫:
→ 第一次请求:status=draft → status=published ✅(影响行数=1)
→ 第二次请求:status=published ≠ draft → 匹配不到行 → 影响行数=0 → 抛出异常 ⚠️
*** ** * ** ***
#### 阶段五:软删除
**实现:** \[KnowPostsServiceImpl.delete()\](file:///d:/ideaProject/zhiguang/src/main/java/com/xiaoce/zhiguang/knowpost/service/impl/KnowPostsServiceImpl.java#L392-414)
```java
@Transactional
@DelayedDoubleDelete(id = "#id", delay = 500)
public void delete(long creatorId, long id) {
invalidateCache(id);
// 物理删除改为逻辑删除(标记为 deleted)
boolean success = lambdaUpdate()
.eq(KnowPosts::getId, id)
.eq(KnowPosts::getCreatorId, creatorId)
.set(KnowPosts::getStatus, "deleted") // 软删除
.set(KnowPosts::getUpdateTime, Instant.now())
.update();
// 异步更新计数器
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
userCounterService.incrementPosts(creatorId, -1); // 文章数 -1
}
});
}
```
**为什么用软删除而不是物理删除?**
1. **数据可恢复** :误删后可以从 `deleted` 状态恢复
2. **审计追踪**:保留删除时间和操作人,满足合规要求
3. **关联数据完整性**:如果文章有关联的评论、点赞记录,物理删除会导致外键约束问题
4. **法律要求**:某些地区法律规定用户数据必须保留一定期限才能彻底删除
*** ** * ** ***
### AI 能力集成:一键生成文章摘要
知光平台接入了 DeepSeek 大模型,帮助用户自动生成文章摘要。
#### 技术选型
**模型:** DeepSeek-Chat(通过 Spring AI 框架集成)
**配置:** \[application.yaml:81-98\](file:///d:/ideaProject/zhiguang/src/main/resources/application.yaml#L81-98)
```yaml
ai:
deepseek:
api-key: ${config.ai.deepseek-key}
base-url: https://api.deepseek.com
chat:
options:
model: deepseek-chat
temperature: 0.8 # 控制创造性(0=确定性输出,1=随机性输出)
```
#### 核心实现
**服务类:** \[KnowPostDescriptionServiceImpl\](file:///d:/ideaProject/zhiguang/src/main/java/com/xiaoce/zhiguang/llm/service/impl/KnowPostDescriptionServiceImpl.java)
```java
@Service
@RequiredArgsConstructor
public class KnowPostDescriptionServiceImpl implements IKnowPostDescriptionService {
private final ChatClient chatClient;
@Override
public String generateDescription(String content) {
// 1. 参数校验
if (content == null || content.trim().isEmpty()) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "正文内容不能为空");
}
// 2. System Prompt:角色设定 + 任务约束
String system = "你是中文文案编辑。请基于用户提供的知文正文,生成一个中文描述,"
+ "简洁有吸引力,且不超过50个汉字。不输出解释或多段,只输出结果。";
// 3. User Prompt:传入正文内容
String user = "正文如下:\n\n" + content + "\n\n请直接给出不超过50字的中文描述。";
// 4. 调用 DeepSeek 大模型
try {
String result = chatClient.prompt()
.system(system)
.user(user)
.options(DeepSeekChatOptions.builder()
.model("deepseek-chat")
.maxTokens(50) // 限制输出长度(防止模型啰嗦)
.temperature(0.8) // 平衡创造性和准确性
.build())
.call()
.content();
return postProcess(result); // 5. 后处理清洗
} catch (Exception e) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "大模型调用失败: " + e.getMessage());
}
}
}
```
#### 后处理算法(亮点)
大模型的输出往往不够规范(可能包含换行符、多余引号、超出字数限制等),所以需要一个精细的后处理步骤。
**方法:** \[postProcess()\](file:///d:/ideaProject/zhiguang/src/main/java/com/xiaoce/zhiguang/llm/service/impl/KnowPostDescriptionServiceImpl.java#L71-112)
```java
private String postProcess(String text) {
if (text == null) return "";
// 1. Unicode 标准化(NFKC):全角转半角,统一字符格式
String t = Normalizer.normalize(text, Normalizer.Form.NFKC)
.replaceAll("\r\n|\r|\n", " ") // 去除换行
.replaceAll("\\s+", " ") // 合并连续空白
.trim();
// 2. 去除首尾引号和末尾标点
t = t.replaceAll("^[\"'""'']+|[\"'""']+$", "") // 去除首尾引号
.replaceAll("[。!!??;;、]+$", ""); // 去除末尾标点
// 3. 严格字数截断(基于 CodePoint,兼容 Emoji)
int limit = 50;
int count = t.codePointCount(0, t.length());
if (count <= limit) return t;
// 按 Unicode Code Point 截断(不会截断 Emoji 等多字节字符)
StringBuilder sb = new StringBuilder();
int i = 0, added = 0;
while (i < t.length() && added < limit) {
int cp = t.codePointAt(i);
sb.appendCodePoint(cp);
i += Character.charCount(cp); // 正确处理 Surrogate Pair(如 Emoji)
added++;
}
return sb.toString();
}
```
**技术细节解析:**
1. **Unicode NFKC 标准化**:
* 把全角字母 `ABC` 转成半角 `ABC`
* 把特殊连字符统一成标准形式
* 保证字符比较的一致性
2. **基于 CodePoint 截断(而非 char length)**:
```java
// 错误做法(会截断 Emoji)
String truncated = text.substring(0, 50); // 可能截断 emoji 的后半部分
// 正确做法(基于 CodePoint)
int count = t.codePointCount(0, t.length()); // 准确计算字符数
```
因为 Java 的 `char` 是 16 位的,而 Emoji 是由一对 `char`(Surrogate Pair)组成的。如果用 `substring` 按 char 截断,可能会把 Emoji 截成两半,导致显示乱码。
*** ** * ** ***
### 架构设计思想总结
#### 设计模式应用
| 模式 | 应用场景 | 体现位置 | 解决什么问题 |
|----------------|-------------|------------------------------|----------------|
| **状态机模式** | 发布流程的状态转换 | `KnowPosts.status` 字段 | 保证状态一致性,防止非法跳转 |
| **策略模式** | 双模式文件上传 | `OssStorageServiceImpl` | 根据场景选择不同的上传方式 |
| **观察者模式** | 事务提交后触发异步任务 | `TransactionSynchronization` | 解耦主流程和副作用 |
| **模板方法** | 缓存失效统一处理 | `@DelayedDoubleDelete` 注解 | 统一缓存失效策略 |
| **Builder 模式** | 复杂对象构建 | `KnowPosts.builder()` | 提高可读性,避免参数顺序错误 |
#### 性能优化策略
1. **OSS 直传**:大文件不上传到后端,节省服务器带宽
2. **延迟双删缓存**:解决分布式环境下的缓存一致性问题
3. **SingleFlight**:防止缓存击穿(同一时刻只有一个请求回源查询数据库)
4. **热点检测**:自动识别热门文章,延长缓存时间
5. **异步解耦**:RAG 索引、计数器更新等非核心操作异步执行,提升响应速度
#### 安全性设计
1. **权限校验** :每个写操作都验证 `creatorId`,防止越权访问
2. **参数校验**:使用 Jakarta Validation 注解(@NotBlank, @NotNull)
3. **状态守卫** :发布操作要求当前状态必须为 `draft`
4. **路径规范化** :FileUtil 强制扩展名以 `.` 开头,防止路径遍历攻击
5. **预签名过期**:10分钟有效期限制,降低 URL 泄露风险
#### 容错与降级设计
**核心原则:非核心链路失败不能影响主流程。**
```java
// 示例:RAG 索引失败的容错处理
try {
ragIndexService.ensureIndexed(id);
} catch (Exception e) {
log.warn("Pre-index failed, post {}: {}", id, e.getMessage());
// 只记录警告日志,不抛异常,不影响用户发布文章
}
```
**哪些操作可以异步/降级?**
| 操作类型 | 是否核心 | 失败策略 |
|-------------|--------|---------------------|
| 数据库更新(状态变更) | **核心** | 必须成功,否则事务回滚 |
| OSS 文件上传确认 | **核心** | 必须成功,否则文章内容丢失 |
| 缓存删除 | 重要 | 失败可能导致短暂脏读,但最终一致 |
| RAG 向量化索引 | 非核心 | 失败只影响 AI 问答功能,可后续补偿 |
| 计数器更新 | 非核心 | 失败导致统计不准,但不影响业务 |
| 通知推送 | 非核心 | 失败只是用户暂时没收到通知 |
*** ** * ** ***
### 面试追问深度解析
> Q1:你们项目的发布系统是怎么设计的?为什么要用渐进式发布而不是一步到位?
>
> A:我们采用的是**状态机驱动的渐进式发布模式**,核心原因有三点:
>
> **1. 用户体验角度**
>
> * 用户可能写一半就去吃饭了,需要**草稿自动保存**
> * 大文件上传可能中断,需要**断点续传**能力
> * 发布前想预览效果,需要**编辑→预览→发布**的分阶段流程
>
> **2. 工程可靠性角度**
>
> * 文件上传和网络请求都可能失败,需要**原子性保证**(要么全成功,要么全失败)
> * 并发操作可能导致数据不一致,需要**状态守卫**(防止重复发布)
> * AI 摘要生成可能超时,需要**异步解耦**(不阻塞主流程)
>
> **3. 业务灵活性角度**
>
> * 不同可见性(公开/私密/仅粉丝可见)需要在发布前确定
> * 内容审核(如果有的话)应该在发布前完成
> * 定时发布功能需要先把内容准备好,到达指定时间才切换状态
>
> 如果采用"一步到位"的方式(前端一次性提交所有内容),一旦中途失败,用户就需要重新填写所有信息,体验很差。
*** ** * ** ***
> Q2:预签名URL上传的安全性怎么保证?如果有人拿到了预签名URL,是不是就能随意上传文件了?
>
> A:**安全性通过多层防护来保证:**
>
> **第一层:时效性限制**
>
> ```java
> int expiresIn = 600; // URL 有效期只有 10 分钟
> ```
>
> 即使 URL 泄露,10 分钟后就失效了。
>
> **第二层:Content-Type 限制**
>
> ```java
> request.setContentType(contentType); // 限制只能上传 image/jpeg
> ```
>
> 前端无法用这个 URL 上传 `.html` 或 `.js` 文件(OSS 会拒绝)。
>
> **第三层:ObjectKey 路径限制**
>
> ```java
> objectKey = "posts/" + postId + "/images/" + date + "/" + rand + ext;
> ```
>
> ObjectKey 由后端生成,前端无法指定路径(防止覆盖其他文件)。
>
> **第四层:权限校验**
>
> ```java
> if (!post.getCreatorId().equals(userId)) {
> throw new BusinessException("无权限");
> }
> ```
>
> 生成 URL 前会校验用户是否是该帖子的作者。
>
> **第五层:确认机制**
>
> 前端上传成功后,还需要调用 `/content/confirm` 接口回传元数据(ETag、SHA256),后端会校验这些信息是否合法。
>
> 所以即使有人拿到预签名URL,他也只能:
>
> * 在 10 分钟内使用
> * 只能上传指定 Content-Type 的文件
> * 只能上传到指定的 ObjectKey 路径
> * 上传后还需要通过确认接口验证
>
> 安全性是有保障的。
*** ** * ** ***
> Q3:为什么要用 `TransactionSynchronization.afterCommit()` 而不是直接在新线程里执行异步任务?
>
> A:**核心区别在于"事务感知"**。
>
> **方案A:直接开新线程(不推荐)**
>
> ```java
> @Transactional
> public void publish(...) {
> db.update(status="published");
>
> // 问题:此时事务还没提交!
> new Thread(() -> {
> ragIndexService.ensureIndexed(id); // 可能读到旧数据(status 还是 draft)
> }).start();
> }
> ```
>
> 因为 `@Transactional` 默认在方法返回后才提交事务,所以新线程启动时,数据库事务还没提交,可能读到不一致的数据。
>
> **方案B:使用 TransactionSynchronization(推荐)**
>
> ```java
> @Transactional
> public void publish(...) {
> db.update(status="published");
>
> TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
> @Override
> public void afterCommit() {
> // 此时事务已经提交,数据库状态是最新的
> ragIndexService.ensureIndexed(id); // 一定能读到 status=published
> }
> });
> }
> ```
>
> `afterCommit()` 会在事务成功提交后才执行,保证:
>
> 1. 数据库状态已经持久化
> 2. 其他事务一定能看到最新数据
> 3. 如果事务回滚了,`afterCommit()` 不会被调用(避免脏数据处理)
>
> 这是 Spring 提供的标准机制,比手动管理线程安全得多。
*** ** * ** ***
> Q4:如果用户在发布过程中刷新页面或者关闭浏览器,怎么办?会出现数据不一致吗?
>
> A:**不会出现数据不一致,因为我们采用了"幂等 + 最终一致"的设计。**
>
> **场景分析:**
>
> **情况1:创建草稿后关闭页面**
>
> * 数据库里有一条 `status=draft` 的记录
> * 下次用户打开编辑器,可以继续编辑这条草稿(基于 postId 加载)
> * 不会有任何数据丢失
>
> **情况2:上传文件到 OSS 后,没来得及调 confirm 接口**
>
> * OSS 里有一个孤立的文件(没有被任何帖子引用)
> * 可以通过定时任务清理"孤儿文件"(超过24小时未被引用的 OSS 对象)
> * 或者在前端增加"未保存提示",用户离开时提醒保存
>
> **情况3:调了 confirm 接口,但在 publish 之前关闭页面**
>
> * 数据库里 `content_object_key` 已更新,但 `status` 还是 `draft`
> * 文章对其他用户不可见(因为查询时会过滤 `status=published`)
> * 用户下次进来可以继续编辑并发布
>
> **情况4:正在执行 publish 操作时网络中断**
>
> * 要么事务成功提交(status=published
> * 要么事务回滚(status 保持 draft)✅
> * 不可能出现中间状态(数据库事务保证了原子性)
>
> **总结:** 我们的每个操作都是**幂等的** (重复调用结果相同)和**原子的**(要么全成功,要么全失败),所以无论用户在哪个环节中断,系统都能保持一致性。
*** ** * ** ***
### 总结
#### 核心思想
**渐进式发布不是简单的"分步骤",而是一种系统工程思维。** 它体现了以下几个原则:
1. **原子性原则**:每个操作都是不可分割的最小单元(创建草稿、确认内容、发布),要么全成功,要么全失败
2. **状态机原则**:明确定义状态和转换规则,防止非法跳转(不能从 published 回到 draft)
3. **异步解耦原则**:核心链路(数据库更新)和辅助链路(索引、计数、通知)分离,互不阻塞
4. **容错降级原则**:非核心操作的失败不影响主流程(AI 索引失败不影响文章发布)
5. **安全最小权限原则**:每次操作都校验权限,防止越权访问
#### 技术栈总结
| 层次 | 技术手段 | 解决什么问题 | 复杂度 |
|-----------|----------------------------|------------|-----|
| **文件存储** | 阿里云 OSS + 预签名URL直传 | 大文件高效上传 | 中 |
| **状态管理** | 数据库字段 + 条件更新 | 保证状态一致性 | 低 |
| **缓存一致性** | 延迟双删 + SingleFlight | 防止脏读和缓存击穿 | 中 |
| **异步任务** | TransactionSynchronization | 解耦主流程和副作用 | 低 |
| **AI 集成** | Spring AI + DeepSeek | 自动生成摘要 | 中 |
| **文本处理** | Unicode CodePoint | 精准截断和多语言支持 | 低 |
#### 适用场景
这种渐进式发布模式特别适合以下场景:
* **内容管理系统(CMS)**:博客、新闻、知识库
* **电商平台**:商品上架(草稿→审核→上架→下架)
* **社交平台**:动态发布(编辑→发布→删除)
* **办公协作**:文档编辑(多人协作 + 版本控制)
* **应用商店**:APP 提交审核(开发→测试→审核→上线)
#### 一句话收尾
> **好的发布系统应该像"自动挡汽车"一样**:用户只需要踩油门(写内容)和刹车(发布),中间的换挡、离合、转向全部由系统自动完成。用户感知不到复杂性,但背后的每一个细节都经过精心设计。
这就是渐进式发布的魅力:**把复杂留给系统,把简单留给用户。**