diff --git a/reading-platform-frontend/playwright-report/data/7e95ea456e51127a93908e95717ba392c2846ff5.md b/reading-platform-frontend/playwright-report/data/7e95ea456e51127a93908e95717ba392c2846ff5.md new file mode 100644 index 0000000..a5a4c63 --- /dev/null +++ b/reading-platform-frontend/playwright-report/data/7e95ea456e51127a93908e95717ba392c2846ff5.md @@ -0,0 +1,121 @@ +# Page snapshot + +```yaml +- generic [ref=e3]: + - complementary [ref=e4]: + - generic [ref=e5]: + - generic [ref=e6]: + - img "Logo" [ref=e7] + - generic [ref=e8]: + - generic [ref=e9]: 少儿智慧阅读 + - generic [ref=e10]: 服务管理后台 + - menu [ref=e12]: + - menuitem "数据看板" [ref=e13] [cursor=pointer]: + - img [ref=e14] + - generic [ref=e20]: 数据看板 + - menuitem "课程包管理" [ref=e21] [cursor=pointer]: + - img [ref=e22] + - generic [ref=e25]: 课程包管理 + - menuitem "database 套餐管理" [ref=e26] [cursor=pointer]: + - img "database" [ref=e27]: + - img [ref=e28] + - generic [ref=e31]: 套餐管理 + - menuitem "format-painter 主题字典" [ref=e32] [cursor=pointer]: + - img "format-painter" [ref=e33]: + - img [ref=e34] + - generic [ref=e37]: 主题字典 + - menuitem "租户管理" [ref=e38] [cursor=pointer]: + - img [ref=e39] + - generic [ref=e44]: 租户管理 + - menuitem "资源库" [ref=e45] [cursor=pointer]: + - img [ref=e46] + - generic [ref=e49]: 资源库 + - menuitem "系统设置" [ref=e50] [cursor=pointer]: + - img [ref=e51] + - generic [ref=e55]: 系统设置 + - generic [ref=e56]: + - generic [ref=e57]: + - img "menu-fold" [ref=e59] [cursor=pointer]: + - img [ref=e60] + - generic [ref=e63]: + - generic [ref=e65]: + - img "bell" [ref=e66] [cursor=pointer]: + - img [ref=e67] + - superscript [ref=e69]: + - paragraph [ref=e71]: "5" + - generic [ref=e73] [cursor=pointer]: + - img "user" [ref=e76]: + - img [ref=e77] + - generic [ref=e79]: 系统管理员 + - img "down" [ref=e81]: + - img [ref=e82] + - main [ref=e84]: + - generic [ref=e85]: + - generic [ref=e87]: + - generic [ref=e88]: + - button "返回" [ref=e90] [cursor=pointer]: + - img "arrow-left" [ref=e91]: + - img [ref=e92] + - generic "编辑课程包" [ref=e94] + - generic [ref=e98]: + - button "保存草稿" [ref=e100] [cursor=pointer]: + - generic [ref=e101]: 保存草稿 + - button "保 存" [ref=e103] [cursor=pointer]: + - generic [ref=e104]: 保 存 + - generic [ref=e108]: + - generic [ref=e109]: + - button "check 基本信息" [ref=e111] [cursor=pointer]: + - img "check" [ref=e114]: + - img [ref=e115] + - generic [ref=e118]: 基本信息 + - button "check 课程介绍" [ref=e120] [cursor=pointer]: + - img "check" [ref=e123]: + - img [ref=e124] + - generic [ref=e127]: 课程介绍 + - button "check 排课参考" [ref=e129] [cursor=pointer]: + - img "check" [ref=e132]: + - img [ref=e133] + - generic [ref=e136]: 排课参考 + - button "check 导入课" [ref=e138] [cursor=pointer]: + - img "check" [ref=e141]: + - img [ref=e142] + - generic [ref=e145]: 导入课 + - button "check 集体课" [ref=e147] [cursor=pointer]: + - img "check" [ref=e150]: + - img [ref=e151] + - generic [ref=e154]: 集体课 + - button "check 领域课" [ref=e156] [cursor=pointer]: + - img "check" [ref=e159]: + - img [ref=e160] + - generic [ref=e163]: 领域课 + - button "7 环创建设" [ref=e165]: + - generic [ref=e166]: "7" + - generic [ref=e168]: 环创建设 + - generic [ref=e169]: + - generic [ref=e170]: 完成度 + - progressbar [ref=e171]: + - img "check-circle" [ref=e176]: + - img [ref=e177] + - generic [ref=e179]: + - text: "* : * : * : : * : 26 / 200 : : : 60 / 1500 64 / 1500 71 / 1500 61 / 1500 33 / 1500 35 / 1500 33 / 1500 36 / 1500 : : : 0 / 500 : : : * : 94 / 1500 * : 64 / 1500 : 0 / 1500 : 0 / 1500 : 0 / 1500 : : : 0 / 500 : : : * : 0 / 1500 * : 0 / 1500 : 0 / 1500 : 0 / 1500 : 0 / 1500 : : : 0 / 500 : : : * : 64 / 1500 * : 0 / 1500 : 0 / 1500 : 0 / 1500 : 0 / 1500 : : : 0 / 500 : : : * : 68 / 1500 * : 0 / 1500 : 0 / 1500 : 0 / 1500 : 0 / 1500" + - generic [ref=e180]: + - generic [ref=e181]: + - generic [ref=e182]: 环创建设 + - generic [ref=e183]: 已填写 + - alert [ref=e185]: + - img "info-circle" [ref=e186]: + - img [ref=e187] + - generic [ref=e190]: + - generic [ref=e191]: 填写提示 + - generic [ref=e192]: 环创建设内容可包括:主题环境布置、区域活动环境、阅读角创设、材料投放建议等,帮助教师更好地创设支持幼儿学习的环境。 + - generic [ref=e194]: + - textbox "请输入环创建设内容,例如: - 主题墙布置建议 - 阅读区环境创设 - 材料展示区设置 - 互动区域规划 - 相关装饰物品建议等" [ref=e195]: + - /placeholder: "请输入环创建设内容,例如:\r\n- 主题墙布置建议\r\n- 阅读区环境创设\r\n- 材料展示区设置\r\n- 互动区域规划\r\n- 相关装饰物品建议等" + - text: 1. 夸夸卡展示区:展示幼儿制作的夸夸卡 2. "我的特别之处"展示墙:张贴幼儿分享的特别之处作品 3. 兔子探秘墙:张贴兔子图片和观察记录 4. 音乐角环创:张贴儿歌歌词图谱、兔子头饰 5. 健康小卫士展示区:张贴保护耳朵方法海报 + - text: 116 / 3000 + - generic [ref=e196]: + - button "上一步" [ref=e197] [cursor=pointer]: + - generic [ref=e198]: 上一步 + - button "保 存" [active] [ref=e199] [cursor=pointer]: + - generic [ref=e200]: 保 存 +``` \ No newline at end of file diff --git a/reading-platform-frontend/playwright-report/data/84d5e726046ba1814754cd179539627d3ff963f4.webm b/reading-platform-frontend/playwright-report/data/84d5e726046ba1814754cd179539627d3ff963f4.webm new file mode 100644 index 0000000..417d52f Binary files /dev/null and b/reading-platform-frontend/playwright-report/data/84d5e726046ba1814754cd179539627d3ff963f4.webm differ diff --git a/reading-platform-frontend/playwright-report/data/fecb36cbd5e3c8f341fe6bf2a9b5befd8f6079ee.png b/reading-platform-frontend/playwright-report/data/fecb36cbd5e3c8f341fe6bf2a9b5befd8f6079ee.png new file mode 100644 index 0000000..f8b53fd Binary files /dev/null and b/reading-platform-frontend/playwright-report/data/fecb36cbd5e3c8f341fe6bf2a9b5befd8f6079ee.png differ diff --git a/reading-platform-frontend/src/api/generated/model/courseLessonResponse.ts b/reading-platform-frontend/src/api/generated/model/courseLessonResponse.ts new file mode 100644 index 0000000..23511f1 --- /dev/null +++ b/reading-platform-frontend/src/api/generated/model/courseLessonResponse.ts @@ -0,0 +1,55 @@ +/** + * Generated by orval v8.5.3 🍺 + * Do not edit manually. + * Reading Platform API + * Reading Platform Backend Service API Documentation + * OpenAPI spec version: 1.0.0 + */ + +/** + * 课程环节响应 + */ +export interface CourseLessonResponse { + /** ID */ + id?: number; + /** 课程 ID */ + courseId?: number; + /** 课程类型 */ + lessonType?: string; + /** 名称 */ + name?: string; + /** 描述 */ + description?: string; + /** 时长(分钟) */ + duration?: number; + /** 视频路径 */ + videoPath?: string; + /** 视频名称 */ + videoName?: string; + /** PPT 路径 */ + pptPath?: string; + /** PPT 名称 */ + pptName?: string; + /** PDF 路径 */ + pdfPath?: string; + /** PDF 名称 */ + pdfName?: string; + /** 教学目标 */ + objectives?: string; + /** 教学准备 */ + preparation?: string; + /** 教学延伸 */ + extension?: string; + /** 教学反思 */ + reflection?: string; + /** 评估数据 */ + assessmentData?: string; + /** 是否使用模板 */ + useTemplate?: boolean; + /** 排序号 */ + sortOrder?: number; + /** 创建时间 */ + createdAt?: string; + /** 更新时间 */ + updatedAt?: string; +} diff --git a/reading-platform-frontend/src/api/generated/model/coursePackageCourseItem.ts b/reading-platform-frontend/src/api/generated/model/coursePackageCourseItem.ts new file mode 100644 index 0000000..ac4bd5b --- /dev/null +++ b/reading-platform-frontend/src/api/generated/model/coursePackageCourseItem.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v8.5.3 🍺 + * Do not edit manually. + * Reading Platform API + * Reading Platform Backend Service API Documentation + * OpenAPI spec version: 1.0.0 + */ + +/** + * 课程包中的课程项 + */ +export interface CoursePackageCourseItem { + /** 课程 ID */ + id?: number; + /** 课程名称 */ + name?: string; + /** 适用年级 */ + gradeLevel?: string; + /** 排序号 */ + sortOrder?: number; +} diff --git a/reading-platform-frontend/src/api/generated/model/coursePackageResponse.ts b/reading-platform-frontend/src/api/generated/model/coursePackageResponse.ts new file mode 100644 index 0000000..8c8e035 --- /dev/null +++ b/reading-platform-frontend/src/api/generated/model/coursePackageResponse.ts @@ -0,0 +1,56 @@ +/** + * Generated by orval v8.5.3 🍺 + * Do not edit manually. + * Reading Platform API + * Reading Platform Backend Service API Documentation + * OpenAPI spec version: 1.0.0 + */ +import type { CoursePackageCourseItem } from './coursePackageCourseItem'; + +/** + * 课程套餐响应 + */ +export interface CoursePackageResponse { + /** ID */ + id?: number; + /** 名称 */ + name?: string; + /** 描述 */ + description?: string; + /** 价格(分) */ + price?: number; + /** 折后价格(分) */ + discountPrice?: number; + /** 折扣类型 */ + discountType?: string; + /** 年级水平(数组) */ + gradeLevels?: string[]; + /** 课程数量 */ + courseCount?: number; + /** 使用学校数 */ + tenantCount?: number; + /** 状态 */ + status?: string; + /** 提交时间 */ + submittedAt?: string; + /** 提交人 ID */ + submittedBy?: number; + /** 审核时间 */ + reviewedAt?: string; + /** 审核人 ID */ + reviewedBy?: number; + /** 审核意见 */ + reviewComment?: string; + /** 发布时间 */ + publishedAt?: string; + /** 创建时间 */ + createdAt?: string; + /** 更新时间 */ + updatedAt?: string; + /** 包含的课程 */ + courses?: CoursePackageCourseItem[]; + /** 开始日期(租户套餐) */ + startDate?: string; + /** 结束日期(租户套餐) */ + endDate?: string; +} diff --git a/reading-platform-frontend/src/api/generated/model/pageResultCoursePackageResponse.ts b/reading-platform-frontend/src/api/generated/model/pageResultCoursePackageResponse.ts new file mode 100644 index 0000000..801d8e9 --- /dev/null +++ b/reading-platform-frontend/src/api/generated/model/pageResultCoursePackageResponse.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v8.5.3 🍺 + * Do not edit manually. + * Reading Platform API + * Reading Platform Backend Service API Documentation + * OpenAPI spec version: 1.0.0 + */ +import type { CoursePackageResponse } from './coursePackageResponse'; + +export interface PageResultCoursePackageResponse { + list?: CoursePackageResponse[]; + total?: number; + pageNum?: number; + pageSize?: number; + pages?: number; +} diff --git a/reading-platform-frontend/src/api/generated/model/pageResultResourceItem.ts b/reading-platform-frontend/src/api/generated/model/pageResultResourceItem.ts new file mode 100644 index 0000000..87d039d --- /dev/null +++ b/reading-platform-frontend/src/api/generated/model/pageResultResourceItem.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v8.5.3 🍺 + * Do not edit manually. + * Reading Platform API + * Reading Platform Backend Service API Documentation + * OpenAPI spec version: 1.0.0 + */ +import type { ResourceItem } from './resourceItem'; + +export interface PageResultResourceItem { + list?: ResourceItem[]; + total?: number; + pageNum?: number; + pageSize?: number; + pages?: number; +} diff --git a/reading-platform-frontend/src/api/generated/model/pageResultResourceLibrary.ts b/reading-platform-frontend/src/api/generated/model/pageResultResourceLibrary.ts new file mode 100644 index 0000000..cb3922f --- /dev/null +++ b/reading-platform-frontend/src/api/generated/model/pageResultResourceLibrary.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v8.5.3 🍺 + * Do not edit manually. + * Reading Platform API + * Reading Platform Backend Service API Documentation + * OpenAPI spec version: 1.0.0 + */ +import type { ResourceLibrary } from './resourceLibrary'; + +export interface PageResultResourceLibrary { + list?: ResourceLibrary[]; + total?: number; + pageNum?: number; + pageSize?: number; + pages?: number; +} diff --git a/reading-platform-frontend/src/api/generated/model/resultCoursePackageResponse.ts b/reading-platform-frontend/src/api/generated/model/resultCoursePackageResponse.ts new file mode 100644 index 0000000..0a6b524 --- /dev/null +++ b/reading-platform-frontend/src/api/generated/model/resultCoursePackageResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.5.3 🍺 + * Do not edit manually. + * Reading Platform API + * Reading Platform Backend Service API Documentation + * OpenAPI spec version: 1.0.0 + */ +import type { CoursePackageResponse } from './coursePackageResponse'; + +export interface ResultCoursePackageResponse { + code?: number; + message?: string; + data?: CoursePackageResponse; +} diff --git a/reading-platform-frontend/src/api/generated/model/resultListCoursePackageResponse.ts b/reading-platform-frontend/src/api/generated/model/resultListCoursePackageResponse.ts new file mode 100644 index 0000000..ebfdfb4 --- /dev/null +++ b/reading-platform-frontend/src/api/generated/model/resultListCoursePackageResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.5.3 🍺 + * Do not edit manually. + * Reading Platform API + * Reading Platform Backend Service API Documentation + * OpenAPI spec version: 1.0.0 + */ +import type { CoursePackageResponse } from './coursePackageResponse'; + +export interface ResultListCoursePackageResponse { + code?: number; + message?: string; + data?: CoursePackageResponse[]; +} diff --git a/reading-platform-frontend/src/api/generated/model/resultPageResultCoursePackageResponse.ts b/reading-platform-frontend/src/api/generated/model/resultPageResultCoursePackageResponse.ts new file mode 100644 index 0000000..40d7b28 --- /dev/null +++ b/reading-platform-frontend/src/api/generated/model/resultPageResultCoursePackageResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.5.3 🍺 + * Do not edit manually. + * Reading Platform API + * Reading Platform Backend Service API Documentation + * OpenAPI spec version: 1.0.0 + */ +import type { PageResultCoursePackageResponse } from './pageResultCoursePackageResponse'; + +export interface ResultPageResultCoursePackageResponse { + code?: number; + message?: string; + data?: PageResultCoursePackageResponse; +} diff --git a/reading-platform-frontend/src/api/generated/model/resultPageResultResourceItem.ts b/reading-platform-frontend/src/api/generated/model/resultPageResultResourceItem.ts new file mode 100644 index 0000000..416374f --- /dev/null +++ b/reading-platform-frontend/src/api/generated/model/resultPageResultResourceItem.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.5.3 🍺 + * Do not edit manually. + * Reading Platform API + * Reading Platform Backend Service API Documentation + * OpenAPI spec version: 1.0.0 + */ +import type { PageResultResourceItem } from './pageResultResourceItem'; + +export interface ResultPageResultResourceItem { + code?: number; + message?: string; + data?: PageResultResourceItem; +} diff --git a/reading-platform-frontend/src/api/generated/model/resultPageResultResourceLibrary.ts b/reading-platform-frontend/src/api/generated/model/resultPageResultResourceLibrary.ts new file mode 100644 index 0000000..5eb85f1 --- /dev/null +++ b/reading-platform-frontend/src/api/generated/model/resultPageResultResourceLibrary.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.5.3 🍺 + * Do not edit manually. + * Reading Platform API + * Reading Platform Backend Service API Documentation + * OpenAPI spec version: 1.0.0 + */ +import type { PageResultResourceLibrary } from './pageResultResourceLibrary'; + +export interface ResultPageResultResourceLibrary { + code?: number; + message?: string; + data?: PageResultResourceLibrary; +} diff --git a/reading-platform-frontend/src/api/imm.api.ts b/reading-platform-frontend/src/api/imm.api.ts new file mode 100644 index 0000000..2e2b7a5 --- /dev/null +++ b/reading-platform-frontend/src/api/imm.api.ts @@ -0,0 +1,36 @@ +/** + * 阿里云 IMM WebOffice API + * 用于文档在线预览和编辑 + */ + +import { http } from '@/api'; + +/** + * 生成只读 WebOffice Token + * @param url OSS 文件路径(如:oss://bucket-name/path/to/file.pptx 或完整 HTTPS URL) + * @param name 文件名 + * @returns Token 信息 + */ +export function generateWebofficeTokenReadOnly(params: { url: string; name: string }) { + return http.get('/v1/imm/token/readonly', { params }); +} + +/** + * 生成编辑 WebOffice Token + * @param url OSS 文件路径(如:oss://bucket-name/path/to/file.pptx 或完整 HTTPS URL) + * @param name 文件名 + * @returns Token 信息 + */ +export function generateWebofficeToken(params: { url: string; name: string }) { + return http.get('/v1/imm/token', { params }); +} + +/** + * 刷新 WebOffice Token + * @param accessToken 当前访问凭证 + * @param refreshToken 刷新凭证 + * @returns 刷新后的 Token 信息 + */ +export function refreshWebofficeToken(data: { accessToken: string; refreshToken: string }) { + return http.post('/v1/imm/token/refresh', data); +} diff --git a/reading-platform-frontend/src/views/office/WebOffice.vue b/reading-platform-frontend/src/views/office/WebOffice.vue index 353e4b8..c976467 100644 --- a/reading-platform-frontend/src/views/office/WebOffice.vue +++ b/reading-platform-frontend/src/views/office/WebOffice.vue @@ -46,7 +46,7 @@ import { generateWebofficeToken, generateWebofficeTokenReadOnly, refreshWebofficeToken, -} from '@/views/cms/teaching-material/CmsTeachingMaterial.api'; +} from '@/api/imm.api'; import { useRoute, useRouter } from 'vue-router'; // import { usePermission } from '@/hooks/web/usePermission'; // import { insertPPTImage, insertWordImage } from './webOffice'; diff --git a/reading-platform-java/pom.xml b/reading-platform-java/pom.xml index 1442d98..07c7728 100644 --- a/reading-platform-java/pom.xml +++ b/reading-platform-java/pom.xml @@ -149,6 +149,12 @@ aliyun-sdk-oss 3.17.1 + + + com.aliyun + imm20200930 + 1.28.3 + com.alibaba.fastjson2 fastjson2-extension-spring6 diff --git a/reading-platform-java/src/main/java/com/reading/platform/common/config/ImmConfig.java b/reading-platform-java/src/main/java/com/reading/platform/common/config/ImmConfig.java new file mode 100644 index 0000000..525cf80 --- /dev/null +++ b/reading-platform-java/src/main/java/com/reading/platform/common/config/ImmConfig.java @@ -0,0 +1,42 @@ +package com.reading.platform.common.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * 阿里云 IMM(智能媒体服务)配置类 + * + * @author reading-platform + * @since 2026-03-16 + */ +@Data +@Configuration +@ConfigurationProperties(prefix = "aliyun.imm") +public class ImmConfig { + + /** + * IMM 服务终端节点(如:imm-cn-shanghai.aliyuncs.com) + */ + private String endpoint; + + /** + * 服务区域(如:cn-shanghai) + */ + private String region; + + /** + * 访问密钥 ID + */ + private String accessKeyId; + + /** + * 访问密钥秘密 + */ + private String accessKeySecret; + + /** + * IMM 项目名称 + */ + private String projectName; +} diff --git a/reading-platform-java/src/main/java/com/reading/platform/common/util/ImmUtil.java b/reading-platform-java/src/main/java/com/reading/platform/common/util/ImmUtil.java new file mode 100644 index 0000000..ef9d022 --- /dev/null +++ b/reading-platform-java/src/main/java/com/reading/platform/common/util/ImmUtil.java @@ -0,0 +1,241 @@ +package com.reading.platform.common.util; + +import com.aliyun.imm20200930.Client; +import com.aliyun.imm20200930.models.*; +import com.reading.platform.common.config.ImmConfig; +import com.reading.platform.dto.response.ImmTokenVo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +/** + * 阿里云 IMM(智能媒体服务)工具类 + *

