+ * 封装 WebOffice Token 生成、刷新等操作 + *
+ * + * @author reading-platform + * @since 2026-03-16 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ImmUtil { + + private final ImmConfig immConfig; + + /** + * 生成 WebOffice Token(只读权限) + * + * @param sourceURI OSS 文件路径(如:oss://bucket-name/path/to/file.pptx) + * @param name 文件名 + * @return WebOffice Token 信息 + */ + public ImmTokenVo generateWebofficeTokenReadOnly(String sourceURI, String name) { + return generateWebofficeToken(sourceURI, name, true); + } + + /** + * 生成 WebOffice Token(编辑权限) + * + * @param sourceURI OSS 文件路径(如:oss://bucket-name/path/to/file.pptx) + * @param name 文件名 + * @return WebOffice Token 信息 + */ + public ImmTokenVo generateWebofficeToken(String sourceURI, String name) { + return generateWebofficeToken(sourceURI, name, false); + } + + /** + * 生成 WebOffice Token + * + * @param sourceURI OSS 文件路径(如:oss://bucket-name/path/to/file.pptx) + * @param name 文件名 + * @param isReadOnly 是否只读 + * @return WebOffice Token 信息 + */ + private ImmTokenVo generateWebofficeToken(String sourceURI, String name, boolean isReadOnly) { + try { + log.info("生成 WebOffice Token: sourceURI={}, name={}, isReadOnly={}", sourceURI, name, isReadOnly); + + // 获取 IMM 客户端 + Client client = getImmClient(); + + // 构建权限配置 + WebofficePermission permission = new WebofficePermission() + .setReadonly(isReadOnly) + .setCopy(!isReadOnly) + .setExport(!isReadOnly) + .setPrint(!isReadOnly) + .setHistory(!isReadOnly) + .setRename(!isReadOnly); + + // 构建请求 + GetWebofficeURLRequest request = new GetWebofficeURLRequest() + .setProjectName(immConfig.getProjectName()) + .setSourceURI(sourceURI) + .setFilename(name) + .setPermission(permission); + + // 发送请求 + GetWebofficeURLResponse response = client.getWebofficeURL(request); + + // 解析响应 + GetWebofficeURLResponseBody body = response.getBody(); + + // 计算过期时间 + Long expireTime = null; + if (body.getAccessTokenExpiredTime() != null) { + try { + expireTime = Instant.parse(body.getAccessTokenExpiredTime()).toEpochMilli(); + } catch (Exception e) { + log.warn("解析过期时间失败:{}", e.getMessage()); + } + } + + // 构建响应 VO + ImmTokenVo tokenVo = ImmTokenVo.builder() + .webofficeURL(body.getWebofficeURL()) + .accessToken(body.getAccessToken()) + .refreshToken(body.getRefreshToken()) + .expireTime(expireTime) + .build(); + + log.info("WebOffice Token 生成成功:webofficeURL={}", maskString(body.getWebofficeURL())); + return tokenVo; + + } catch (Exception e) { + log.error("生成 WebOffice Token 失败:{}", e.getMessage(), e); + throw new RuntimeException("生成 WebOffice Token 失败:" + e.getMessage(), e); + } + } + + /** + * 刷新 WebOffice Token + * + * @param accessToken 当前访问凭证 + * @param refreshToken 刷新凭证 + * @return 刷新后的 Token 信息 + */ + public ImmTokenVo refreshWebofficeToken(String accessToken, String refreshToken) { + try { + log.info("刷新 WebOffice Token: accessToken={}, refreshToken={}", + maskString(accessToken), maskString(refreshToken)); + + // 获取 IMM 客户端 + Client client = getImmClient(); + + // 构建请求 + RefreshWebofficeTokenRequest request = new RefreshWebofficeTokenRequest() + .setProjectName(immConfig.getProjectName()) + .setAccessToken(accessToken) + .setRefreshToken(refreshToken); + + // 发送请求 + RefreshWebofficeTokenResponse response = client.refreshWebofficeToken(request); + + // 解析响应 + RefreshWebofficeTokenResponseBody body = response.getBody(); + + // 计算过期时间 + Long expireTime = null; + if (body.getAccessTokenExpiredTime() != null) { + try { + expireTime = Instant.parse(body.getAccessTokenExpiredTime()).toEpochMilli(); + } catch (Exception e) { + log.warn("解析过期时间失败:{}", e.getMessage()); + } + } + + // 构建响应 VO(刷新 Token 不返回 webofficeURL) + ImmTokenVo tokenVo = ImmTokenVo.builder() + .accessToken(body.getAccessToken()) + .refreshToken(body.getRefreshToken()) + .expireTime(expireTime) + .build(); + + log.info("WebOffice Token 刷新成功"); + return tokenVo; + + } catch (Exception e) { + log.error("刷新 WebOffice Token 失败:{}", e.getMessage(), e); + throw new RuntimeException("刷新 WebOffice Token 失败:" + e.getMessage(), e); + } + } + + /** + * 获取 IMM 客户端 + * + * @return IMM 客户端实例 + */ + private Client getImmClient() { + try { + // 配置访问参数 + com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config() + .setAccessKeyId(immConfig.getAccessKeyId()) + .setAccessKeySecret(immConfig.getAccessKeySecret()) + .setRegionId(immConfig.getRegion()) + .setEndpoint(immConfig.getEndpoint()); + + // 创建客户端 + return new Client(config); + } catch (Exception e) { + log.error("创建 IMM 客户端失败:{}", e.getMessage(), e); + throw new RuntimeException("创建 IMM 客户端失败:" + e.getMessage(), e); + } + } + + /** + * 脱敏显示字符串(用于日志) + * + * @param str 原始字符串 + * @return 脱敏后的字符串 + */ + private String maskString(String str) { + if (str == null || str.length() < 10) { + return "***"; + } + return str.substring(0, 3) + "..." + str.substring(str.length() - 3); + } + + /** + * 将 OSS URL 转换为 IMM 所需的 SourceURI 格式 + * + * @param ossUrl OSS 文件 URL + * @return IMM SourceURI 格式 + */ + public String convertOssUrlToSourceURI(String ossUrl) { + if (ossUrl == null || ossUrl.isEmpty()) { + throw new IllegalArgumentException("OSS URL 不能为空"); + } + + // 如果已经是 oss:// 格式,直接返回 + if (ossUrl.startsWith("oss://")) { + return ossUrl; + } + + // 从完整 URL 提取 bucket 和 object key + // 格式:https://bucket-name.endpoint/object-key + try { + String url = ossUrl.replace("https://", ""); + int firstSlash = url.indexOf("/"); + if (firstSlash == -1) { + throw new IllegalArgumentException("无效的 OSS URL 格式:" + ossUrl); + } + + String bucketAndEndpoint = url.substring(0, firstSlash); + String objectKey = url.substring(firstSlash + 1); + + // 提取 bucket 名称(endpoint 之前) + String bucket = bucketAndEndpoint.split("\\.")[0]; + + // 构建 oss://bucket/object-key 格式 + return "oss://" + bucket + "/" + objectKey; + } catch (Exception e) { + log.error("解析 OSS URL 失败:{}", e.getMessage(), e); + throw new IllegalArgumentException("无效的 OSS URL 格式:" + ossUrl, e); + } + } +} diff --git a/reading-platform-java/src/main/java/com/reading/platform/controller/ImmController.java b/reading-platform-java/src/main/java/com/reading/platform/controller/ImmController.java new file mode 100644 index 0000000..32db5af --- /dev/null +++ b/reading-platform-java/src/main/java/com/reading/platform/controller/ImmController.java @@ -0,0 +1,125 @@ +package com.reading.platform.controller; + +import com.reading.platform.common.response.Result; +import com.reading.platform.common.util.ImmUtil; +import com.reading.platform.dto.response.ImmTokenVo; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +/** + * 阿里云 IMM(智能媒体服务)控制器 + *+ * 提供 WebOffice Token 生成、刷新等接口 + *
+ * + * @author reading-platform + * @since 2026-03-16 + */ +@RestController +@RequestMapping("/api/v1/imm") +@RequiredArgsConstructor +@Tag(name = "阿里云 IMM 服务", description = "WebOffice 文档预览和编辑相关接口") +public class ImmController { + + private final ImmUtil immUtil; + + /** + * 生成只读 WebOffice Token + *+ * 用于前端预览文档(PPT、Word、Excel 等),仅支持查看,不支持编辑 + *
+ * + * @param url OSS 文件路径(如:oss://bucket-name/path/to/file.pptx 或完整 HTTPS URL) + * @param name 文件名 + * @return WebOffice Token 信息 + */ + @GetMapping("/token/readonly") + @Operation(summary = "生成只读 WebOffice Token", description = "用于文档预览,仅支持查看") + public Result+ * 用于前端编辑文档(PPT、Word、Excel 等),支持查看和编辑 + *
+ * + * @param url OSS 文件路径(如:oss://bucket-name/path/to/file.pptx 或完整 HTTPS URL) + * @param name 文件名 + * @return WebOffice Token 信息 + */ + @GetMapping("/token") + @Operation(summary = "生成编辑 WebOffice Token", description = "用于文档编辑,支持查看和修改") + public Result+ * 当 Token 即将过期时,使用 refreshToken 刷新获取新的 Token + *
+ * + * @param request 刷新请求,包含 accessToken 和 refreshToken + * @return 刷新后的 WebOffice Token 信息 + */ + @PostMapping("/token/refresh") + @Operation(summary = "刷新 WebOffice Token", description = "当 Token 即将过期时刷新") + public Result+ * 用于前端 WebOffice 文档预览和编辑 + *
+ * + * @author reading-platform + * @since 2026-03-16 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImmTokenVo { + + /** + * WebOffice 地址 + */ + private String webofficeURL; + + /** + * 访问凭证 + */ + private String accessToken; + + /** + * 刷新凭证 + */ + private String refreshToken; + + /** + * 过期时间(毫秒时间戳) + */ + private Long expireTime; +} diff --git a/reading-platform-java/src/main/resources/application-dev.yml b/reading-platform-java/src/main/resources/application-dev.yml index 900b84d..6529c14 100644 --- a/reading-platform-java/src/main/resources/application-dev.yml +++ b/reading-platform-java/src/main/resources/application-dev.yml @@ -78,7 +78,13 @@ aliyun: max-file-size: ${OSS_MAX_FILE_SIZE:10485760} # 前端直传跨域:启动时自动配置 OSS CORS cors-enabled: ${OSS_CORS_ENABLED:true} - cors-allowed-origins: ${OSS_CORS_ORIGINS:http://localhost:5173,http://localhost:5174} + cors-allowed-origins: ${OSS_CORS_ORIGINS:*} + imm: + endpoint: ${IMM_ENDPOINT:imm.cn-shenzhen.aliyuncs.com} + region: ${IMM_REGION:cn-shenzhen} + project-name: ${IMM_PROJECT_NAME:kid-course-platform-doc} + access-key-id: ${IMM_ACCESS_KEY_ID:LTAI5tKZhPofbThbSzDSiWoK} + access-key-secret: ${IMM_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM} # 日志配置(开发环境 - DEBUG 级别) logging: diff --git a/reading-platform-java/src/main/resources/application-prod.yml b/reading-platform-java/src/main/resources/application-prod.yml index 64267a1..5496bc2 100644 --- a/reading-platform-java/src/main/resources/application-prod.yml +++ b/reading-platform-java/src/main/resources/application-prod.yml @@ -74,8 +74,15 @@ aliyun: access-key-secret: ${OSS_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM} bucket-name: ${OSS_BUCKET_NAME:lesingle-kid-course} max-file-size: ${OSS_MAX_FILE_SIZE:10485760} - cors-enabled: ${OSS_CORS_ENABLED:false} - cors-allowed-origins: ${OSS_CORS_ORIGINS:} + # 前端直传跨域:启动时自动配置 OSS CORS + cors-enabled: ${OSS_CORS_ENABLED:true} + cors-allowed-origins: ${OSS_CORS_ORIGINS:*} + imm: + endpoint: ${IMM_ENDPOINT:imm.cn-shenzhen.aliyuncs.com} + region: ${IMM_REGION:cn-shenzhen} + project-name: ${IMM_PROJECT_NAME:kid-course-platform-doc} + access-key-id: ${IMM_ACCESS_KEY_ID:LTAI5tKZhPofbThbSzDSiWoK} + access-key-secret: ${IMM_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM} # 日志配置(生产环境 - 降低日志级别) logging: diff --git a/reading-platform-java/src/main/resources/application-test.yml b/reading-platform-java/src/main/resources/application-test.yml index 1b7f2b7..0b8aac6 100644 --- a/reading-platform-java/src/main/resources/application-test.yml +++ b/reading-platform-java/src/main/resources/application-test.yml @@ -74,6 +74,15 @@ aliyun: access-key-secret: ${OSS_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM} bucket-name: ${OSS_BUCKET_NAME:lesingle-kid-course} max-file-size: ${OSS_MAX_FILE_SIZE:10485760} + # 前端直传跨域:启动时自动配置 OSS CORS + cors-enabled: ${OSS_CORS_ENABLED:true} + cors-allowed-origins: ${OSS_CORS_ORIGINS:*} + imm: + endpoint: ${IMM_ENDPOINT:imm.cn-shenzhen.aliyuncs.com} + region: ${IMM_REGION:cn-shenzhen} + project-name: ${IMM_PROJECT_NAME:kid-course-platform-doc} + access-key-id: ${IMM_ACCESS_KEY_ID:LTAI5tKZhPofbThbSzDSiWoK} + access-key-secret: ${IMM_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM} # 日志配置(测试环境) logging: diff --git a/reading-platform-java/test-course.json b/reading-platform-java/test-course.json new file mode 100644 index 0000000..60499b7 --- /dev/null +++ b/reading-platform-java/test-course.json @@ -0,0 +1,10 @@ +{ + "name": "Test Course 005", + "themeId": null, + "gradeTags": "[\"class_a\"]", + "pictureBookName": "Test Picture Book", + "coreContent": "Test Content", + "durationMinutes": 25, + "domainTags": "[\"social\"]", + "introSummary": "Test Summary" +} diff --git a/reading-platform-java/test-course2.json b/reading-platform-java/test-course2.json new file mode 100644 index 0000000..b6c57d7 --- /dev/null +++ b/reading-platform-java/test-course2.json @@ -0,0 +1,10 @@ +{ + "name": "Test Course 007", + "themeId": null, + "gradeTags": "[]", + "pictureBookName": "Test Picture Book", + "coreContent": "Test Content", + "durationMinutes": 25, + "domainTags": "[]", + "introSummary": "Test Summary" +}