渐进式发布

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

从一个真实痛点说起

假设你在做一个知识社区平台(类似知乎、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 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 提交审核(开发→测试→审核→上线) #### 一句话收尾 > **好的发布系统应该像"自动挡汽车"一样**:用户只需要踩油门(写内容)和刹车(发布),中间的换挡、离合、转向全部由系统自动完成。用户感知不到复杂性,但背后的每一个细节都经过精心设计。 这就是渐进式发布的魅力:**把复杂留给系统,把简单留给用户。**

相关推荐
小则又沐风a1 小时前
深入理解进程概念 第三章 进程调度切换
java·linux·服务器·前端
努力攀登的小k1 小时前
《Java基础,Java多态入门到进阶:重写、重载、转型的逻辑与实战避坑》
java·开发语言
甲方大人请饶命1 小时前
Java-集合进阶
java·开发语言
噗噗121 小时前
基于 Go 语言实现企业大群发任务的平滑限流与多线程漏斗调度器
java·开发语言
甲方大人请饶命1 小时前
Java-异常、File
java·开发语言
多敲代码防脱发2 小时前
Spring进阶(Aware接口)
java·后端·spring
Chase_______2 小时前
【Java基础核心知识点全解·01】Java运行机制详解:从 HelloWorld 到 classpath 找类流程
java·开发语言·python
未若君雅裁2 小时前
SpringMVC 执行流程详解
java·spring boot·spring·状态模式