From a7e22ff35b1a1dee4dbc6fd9346583ffd2a87c1c Mon Sep 17 00:00:00 2001 From: zhonghua Date: Mon, 16 Mar 2026 18:46:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=9B=B4=E4=BC=A0=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- reading-platform-frontend/src/api/file.ts | 198 +++++++++----- reading-platform-frontend/src/components.d.ts | 3 + .../src/views/office/WebOffice.vue | 242 ++++++++++++++++++ .../src/views/office/temObjs.ts | 69 +++++ .../src/views/office/webOffice.ts | 161 ++++++++++++ reading-platform-frontend/typed-router.d.ts | 13 + .../platform/common/config/OssConfig.java | 11 + .../common/config/OssCorsInitRunner.java | 30 +++ .../exception/GlobalExceptionHandler.java | 18 +- .../platform/common/util/OssUtils.java | 50 +++- .../src/main/resources/application-dev.yml | 3 + .../src/main/resources/application-prod.yml | 4 +- 12 files changed, 739 insertions(+), 63 deletions(-) create mode 100644 reading-platform-frontend/src/views/office/WebOffice.vue create mode 100644 reading-platform-frontend/src/views/office/temObjs.ts create mode 100644 reading-platform-frontend/src/views/office/webOffice.ts create mode 100644 reading-platform-java/src/main/java/com/reading/platform/common/config/OssCorsInitRunner.java diff --git a/reading-platform-frontend/src/api/file.ts b/reading-platform-frontend/src/api/file.ts index 74f5147..4856642 100644 --- a/reading-platform-frontend/src/api/file.ts +++ b/reading-platform-frontend/src/api/file.ts @@ -1,7 +1,10 @@ -import axios from 'axios'; -import { buildOssDirPath } from '@/utils/env'; +import axios from "axios"; +import { buildOssDirPath } from "@/utils/env"; -const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1'; +const API_BASE = import.meta.env.VITE_API_BASE_URL; + +/** 上传请求 AbortController 映射,用于取消上传 */ +const uploadControllers = new Map(); export interface UploadResult { success: boolean; @@ -17,6 +20,39 @@ export interface DeleteResult { message: string; } +/** + * 获取指定文件的 AbortController(用于取消上传) + */ +export function getUploadController(file: File): AbortController { + const key = `${file.name}_${file.size}_${file.lastModified}`; + let controller = uploadControllers.get(key); + if (!controller) { + controller = new AbortController(); + uploadControllers.set(key, controller); + } + return controller; +} + +/** + * 取消指定文件的上传 + */ +export function abortUpload(file: File): void { + const key = `${file.name}_${file.size}_${file.lastModified}`; + const controller = uploadControllers.get(key); + if (controller) { + controller.abort(); + uploadControllers.delete(key); + } +} + +/** + * 取消所有进行中的上传 + */ +export function abortAllUploads(): void { + uploadControllers.forEach((controller) => controller.abort()); + uploadControllers.clear(); +} + /** * OSS 直传 Token 响应 */ @@ -42,72 +78,117 @@ export const fileApi = { * @param dir 业务目录(如:avatar, course/cover),会自动添加环境前缀 * @returns OSS 直传 Token */ - getOssToken: async ( - fileName: string, - dir?: string, - ): Promise => { + getOssToken: async (fileName: string, dir?: string): Promise => { // 自动添加环境前缀 const fullDir = buildOssDirPath(dir); - const response = await axios.get<{ data: OssToken }>(`${API_BASE}/api/v1/files/oss/token`, { - params: { fileName, dir: fullDir }, - }); + const response = await axios.get<{ data: OssToken }>( + `${API_BASE}/api/v1/files/oss/token`, + { + params: { fileName, dir: fullDir }, + }, + ); return response.data.data; }, /** * 直接上传文件到阿里云 OSS + * 参考 uploadAliOSS 实现:支持取消、进度回调、超时 + * + * @param file 文件 + * @param token OSS Token + * @param options 进度回调函数(兼容旧 API)或配置对象 { onProgress, signal, timeout } */ uploadToOss: async ( file: File, token: OssToken, - onProgress?: (percent: number) => void, + options?: + | ((percent: number) => void) + | { + onProgress?: (percent: number) => void; + signal?: AbortSignal; + timeout?: number; + }, ): Promise<{ url: string }> => { + const opts = + typeof options === "function" + ? { onProgress: options } + : options ?? {}; const formData = new FormData(); // 按照阿里云 OSS PostObject 要求构造表单 - formData.append('OSSAccessKeyId', token.accessid); - formData.append('policy', token.policy); - formData.append('signature', token.signature); - formData.append('key', token.key); - formData.append('x-oss-credential', token.accessid); - formData.append('file', file); + formData.append("success_action_status", "200"); // 成功时返回 200 + formData.append("OSSAccessKeyId", token.accessid); + formData.append("policy", token.policy); + formData.append("signature", token.signature); + formData.append("key", token.key); + formData.append("x-oss-credential", token.accessid); + formData.append("file", file); // file 必须为最后一个表单域 - await axios.post(token.host, formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - onUploadProgress: (progressEvent) => { - if (progressEvent.total && onProgress) { - const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total); - onProgress(percentCompleted); - } - }, - }); + const controller = getUploadController(file); + const signal = opts.signal ?? controller.signal; - // 上传成功后返回文件访问 URL - return { - url: `${token.host}/${token.key}`, - }; + try { + await axios.post(token.host, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + timeout: opts.timeout ?? 1000 * 60 * 5, // 默认 5 分钟 + signal, + onUploadProgress: (progressEvent) => { + if (opts.onProgress) { + const percent = + progressEvent.progress != null + ? progressEvent.progress * 100 + : progressEvent.total + ? (progressEvent.loaded * 100) / progressEvent.total + : 0; + opts.onProgress(Math.round(percent)); + } + }, + }); + + return { + url: `${token.host}/${token.key}`, + }; + } finally { + abortUpload(file); + } }, /** * 上传文件(使用 OSS 直传方式) + * 参考 uploadAliOSS:支持进度回调、取消上传 * * @param file 要上传的文件 * @param type 文件类型(用于指定 OSS 目录前缀) - * @param _courseId 课程 ID(可选,用于关联业务 - 预留参数) + * @param options 可选:onProgress 进度回调、signal 取消信号、courseId 预留 * @returns 上传结果,包含 OSS 文件 URL */ uploadFile: async ( file: File, - type: 'cover' | 'ebook' | 'audio' | 'video' | 'ppt' | 'poster' | 'document' | 'other', - _courseId?: number, + type: + | "cover" + | "ebook" + | "audio" + | "video" + | "ppt" + | "poster" + | "document" + | "other", + options?: { + onProgress?: (percent: number) => void; + signal?: AbortSignal; + courseId?: number; + }, ): Promise => { // 1. 获取 OSS 直传 Token(自动添加环境前缀) - const token = await getOssToken(file.name, type); + const token = await fileApi.getOssToken(file.name, type); - // 2. 上传到 OSS - await uploadToOss(file, token); + // 2. 上传到 OSS(支持进度、取消) + await fileApi.uploadToOss(file, token, { + onProgress: options?.onProgress, + signal: options?.signal, + }); // 3. 返回兼容格式的结果 return { @@ -124,9 +205,12 @@ export const fileApi = { * 删除文件 */ deleteFile: async (filePath: string): Promise => { - const response = await axios.delete(`${API_BASE}/api/v1/files/delete`, { - data: { filePath }, - }); + const response = await axios.delete( + `${API_BASE}/api/v1/files/delete`, + { + data: { filePath }, + }, + ); return response.data; }, @@ -144,28 +228,28 @@ export const fileApi = { * 文件类型常量 */ export const FILE_TYPES = { - COVER: 'cover', - EBOOK: 'ebook', - AUDIO: 'audio', - VIDEO: 'video', - PPT: 'ppt', - POSTER: 'poster', - DOCUMENT: 'document', - OTHER: 'other', + COVER: "cover", + EBOOK: "ebook", + AUDIO: "audio", + VIDEO: "video", + PPT: "ppt", + POSTER: "poster", + DOCUMENT: "document", + OTHER: "other", } as const; /** * 文件大小限制(字节) */ export const FILE_SIZE_LIMITS = { - COVER: 10 * 1024 * 1024, // 10MB - EBOOK: 300 * 1024 * 1024, // 300MB - AUDIO: 300 * 1024 * 1024, // 300MB - VIDEO: 300 * 1024 * 1024, // 300MB - PPT: 300 * 1024 * 1024, // 300MB - POSTER: 10 * 1024 * 1024, // 10MB - DOCUMENT: 300 * 1024 * 1024, // 300MB - OTHER: 300 * 1024 * 1024, // 300MB + COVER: 10 * 1024 * 1024, // 10MB + EBOOK: 300 * 1024 * 1024, // 300MB + AUDIO: 300 * 1024 * 1024, // 300MB + VIDEO: 300 * 1024 * 1024, // 300MB + PPT: 300 * 1024 * 1024, // 300MB + POSTER: 10 * 1024 * 1024, // 10MB + DOCUMENT: 300 * 1024 * 1024, // 300MB + OTHER: 300 * 1024 * 1024, // 300MB } as const; /** diff --git a/reading-platform-frontend/src/components.d.ts b/reading-platform-frontend/src/components.d.ts index 89333d6..f58df7e 100644 --- a/reading-platform-frontend/src/components.d.ts +++ b/reading-platform-frontend/src/components.d.ts @@ -20,6 +20,7 @@ declare module 'vue' { AEmpty: typeof import('ant-design-vue/es')['Empty'] AForm: typeof import('ant-design-vue/es')['Form'] AFormItem: typeof import('ant-design-vue/es')['FormItem'] + AImage: typeof import('ant-design-vue/es')['Image'] AInput: typeof import('ant-design-vue/es')['Input'] AInputNumber: typeof import('ant-design-vue/es')['InputNumber'] AInputPassword: typeof import('ant-design-vue/es')['InputPassword'] @@ -35,6 +36,8 @@ declare module 'vue' { APageHeader: typeof import('ant-design-vue/es')['PageHeader'] APopconfirm: typeof import('ant-design-vue/es')['Popconfirm'] AProgress: typeof import('ant-design-vue/es')['Progress'] + ARadio: typeof import('ant-design-vue/es')['Radio'] + ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup'] ARow: typeof import('ant-design-vue/es')['Row'] ASelect: typeof import('ant-design-vue/es')['Select'] ASelectOptGroup: typeof import('ant-design-vue/es')['SelectOptGroup'] diff --git a/reading-platform-frontend/src/views/office/WebOffice.vue b/reading-platform-frontend/src/views/office/WebOffice.vue new file mode 100644 index 0000000..353e4b8 --- /dev/null +++ b/reading-platform-frontend/src/views/office/WebOffice.vue @@ -0,0 +1,242 @@ + + + + + diff --git a/reading-platform-frontend/src/views/office/temObjs.ts b/reading-platform-frontend/src/views/office/temObjs.ts new file mode 100644 index 0000000..768a7fe --- /dev/null +++ b/reading-platform-frontend/src/views/office/temObjs.ts @@ -0,0 +1,69 @@ + +import { ref, watch, } from 'vue'; +/** + * 临时localStorage缓存,使用后立即删除,适合跨页面使用 + */ +const TemKel = '_t' +/** + * 设置缓存超时 + */ +const base_time = 1000 * 60 * 60 * 8; +type Tem = { + [key: string]: { + time: number, + val: TemObj + } +} +export function initilTemItem() { + const tem: Tem = JSON.parse(localStorage.getItem(TemKel) || "{}"); + const _time = Date.now(); + const _tem: Tem = {}; + for (let key in tem) { + const val = tem[key]; + if (_time - val.time < base_time) { + _tem[key] = val + } + } + + localStorage.setItem(TemKel, JSON.stringify(_tem)); +} +export type TemObj = { + id: string; + url: string; + name: string; + type: string; + isEdit: boolean; +} +export function setTemItem(val: TemObj, time: Number = Date.now() + base_time): string { + const key = `_t${Date.now()}${Math.floor(Math.random() * 100000)}`; + let Tem: Tem = {}; + try { + Tem = JSON.parse(localStorage.getItem(TemKel) || "{}"); + } catch (error) { } + Tem[key] = { + time: Date.now(), + val: val + } + localStorage.setItem(TemKel, JSON.stringify(Tem)); + + return key +} +export function getTemItem(key: string): TemObj | null { + let Tem: Tem = {}; + try { + Tem = JSON.parse(localStorage.getItem(TemKel) || "{}"); + return Tem[key].val + } catch (error) { } + return null; +} +/** + * + * @param val 默认值 + */ +export function getCacheVal(key: string, defaultVal: any) { + const _val = ref(Number(localStorage.getItem(key) || defaultVal)); + watch(() => _val.value, () => { + localStorage.setItem(key, `${_val.value}`) + }) + return _val +} \ No newline at end of file diff --git a/reading-platform-frontend/src/views/office/webOffice.ts b/reading-platform-frontend/src/views/office/webOffice.ts new file mode 100644 index 0000000..a8060e9 --- /dev/null +++ b/reading-platform-frontend/src/views/office/webOffice.ts @@ -0,0 +1,161 @@ +export async function insertWordImage(instance, imageUrl) { + // 获取当前激活的应用 + const app = instance.Application; + // 检查是否为支持插入图片的应用类型 + if (app && app.ActiveDocument) { + try { + const _imageUrl = false ? `${imageUrl}?x-oss-process=image/resize,w_1080` : imageUrl; + const base64String: any = await urlToBase64(_imageUrl); + const img = await getImgSize(base64String, 'doc'); + const obj = { + FileName: base64String, + // FileName: _imageUrl, + LinkToFile: true, + SaveWithDocument: true, + ...img + } + console.log('obj', obj); + await app.ActiveDocument.Shapes.AddPicture(obj); + + console.log('图片插入成功'); + } catch (error) { + console.error('ActiveDocument图片插入失败:', error); + } + } else { + console.warn('当前没有活动的文档可以插入图片'); + } +} +export async function insertExcelImage(instance, imageUrl) { + const app = instance.Application; + + if (app && app.ActiveWorkbook) { + try { + const activeSheet = await app.ActiveWorkbook.ActiveSheet; + const shapes = await activeSheet.Shapes; + + const base64String: any = await urlToBase64(imageUrl); + await shapes.AddPicture({ + // FileName: imageUrl, + FileName: base64String, + LinkToFile: -1, + SaveWithDocument: 0, + Left: 0.2, // 图片左边缘距离幻灯片左边缘 10% + Top: 0.2, // 图片上边缘距离幻灯片上边缘 10% + Width: 0.6, // 图片宽度为幻灯片宽度的 50% + Height: 0.6, // 图片高度为幻灯片高度的 30% + Scale: true, // 按幻灯片比例计算宽高和坐标 + }); + + console.log('图片插入成功'); + } catch (error) { + console.error('insertExcelImage图片插入失败:', error); + } + } else { + console.warn('当前没有活动的工作表可以插入图片'); + } +} + +export async function insertPPTImage(instance, imageUrl) { + // 确保WebOffice实例已经准备好 + await instance.ready(); + const app = instance.Application; + + if (app && app.ActivePresentation) { + try { + const slide = await app.ActivePresentation.SlideShowWindow.View.Slide; + const shapes = await slide.Shapes; + const base64String: any = await urlToBase64(imageUrl); + const img = await getImgSize(base64String, 'ppt'); + + const _res = await shapes.AddPicture({ + // FileName: imageUrl, + FileName: base64String, + + LinkToFile: -1, // 链接到文件 + SaveWithDocument: 0, // 不随文档保存 + ...img, + }); + + console.log('图片插入成功', _res); + } catch (error) { + console.error('图片插入失败:', error); + } + } else { + console.warn('当前没有活动的幻灯片可以插入图片'); + } +} +async function urlToBase64(url) { + return new Promise((resolve, reject) => { + fetch(url) + .then(response => response.blob()) // 获取Blob + .then(blob => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); // 读取为Data URL,即Base64编码 + }); + }) + .then((base64String: string) => { + resolve(base64String) + }).catch((error) => { + reject(error) + }); + }); +} +function getImgSize(url: string, type: string) { + let image = new Image(); + image.src = url; + return new Promise<{ + Left: number, + Top: number, + Width: number, + Height: number, + Scale?: boolean, + }>((resolve, reject) => { + image.onload = () => { + const size = type === "ppt" ? getPPTSize(image) : getDOCSize(image); + resolve({ + ...size + }) + } + image.onerror = () => { + reject(null); + } + }) +} +function getPPTSize(image: HTMLImageElement) { + + const width = (1280 * 0.8); //600 + const height = (720 * 0.8); //360 + const isH = image.width <= image.height; // false + const _r = isH ? image.width / image.height : image.height / image.width; // 0.5625 + const _rw = isH ? ((_r * 0.8 * height) / width) : 0.8; + const _rh = isH ? 0.8 : ((_r * 0.8 * width) / height); + /** + * 按幻灯片比例计算宽高和坐标 , + */ + return { + Scale: true, + Left: isH ? (1 - _rw) / 2 : 0.1, + Top: isH ? 0.1 : (1 - _rh) / 2, + Width: _rw, + Height: _rh, + } +} +function getDOCSize(image: HTMLImageElement) { + //796,1125 + const rw = 400 / image.width; + const rh = 400 / image.height; + const r = rw < rh ? rw : rh; + return { + Left: 50, // 图片距离左边位置 + Top: 100, // 图片距离顶部位置 + Width: Math.ceil(image.width * r), // 图片宽度 + Height: Math.ceil(image.height * r), // 图片高度 + // Width: image.width * r, // 图片宽度 + // Height: image.height * r, // 图片高度 + } +} +export const imgstr = 'data:image/png;base64,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'; + diff --git a/reading-platform-frontend/typed-router.d.ts b/reading-platform-frontend/typed-router.d.ts index a89221c..39a3c46 100644 --- a/reading-platform-frontend/typed-router.d.ts +++ b/reading-platform-frontend/typed-router.d.ts @@ -191,6 +191,13 @@ declare module 'vue-router/auto-routes' { Record, | never >, + '/office/WebOffice': RouteRecordInfo< + '/office/WebOffice', + '/office/WebOffice', + Record, + Record, + | never + >, '/parent/children/ChildProfileView': RouteRecordInfo< '/parent/children/ChildProfileView', '/parent/children/ChildProfileView', @@ -845,6 +852,12 @@ declare module 'vue-router/auto-routes' { views: | never } + 'src/views/office/WebOffice.vue': { + routes: + | '/office/WebOffice' + views: + | never + } 'src/views/parent/children/ChildProfileView.vue': { routes: | '/parent/children/ChildProfileView' diff --git a/reading-platform-java/src/main/java/com/reading/platform/common/config/OssConfig.java b/reading-platform-java/src/main/java/com/reading/platform/common/config/OssConfig.java index ee0b0be..e32c4e9 100644 --- a/reading-platform-java/src/main/java/com/reading/platform/common/config/OssConfig.java +++ b/reading-platform-java/src/main/java/com/reading/platform/common/config/OssConfig.java @@ -40,6 +40,17 @@ public class OssConfig { */ private Long maxFileSize = 10 * 1024 * 1024L; + /** + * 是否在启动时自动配置 OSS Bucket CORS(解决前端直传跨域) + */ + private Boolean corsEnabled = false; + + /** + * CORS 允许的来源,逗号分隔(如:http://localhost:5173,https://example.com) + * 使用 * 表示允许所有来源 + */ + private String corsAllowedOrigins = "http://localhost:5173,http://localhost:5174"; + /** * 允许的 fileExtension */ diff --git a/reading-platform-java/src/main/java/com/reading/platform/common/config/OssCorsInitRunner.java b/reading-platform-java/src/main/java/com/reading/platform/common/config/OssCorsInitRunner.java new file mode 100644 index 0000000..7e5ad49 --- /dev/null +++ b/reading-platform-java/src/main/java/com/reading/platform/common/config/OssCorsInitRunner.java @@ -0,0 +1,30 @@ +package com.reading.platform.common.config; + +import com.reading.platform.common.util.OssUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * OSS Bucket CORS 初始化 + *

+ * 应用启动时自动配置 OSS 跨域规则,解决前端直传跨域问题。 + * 需在配置中开启:aliyun.oss.cors-enabled=true + *

+ */ +@Slf4j +@Component +@Order(100) // 较晚执行,确保其他组件已就绪 +@RequiredArgsConstructor +public class OssCorsInitRunner implements ApplicationRunner { + + private final OssUtils ossUtils; + + @Override + public void run(ApplicationArguments args) { + ossUtils.configureBucketCors(); + } +} diff --git a/reading-platform-java/src/main/java/com/reading/platform/common/exception/GlobalExceptionHandler.java b/reading-platform-java/src/main/java/com/reading/platform/common/exception/GlobalExceptionHandler.java index 673114c..cd0508e 100644 --- a/reading-platform-java/src/main/java/com/reading/platform/common/exception/GlobalExceptionHandler.java +++ b/reading-platform-java/src/main/java/com/reading/platform/common/exception/GlobalExceptionHandler.java @@ -6,6 +6,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.BadCredentialsException; @@ -28,6 +29,9 @@ import java.util.stream.Collectors; @RestControllerAdvice public class GlobalExceptionHandler { + @Value("${spring.profiles.active:dev}") + private String activeProfile; + @ExceptionHandler(BusinessException.class) public Result handleBusinessException(BusinessException e, HttpServletRequest request) { log.warn("业务异常 at {}: {}", request.getRequestURI(), e.getMessage()); @@ -100,7 +104,19 @@ public class GlobalExceptionHandler { @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public Result handleException(Exception e, HttpServletRequest request) { log.error("系统异常 at {}: ", request.getRequestURI(), e); - return Result.error(ErrorCode.INTERNAL_ERROR.getCode(), "系统内部错误"); + // 开发/测试环境返回详细错误信息,便于排查 + boolean isDev = activeProfile != null && (activeProfile.contains("dev") || activeProfile.contains("test")); + String msg = isDev ? getExceptionMessage(e) : "系统内部错误"; + return Result.error(ErrorCode.INTERNAL_ERROR.getCode(), msg); + } + + private String getExceptionMessage(Throwable e) { + Throwable cause = e; + while (cause.getCause() != null) { + cause = cause.getCause(); + } + String m = cause.getMessage(); + return m != null && !m.isBlank() ? m : cause.getClass().getSimpleName(); } } diff --git a/reading-platform-java/src/main/java/com/reading/platform/common/util/OssUtils.java b/reading-platform-java/src/main/java/com/reading/platform/common/util/OssUtils.java index 0eb2cb5..afc6763 100644 --- a/reading-platform-java/src/main/java/com/reading/platform/common/util/OssUtils.java +++ b/reading-platform-java/src/main/java/com/reading/platform/common/util/OssUtils.java @@ -498,10 +498,8 @@ public class OssUtils { * @return Base64 编码的 Policy */ private String buildPostPolicy(String objectKey, long expiration) { - // ISO 8601 格式的时间 - String expireTime = java.time.Instant.ofEpochMilli(expiration) - .toString() - .replace("Z", "+00:00"); + // ISO 8601 GMT 格式(阿里云要求 YYYY-MM-DDTHH:mm:ss.sssZ,必须以 Z 结尾) + String expireTime = java.time.Instant.ofEpochMilli(expiration).toString(); // 构建 Policy JSON String policyJson = String.format( @@ -541,4 +539,48 @@ public class OssUtils { throw new RuntimeException("计算 OSS 签名失败:" + e.getMessage(), e); } } + + /** + * 配置 OSS Bucket CORS 规则,解决前端直传跨域问题 + *

+ * 需确保 OSS 账号有 oss:PutBucketCors 权限 + *

+ */ + public void configureBucketCors() { + if (!Boolean.TRUE.equals(ossConfig.getCorsEnabled())) { + return; + } + OSS ossClient = getOssClient(); + try { + SetBucketCORSRequest request = new SetBucketCORSRequest(ossConfig.getBucketName()); + SetBucketCORSRequest.CORSRule rule = new SetBucketCORSRequest.CORSRule(); + + // 允许的来源(开发:localhost,生产:需配置实际域名) + String origins = ossConfig.getCorsAllowedOrigins(); + if (origins == null || origins.isBlank()) { + origins = "http://localhost:5173,http://localhost:5174"; + } + rule.setAllowedOrigins(Arrays.asList(origins.trim().split("\\s*,\\s*"))); + + // 允许的方法:POST(直传)、GET、PUT、DELETE、HEAD + rule.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "HEAD")); + + // 允许的请求头(* 表示全部,PostObject 需要) + rule.setAllowedHeaders(Arrays.asList("*")); + + // 暴露给前端的响应头 + rule.setExposeHeaders(Arrays.asList("ETag", "x-oss-request-id")); + + // 预检请求缓存时间(秒) + rule.setMaxAgeSeconds(600); + + request.setCorsRules(List.of(rule)); + ossClient.setBucketCORS(request); + log.info("OSS Bucket CORS 配置成功,允许来源: {}", origins); + } catch (Exception e) { + log.warn("OSS Bucket CORS 配置失败(可在阿里云控制台手动配置): {}", e.getMessage()); + } finally { + ossClient.shutdown(); + } + } } diff --git a/reading-platform-java/src/main/resources/application-dev.yml b/reading-platform-java/src/main/resources/application-dev.yml index dd9ac6e..900b84d 100644 --- a/reading-platform-java/src/main/resources/application-dev.yml +++ b/reading-platform-java/src/main/resources/application-dev.yml @@ -76,6 +76,9 @@ 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:http://localhost:5173,http://localhost:5174} # 日志配置(开发环境 - 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 3e2afac..64267a1 100644 --- a/reading-platform-java/src/main/resources/application-prod.yml +++ b/reading-platform-java/src/main/resources/application-prod.yml @@ -66,7 +66,7 @@ jwt: secret: ${JWT_SECRET} expiration: ${JWT_EXPIRATION:86400000} -# 阿里云 OSS 配置(开发环境) +# 阿里云 OSS 配置(生产环境) aliyun: oss: endpoint: ${OSS_ENDPOINT:oss-cn-shenzhen.aliyuncs.com} @@ -74,6 +74,8 @@ 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:} # 日志配置(生产环境 - 降低日志级别) logging: