diff --git a/backend/package-lock.json b/backend/package-lock.json index 07fb6ed..8608f3d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,6 +17,7 @@ "ali-oss": "^6.23.0", "axios": "^1.13.2", "bcryptjs": "^3.0.3", + "canvas": "^3.2.1", "cors": "^2.8.5", "dotenv": "^17.2.3", "epub": "^1.3.0", @@ -3065,13 +3066,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -3674,6 +3675,20 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.1.tgz", + "integrity": "sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4277,9 +4292,9 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -7666,6 +7681,12 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-ensure": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz", diff --git a/backend/scripts/TEST_GUIDE.md b/backend/scripts/TEST_GUIDE.md deleted file mode 100644 index d8a1e43..0000000 --- a/backend/scripts/TEST_GUIDE.md +++ /dev/null @@ -1,64 +0,0 @@ -# 课程生成功能测试指南 - -## 部署状态 - -✅ **代码已部署到服务器** -- 服务器地址: https://api.muststudy.xin -- PM2 服务: 运行中 -- 修复内容: 大纲生成完成后自动开始生成课程内容 - -## 测试方法 - -### 方法1: 使用测试脚本(推荐) - -1. **获取Token** - - 使用手机号登录获取Token - - 或使用现有的测试账号 - -2. **运行测试脚本** - ```bash - export TEST_TOKEN='your_token_here' - cd backend - bash scripts/test-course-generation-direct.sh - ``` - -### 方法2: 直接调用API - -1. **创建课程** - ```bash - curl -X POST https://api.muststudy.xin/api/ai/content/upload \ - -H 'Authorization: Bearer YOUR_TOKEN' \ - -H 'Content-Type: application/json' \ - -d '{ - "content": "测试内容...", - "style": "essence" - }' - ``` - -2. **查询状态** - ```bash - curl -X GET https://api.muststudy.xin/api/my-courses \ - -H 'Authorization: Bearer YOUR_TOKEN' - ``` - -3. **查询日志** - ```bash - curl -X GET "https://api.muststudy.xin/api/ai/prompts/logs?taskId=TASK_ID" \ - -H 'Authorization: Bearer YOUR_TOKEN' - ``` - -## 测试检查点 - -1. ✅ 创建课程后立即返回 courseId 和 taskId -2. ✅ 进度从 0% 开始,逐步增长 -3. ✅ 大纲生成完成后(30%)自动继续生成内容(40%+) -4. ✅ 最终完成(100%) -5. ✅ 所有步骤都记录了日志(可通过 taskId 查询) - -## 问题排查 - -如果进度卡在30%: -- 检查服务器日志: `pm2 logs wildgrowth-api` -- 检查任务状态: `GET /api/ai/content/tasks/:taskId` -- 检查错误信息: 查看 `error_message` 字段 - diff --git a/backend/src/controllers/uploadController.ts b/backend/src/controllers/uploadController.ts index 1f83633..d64c7cd 100644 --- a/backend/src/controllers/uploadController.ts +++ b/backend/src/controllers/uploadController.ts @@ -6,6 +6,7 @@ import { CustomError } from '../middleware/errorHandler'; import { AuthRequest } from '../middleware/auth'; import fs from 'fs'; import { documentParserService } from '../services/documentParserService'; +import ossService from '../services/ossService'; import { logger } from '../utils/logger'; // 确保 images 目录存在 @@ -91,6 +92,36 @@ const documentUpload = multer({ }, }); +// OSS 上传:内存存储,上传后直接传到 OSS +const ossFileFilter = (req: Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => { + // 允许常见文件类型(可按需扩展) + const allowedMimes = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/webp', + 'image/gif', + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/epub+zip', + 'text/plain', + 'application/octet-stream', + ]; + if (allowedMimes.includes(file.mimetype) || file.mimetype.startsWith('image/')) { + cb(null, true); + } else { + cb(new CustomError('不支持的文件类型', 400)); + } +}; + +const ossUpload = multer({ + storage: multer.memoryStorage(), + fileFilter: ossFileFilter, + limits: { + fileSize: 50 * 1024 * 1024, // 50MB + }, +}); + /** * 上传图片 * POST /api/upload/image @@ -184,3 +215,95 @@ export const uploadDocument = [ } }, ]; + +/** + * 上传文件到 OSS + * POST /api/upload/oss + * body: FormData with 'file' field + * 返回 OSS 对象路径和访问 URL + */ +export const uploadToOss = [ + ossUpload.single('file'), + async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const userId = req.userId; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + if (!req.file) { + throw new CustomError('请选择要上传的文件', 400); + } + + const ext = path.extname(req.file.originalname) || ''; + const objectName = `uploads/${Date.now()}-${uuidv4()}${ext}`; + + await ossService.putBuffer(objectName, req.file.buffer, { + contentType: req.file.mimetype, + }); + + const url = ossService.getObjectUrl(objectName); + + res.json({ + success: true, + data: { + objectName, + url, + filename: req.file.originalname, + size: req.file.size, + contentType: req.file.mimetype, + }, + }); + } catch (error) { + next(error); + } + }, +]; + +/** + * 从 OSS 下载文件 + * GET /api/upload/oss/download?objectName=xxx + * 通过 objectName 参数指定 OSS 对象路径,返回文件流 + */ +export const downloadFromOss = async ( + req: AuthRequest, + res: Response, + next: NextFunction +): Promise => { + try { + const userId = req.userId; + + if (!userId) { + throw new CustomError('未授权', 401); + } + + const objectName = req.query.objectName as string; + + if (!objectName || typeof objectName !== 'string') { + throw new CustomError('缺少 objectName 参数', 400); + } + + // 防止路径遍历 + if (objectName.includes('..') || objectName.startsWith('/')) { + throw new CustomError('无效的 objectName', 400); + } + + const { stream, contentLength, contentType } = + await ossService.getObjectStream(objectName); + + const filename = objectName.split('/').pop() || 'download'; + + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`); + if (contentType) { + res.setHeader('Content-Type', contentType); + } + if (contentLength) { + res.setHeader('Content-Length', contentLength); + } + + stream.pipe(res); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/routes/uploadRoutes.ts b/backend/src/routes/uploadRoutes.ts index 033cb7a..bcf4c4f 100644 --- a/backend/src/routes/uploadRoutes.ts +++ b/backend/src/routes/uploadRoutes.ts @@ -1,5 +1,10 @@ import { Router } from 'express'; -import { uploadImage, uploadDocument } from '../controllers/uploadController'; +import { + uploadImage, + uploadDocument, + uploadToOss, + downloadFromOss, +} from '../controllers/uploadController'; import { authenticate } from '../middleware/auth'; const router = Router(); @@ -20,6 +25,23 @@ router.post('/image', authenticate, uploadImage); */ router.post('/document', authenticate, uploadDocument); +/** + * @route POST /api/upload/oss + * @desc 上传文件到 OSS + * @access Private + * @body FormData with 'file' field + * @returns { success: true, data: { objectName, url, filename, size, contentType } } + */ +router.post('/oss', authenticate, uploadToOss); + +/** + * @route GET /api/upload/oss/download + * @desc 从 OSS 下载文件 + * @access Private + * @query objectName - OSS 对象路径(如 uploads/xxx.png) + */ +router.get('/oss/download', authenticate, downloadFromOss); + export default router; diff --git a/backend/src/services/ossService.ts b/backend/src/services/ossService.ts index caa9cdd..800e7b3 100644 --- a/backend/src/services/ossService.ts +++ b/backend/src/services/ossService.ts @@ -119,6 +119,30 @@ export class OssService { } } + /** + * 从 OSS 获取文件流(用于下载) + * @param objectName - OSS对象名称 + * @returns Promise<{ stream: Readable; contentLength?: number; contentType?: string }> + */ + async getObjectStream(objectName: string): Promise<{ + stream: NodeJS.ReadableStream; + contentLength?: number; + contentType?: string; + }> { + try { + const result = await this.client.getStream(objectName); + const headers = (result as { res?: { headers?: Record } }).res?.headers; + return { + stream: result.stream, + contentLength: headers?.['content-length'] ? parseInt(headers['content-length'], 10) : undefined, + contentType: headers?.['content-type'], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`从OSS获取文件失败: ${errorMessage}`); + } + } + /** * 检查对象是否存在 * @param objectName - OSS对象名称 diff --git a/ios/WildGrowth/Config/Develop.xcconfig b/ios/WildGrowth/Config/Develop.xcconfig index 4f2f6ab..401360c 100644 --- a/ios/WildGrowth/Config/Develop.xcconfig +++ b/ios/WildGrowth/Config/Develop.xcconfig @@ -4,7 +4,7 @@ #include "Shared.xcconfig" // API 域名(注入 Info.plist,运行时通过 Bundle.main 读取) -API_DOMAIN = https://api.muststudy.xin +API_DOMAIN = https:/$()/api.muststudy.xin INFOPLIST_KEY_API_DOMAIN = $(API_DOMAIN) // Swift 编译条件:代码中可用 #if API_ENV_DEVELOP diff --git a/ios/WildGrowth/Config/Local.xcconfig b/ios/WildGrowth/Config/Local.xcconfig index 219be54..3b4bb6d 100644 --- a/ios/WildGrowth/Config/Local.xcconfig +++ b/ios/WildGrowth/Config/Local.xcconfig @@ -5,7 +5,7 @@ #include "Shared.xcconfig" // API 域名(注入 Info.plist,运行时通过 Bundle.main 读取) -API_DOMAIN = http://localhost:3000 +API_DOMAIN = http:/$()/localhost:3000 INFOPLIST_KEY_API_DOMAIN = $(API_DOMAIN) // Swift 编译条件:代码中可用 #if API_ENV_LOCAL diff --git a/ios/WildGrowth/Config/Online.xcconfig b/ios/WildGrowth/Config/Online.xcconfig index 30edb1e..c3abf50 100644 --- a/ios/WildGrowth/Config/Online.xcconfig +++ b/ios/WildGrowth/Config/Online.xcconfig @@ -4,7 +4,7 @@ #include "Shared.xcconfig" // API 域名(注入 Info.plist,运行时通过 Bundle.main 读取) -API_DOMAIN = https://wildgrowth.upolar.com +API_DOMAIN = https:/$()/wildgrowth.upolar.com INFOPLIST_KEY_API_DOMAIN = $(API_DOMAIN) // Swift 编译条件:代码中可用 #if API_ENV_ONLINE diff --git a/ios/WildGrowth/Config/Shared.xcconfig b/ios/WildGrowth/Config/Shared.xcconfig index 22f701a..4027505 100644 --- a/ios/WildGrowth/Config/Shared.xcconfig +++ b/ios/WildGrowth/Config/Shared.xcconfig @@ -2,5 +2,8 @@ // 包含 Swift 版本、编译选项等通用设置 SWIFT_VERSION = 5.0 + +// 启用 Info.plist 中的 $(变量) 展开,否则 Info-API.plist 的 $(API_DOMAIN) 无法注入 +INFOPLIST_EXPAND_BUILD_SETTINGS = YES SWIFT_EMIT_LOC_STRINGS = YES ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES diff --git a/ios/WildGrowth/电子成长.xcodeproj/project.pbxproj b/ios/WildGrowth/电子成长.xcodeproj/project.pbxproj index e107f42..0662f4e 100644 --- a/ios/WildGrowth/电子成长.xcodeproj/project.pbxproj +++ b/ios/WildGrowth/电子成长.xcodeproj/project.pbxproj @@ -376,6 +376,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Config/Info-API.plist"; INFOPLIST_KEY_CFBundleDisplayName = "电子成长"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books"; @@ -507,6 +508,7 @@ baseConfigurationReference = A3CF00022EE29B130004B865 /* Online.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + API_DOMAIN = "https://wildgrowth.upolar.com"; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -569,6 +571,7 @@ baseConfigurationReference = A3CF00022EE29B130004B865 /* Online.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + API_DOMAIN = "https://wildgrowth.upolar.com"; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -623,6 +626,7 @@ baseConfigurationReference = A3CF00032EE29B130004B865 /* Develop.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + API_DOMAIN = "https://api.muststudy.xin"; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -685,6 +689,7 @@ baseConfigurationReference = A3CF00032EE29B130004B865 /* Develop.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + API_DOMAIN = "https://api.muststudy.xin"; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -739,6 +744,7 @@ baseConfigurationReference = A3CF00042EE29B130004B865 /* Local.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + API_DOMAIN = "http://localhost:3000"; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -801,6 +807,7 @@ baseConfigurationReference = A3CF00042EE29B130004B865 /* Local.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + API_DOMAIN = "http://localhost:3000"; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -954,7 +961,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = A3CF00042EE29B130004B865 /* Local.xcconfig */; buildSettings = { - API_DOMAIN = ""; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; @@ -1005,7 +1011,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = A3CF00042EE29B130004B865 /* Local.xcconfig */; buildSettings = { - API_DOMAIN = ""; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;