+ * 封装 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 generateReadOnlyToken( + @RequestParam("url") String url, + @RequestParam("name") String name) { + + // 将 OSS URL 转换为 IMM 所需的 SourceURI 格式 + String sourceURI = immUtil.convertOssUrlToSourceURI(url); + + ImmTokenVo tokenVo = immUtil.generateWebofficeTokenReadOnly(sourceURI, name); + return Result.success(tokenVo); + } + + /** + * 生成编辑 WebOffice Token + *

+ * 用于前端编辑文档(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 generateEditToken( + @RequestParam("url") String url, + @RequestParam("name") String name) { + + // 将 OSS URL 转换为 IMM 所需的 SourceURI 格式 + String sourceURI = immUtil.convertOssUrlToSourceURI(url); + + ImmTokenVo tokenVo = immUtil.generateWebofficeToken(sourceURI, name); + return Result.success(tokenVo); + } + + /** + * 刷新 WebOffice Token + *

+ * 当 Token 即将过期时,使用 refreshToken 刷新获取新的 Token + *

+ * + * @param request 刷新请求,包含 accessToken 和 refreshToken + * @return 刷新后的 WebOffice Token 信息 + */ + @PostMapping("/token/refresh") + @Operation(summary = "刷新 WebOffice Token", description = "当 Token 即将过期时刷新") + public Result refreshToken( + @RequestBody RefreshTokenRequest request) { + + ImmTokenVo tokenVo = immUtil.refreshWebofficeToken( + request.getAccessToken(), + request.getRefreshToken() + ); + return Result.success(tokenVo); + } + + /** + * 刷新 Token 请求 DTO + */ + public static class RefreshTokenRequest { + /** + * 当前访问凭证 + */ + private String accessToken; + + /** + * 刷新凭证 + */ + private String refreshToken; + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + } +} diff --git a/reading-platform-java/src/main/java/com/reading/platform/dto/response/ImmTokenVo.java b/reading-platform-java/src/main/java/com/reading/platform/dto/response/ImmTokenVo.java new file mode 100644 index 0000000..ab1fa88 --- /dev/null +++ b/reading-platform-java/src/main/java/com/reading/platform/dto/response/ImmTokenVo.java @@ -0,0 +1,42 @@ +package com.reading.platform.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 阿里云 IMM WebOffice Token 响应 VO + *

+ * 用于前端 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" +}