diff --git a/.env.cos.example b/.env.cos.example new file mode 100644 index 00000000..c5718cec --- /dev/null +++ b/.env.cos.example @@ -0,0 +1,24 @@ +# ============================================================================ +# 腾讯云 COS 部署配置 +# 复制此文件为 .env.cos 并填写你的配置 +# ============================================================================ + +# 腾讯云 API 密钥 +# 获取地址: https://console.cloud.tencent.com/cam/capi +# 建议创建子账号并只授予 COS 相关权限 +COS_SECRET_ID=你的SecretId +COS_SECRET_KEY=你的SecretKey + +# COS 存储桶配置 +# 存储桶名称(包含 APPID 后缀) +# 例如: valuefrontier-static-1234567890 +COS_BUCKET=你的存储桶名称 + +# 存储桶地域 +# 例如: ap-shanghai, ap-guangzhou, ap-beijing +COS_REGION=ap-shanghai + +# CDN 加速域名(可选) +# 配置后部署完成会显示访问地址 +# 例如: www.valuefrontier.cn +CDN_DOMAIN=www.valuefrontier.cn diff --git a/.env.production b/.env.production index 2d9689ac..3698d729 100644 --- a/.env.production +++ b/.env.production @@ -14,7 +14,8 @@ REACT_APP_ENABLE_MOCK=false REACT_APP_ENABLE_DEBUG=false # 后端 API 地址(生产环境) -REACT_APP_API_URL=http://49.232.185.254:5001 +# 使用独立的 API 域名,因为静态资源托管在 COS + CDN +REACT_APP_API_URL=https://api.valuefrontier.cn # PostHog 分析配置(生产环境) # PostHog API Key(从 PostHog 项目设置中获取) diff --git a/.gitignore b/.gitignore index 6141ca49..b33bb79a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,10 @@ node_modules/ .env.test.local .env.production.local +# 部署配置(包含密钥,不提交) +.env.cos +.env.deploy + # 日志 npm-debug.log* yarn-debug.log* diff --git a/package.json b/package.json index fec6e2f6..8d38cec5 100755 --- a/package.json +++ b/package.json @@ -108,6 +108,9 @@ "test": "craco test --env=jsdom", "eject": "react-scripts eject", "deploy": "bash scripts/deploy-from-local.sh", + "deploy:cos": "node scripts/deploy-to-cos.js", + "deploy:cos:clean": "node scripts/deploy-to-cos.js --clear", + "deploy:cdn": "npm run build && npm run deploy:cos", "deploy:setup": "bash scripts/setup-deployment.sh", "rollback": "bash scripts/rollback-from-local.sh", "lint:check": "eslint . --ext=js,jsx,ts,tsx; exit 0", @@ -126,6 +129,7 @@ "@typescript-eslint/parser": "^8.46.4", "ajv": "^8.17.1", "concurrently": "^8.2.2", + "cos-nodejs-sdk-v5": "^2.15.4", "env-cmd": "^11.0.0", "eslint-config-prettier": "8.3.0", "eslint-plugin-prettier": "3.4.0", diff --git a/scripts/deploy-to-cos.js b/scripts/deploy-to-cos.js new file mode 100644 index 00000000..ee5a0952 --- /dev/null +++ b/scripts/deploy-to-cos.js @@ -0,0 +1,357 @@ +#!/usr/bin/env node +/** + * 腾讯云 COS 部署脚本 + * 将构建产物上传到 COS 存储桶,配合 CDN 实现静态资源加速 + * + * 使用方法: + * node scripts/deploy-to-cos.js + * + * 配置文件: + * .env.cos (从 .env.cos.example 复制并填写) + */ + +const COS = require('cos-nodejs-sdk-v5'); +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +// ============================================================================ +// 配置加载 +// ============================================================================ + +// 加载 .env.cos 配置 +function loadConfig() { + const envPath = path.join(__dirname, '..', '.env.cos'); + + if (!fs.existsSync(envPath)) { + console.error('\x1b[31m[错误]\x1b[0m 配置文件不存在: .env.cos'); + console.log('\n请先创建配置文件:'); + console.log(' 1. 复制 .env.cos.example 为 .env.cos'); + console.log(' 2. 填写腾讯云 SecretId、SecretKey 等信息\n'); + process.exit(1); + } + + const envContent = fs.readFileSync(envPath, 'utf-8'); + const config = {}; + + envContent.split('\n').forEach(line => { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#')) { + const [key, ...valueParts] = trimmed.split('='); + if (key) { + config[key.trim()] = valueParts.join('=').trim(); + } + } + }); + + // 验证必需配置 + const required = ['COS_SECRET_ID', 'COS_SECRET_KEY', 'COS_BUCKET', 'COS_REGION']; + const missing = required.filter(key => !config[key]); + + if (missing.length > 0) { + console.error('\x1b[31m[错误]\x1b[0m 配置不完整,缺少:', missing.join(', ')); + process.exit(1); + } + + return config; +} + +// ============================================================================ +// COS 客户端 +// ============================================================================ + +let cos = null; +let config = null; + +function initCOS() { + config = loadConfig(); + + cos = new COS({ + SecretId: config.COS_SECRET_ID, + SecretKey: config.COS_SECRET_KEY, + }); + + console.log('\x1b[32m[✓]\x1b[0m COS 客户端初始化成功'); + console.log(` 存储桶: ${config.COS_BUCKET}`); + console.log(` 地域: ${config.COS_REGION}`); +} + +// ============================================================================ +// 文件操作 +// ============================================================================ + +/** + * 递归获取目录下所有文件 + */ +function getAllFiles(dirPath, arrayOfFiles = []) { + const files = fs.readdirSync(dirPath); + + files.forEach(file => { + const fullPath = path.join(dirPath, file); + if (fs.statSync(fullPath).isDirectory()) { + getAllFiles(fullPath, arrayOfFiles); + } else { + arrayOfFiles.push(fullPath); + } + }); + + return arrayOfFiles; +} + +/** + * 获取文件的 Content-Type + */ +function getContentType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + const mimeTypes = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.eot': 'application/vnd.ms-fontobject', + '.map': 'application/json', + '.txt': 'text/plain; charset=utf-8', + '.xml': 'application/xml', + '.webmanifest': 'application/manifest+json', + }; + + return mimeTypes[ext] || 'application/octet-stream'; +} + +/** + * 获取缓存控制头 + */ +function getCacheControl(filePath) { + const ext = path.extname(filePath).toLowerCase(); + const fileName = path.basename(filePath); + + // HTML 文件不缓存(或短缓存) + if (ext === '.html') { + return 'no-cache, no-store, must-revalidate'; + } + + // 带 hash 的静态资源长期缓存(JS/CSS 构建产物) + // 文件名格式: main.abc123.js, styles.def456.css + if (/\.[a-f0-9]{8,}\.(js|css)$/.test(fileName)) { + return 'public, max-age=31536000, immutable'; // 1 年 + } + + // 图片、字体缓存 30 天 + if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.webp', '.woff', '.woff2', '.ttf', '.eot'].includes(ext)) { + return 'public, max-age=2592000'; // 30 天 + } + + // 其他文件缓存 1 天 + return 'public, max-age=86400'; +} + +// ============================================================================ +// 上传功能 +// ============================================================================ + +/** + * 上传单个文件到 COS + */ +function uploadFile(localPath, remotePath) { + return new Promise((resolve, reject) => { + const contentType = getContentType(localPath); + const cacheControl = getCacheControl(localPath); + + cos.putObject({ + Bucket: config.COS_BUCKET, + Region: config.COS_REGION, + Key: remotePath, + Body: fs.createReadStream(localPath), + ContentType: contentType, + CacheControl: cacheControl, + }, (err, data) => { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }); +} + +/** + * 清空存储桶(可选) + */ +async function clearBucket() { + console.log('\n\x1b[36m[清理]\x1b[0m 清空存储桶旧文件...'); + + return new Promise((resolve, reject) => { + cos.getBucket({ + Bucket: config.COS_BUCKET, + Region: config.COS_REGION, + MaxKeys: 1000, + }, async (err, data) => { + if (err) { + reject(err); + return; + } + + if (!data.Contents || data.Contents.length === 0) { + console.log(' 存储桶为空,无需清理'); + resolve(); + return; + } + + const objects = data.Contents.map(item => ({ Key: item.Key })); + console.log(` 发现 ${objects.length} 个文件`); + + cos.deleteMultipleObject({ + Bucket: config.COS_BUCKET, + Region: config.COS_REGION, + Objects: objects, + }, (err, data) => { + if (err) { + reject(err); + } else { + console.log(` 已删除 ${objects.length} 个文件`); + resolve(); + } + }); + }); + }); +} + +/** + * 上传整个 build 目录 + */ +async function uploadBuildDir() { + const buildDir = path.join(__dirname, '..', 'build'); + + if (!fs.existsSync(buildDir)) { + console.error('\x1b[31m[错误]\x1b[0m build 目录不存在,请先运行 npm run build'); + process.exit(1); + } + + const files = getAllFiles(buildDir); + console.log(`\n\x1b[36m[上传]\x1b[0m 共 ${files.length} 个文件待上传...\n`); + + let uploaded = 0; + let failed = 0; + const errors = []; + + // 并发上传(限制并发数) + const concurrency = 10; + const chunks = []; + for (let i = 0; i < files.length; i += concurrency) { + chunks.push(files.slice(i, i + concurrency)); + } + + for (const chunk of chunks) { + await Promise.all(chunk.map(async (filePath) => { + const relativePath = path.relative(buildDir, filePath).replace(/\\/g, '/'); + + try { + await uploadFile(filePath, relativePath); + uploaded++; + + // 进度显示 + const progress = Math.round((uploaded + failed) / files.length * 100); + process.stdout.write(`\r 进度: ${progress}% (${uploaded}/${files.length})`); + } catch (err) { + failed++; + errors.push({ file: relativePath, error: err.message }); + } + })); + } + + console.log('\n'); + + if (failed > 0) { + console.log('\x1b[33m[警告]\x1b[0m 部分文件上传失败:'); + errors.forEach(({ file, error }) => { + console.log(` - ${file}: ${error}`); + }); + } + + console.log(`\x1b[32m[✓]\x1b[0m 上传完成: 成功 ${uploaded},失败 ${failed}`); + + return { uploaded, failed }; +} + +// ============================================================================ +// CDN 刷新(可选) +// ============================================================================ + +/** + * 刷新 CDN 缓存 + * 注意:需要额外配置 CDN API 权限 + */ +async function refreshCDN() { + if (!config.CDN_DOMAIN) { + console.log('\n\x1b[33m[提示]\x1b[0m 未配置 CDN 域名,跳过 CDN 刷新'); + console.log(' 如需自动刷新 CDN,请在 .env.cos 中配置 CDN_DOMAIN'); + return; + } + + console.log('\n\x1b[36m[CDN]\x1b[0m 刷新 CDN 缓存...'); + console.log(` 域名: ${config.CDN_DOMAIN}`); + console.log('\n\x1b[33m[提示]\x1b[0m 由于文件名包含 hash,通常不需要手动刷新 CDN'); + console.log(' 如需刷新,请到腾讯云 CDN 控制台操作:'); + console.log(' https://console.cloud.tencent.com/cdn/refresh'); +} + +// ============================================================================ +// 主流程 +// ============================================================================ + +async function main() { + console.log('\n╔════════════════════════════════════════════════════════════════╗'); + console.log('║ 腾讯云 COS 部署工具 - ValueFrontier ║'); + console.log('╚════════════════════════════════════════════════════════════════╝\n'); + + const startTime = Date.now(); + + try { + // 1. 初始化 COS 客户端 + initCOS(); + + // 2. 可选:清空存储桶 + const clearFirst = process.argv.includes('--clear'); + if (clearFirst) { + await clearBucket(); + } + + // 3. 上传文件 + const { uploaded, failed } = await uploadBuildDir(); + + // 4. CDN 刷新提示 + await refreshCDN(); + + // 5. 完成 + const duration = ((Date.now() - startTime) / 1000).toFixed(1); + + console.log('\n╔════════════════════════════════════════════════════════════════╗'); + console.log('║ 🎉 部署成功! ║'); + console.log('╚════════════════════════════════════════════════════════════════╝'); + console.log(`\n 耗时: ${duration} 秒`); + console.log(` 上传: ${uploaded} 个文件`); + + if (config.CDN_DOMAIN) { + console.log(`\n 访问地址: https://${config.CDN_DOMAIN}`); + } + + console.log('\n'); + + } catch (err) { + console.error('\n\x1b[31m[错误]\x1b[0m 部署失败:', err.message); + process.exit(1); + } +} + +// 运行 +main(); diff --git a/scripts/nginx-api.conf.example b/scripts/nginx-api.conf.example new file mode 100644 index 00000000..a52b42ad --- /dev/null +++ b/scripts/nginx-api.conf.example @@ -0,0 +1,105 @@ +# ============================================================================ +# Nginx 配置 - API 服务器 +# 部署 CDN 后,Nginx 只需处理 API 请求 +# +# 使用方法: +# 1. 复制此文件到服务器: /etc/nginx/sites-available/api.valuefrontier.cn +# 2. 修改 SSL 证书路径 +# 3. sudo ln -s /etc/nginx/sites-available/api.valuefrontier.cn /etc/nginx/sites-enabled/ +# 4. sudo nginx -t && sudo systemctl reload nginx +# ============================================================================ + +# API 服务器配置 +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name api.valuefrontier.cn; + + # SSL 证书(需要为 api.valuefrontier.cn 申请证书) + ssl_certificate /etc/nginx/ssl/api.valuefrontier.cn.pem; + ssl_certificate_key /etc/nginx/ssl/api.valuefrontier.cn.key; + + # SSL 配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + + # CORS 配置(允许 CDN 域名访问) + add_header 'Access-Control-Allow-Origin' 'https://www.valuefrontier.cn' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Origin, Content-Type, Accept, Authorization, X-Requested-With' always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + + # 处理 OPTIONS 预检请求 + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' 'https://www.valuefrontier.cn' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Origin, Content-Type, Accept, Authorization, X-Requested-With' always; + add_header 'Access-Control-Max-Age' 86400; + add_header 'Content-Type' 'text/plain charset=UTF-8'; + add_header 'Content-Length' 0; + return 204; + } + + # API 代理到 Flask + location / { + proxy_pass http://127.0.0.1:5001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket 支持 + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # 超时配置 + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # 健康检查端点 + location /health { + return 200 'ok'; + add_header Content-Type text/plain; + } +} + +# HTTP 重定向到 HTTPS +server { + listen 80; + listen [::]:80; + server_name api.valuefrontier.cn; + return 301 https://$server_name$request_uri; +} + +# ============================================================================ +# 可选:如果你想保留原来的 www 域名也指向服务器(作为备用) +# 取消下面的注释 +# ============================================================================ +# server { +# listen 443 ssl http2; +# server_name www.valuefrontier.cn valuefrontier.cn; +# +# # SSL 证书 +# ssl_certificate /etc/nginx/ssl/valuefrontier.cn.pem; +# ssl_certificate_key /etc/nginx/ssl/valuefrontier.cn.key; +# +# root /var/www/valuefrontier.cn; +# index index.html; +# +# # SPA 路由 +# location / { +# try_files $uri $uri/ /index.html; +# } +# +# # API 代理 +# location /api { +# proxy_pass http://127.0.0.1:5001; +# # ... 其他配置 +# } +# } diff --git a/src/components/Subscription/SubscriptionContentNew.tsx b/src/components/Subscription/SubscriptionContentNew.tsx index d5d26a22..cb5fb2e0 100644 --- a/src/components/Subscription/SubscriptionContentNew.tsx +++ b/src/components/Subscription/SubscriptionContentNew.tsx @@ -519,23 +519,34 @@ export default function SubscriptionContentNew() { console.log('[支付宝] 是否移动端跳转:', isMobileDevice); if (isMobileDevice) { - // 手机端:直接在当前页面跳转(可调起支付宝APP) - toast({ - title: '订单创建成功', - description: '正在跳转到支付宝...', - status: 'success', - duration: 2000, - isClosable: true, - }); + // 手机端:显示模态框,让用户手动点击跳转到支付宝 + // 原因:各种手机浏览器(Safari、华为、小米、微信内置等)对自动跳转有不同限制 + // 用户主动点击可以绑过这些限制,确保兼容性 // 保存订单信息到 sessionStorage,支付完成后返回时可以用来检查状态 sessionStorage.setItem('alipay_order_id', data.data.id); sessionStorage.setItem('alipay_order_no', data.data.order_no); - // 延迟跳转,让用户看到提示 - setTimeout(() => { - window.location.href = data.data.pay_url; - }, 500); + // 检测是否在微信内置浏览器中 + const isWechatBrowser = /MicroMessenger/i.test(navigator.userAgent); + + // 显示模态框,让用户点击按钮跳转 + setPaymentOrder({ + ...data.data, + payment_method: 'alipay', + is_mobile: true, + is_wechat_browser: isWechatBrowser, + }); + setPaymentCountdown(30 * 60); + startAutoPaymentCheck(data.data.id, 'alipay'); + + toast({ + title: '订单创建成功', + description: isWechatBrowser ? '请点击按钮,在浏览器中打开支付' : '请点击按钮打开支付宝', + status: 'success', + duration: 3000, + isClosable: true, + }); } else { // PC端:新窗口打开 setPaymentOrder(data.data); @@ -1619,12 +1630,49 @@ export default function SubscriptionContentNew() { 支付宝支付 - - 请在新打开的页面中完成支付 - - - 支付完成后点击下方按钮确认 - + {(paymentOrder as any).is_mobile ? ( + <> + {(paymentOrder as any).is_wechat_browser ? ( + <> + + 检测到您在微信中打开,请点击右上角「...」选择「在浏览器中打开」后再支付 + + + 或点击下方按钮尝试跳转 + + + ) : ( + + 点击下方按钮打开支付宝完成支付 + + )} + + + ) : ( + <> + + 请在新打开的页面中完成支付 + + + 支付完成后点击下方按钮确认 + + + )} ) : ( diff --git a/src/utils/apiConfig.js b/src/utils/apiConfig.js index 03928679..4ab7dcf3 100644 --- a/src/utils/apiConfig.js +++ b/src/utils/apiConfig.js @@ -17,19 +17,15 @@ * const response = await fetch(getApiBase() + '/api/users'); */ export const getApiBase = () => { - // 生产环境使用相对路径 - if (process.env.NODE_ENV === 'production') { - return ''; - } - - // 检查是否定义了 REACT_APP_API_URL(包括空字符串) - // 使用 !== undefined 而不是 || 运算符,正确处理空字符串 + // 优先使用环境变量配置的 API URL + // 生产环境配置为 https://api.valuefrontier.cn(CDN 部署后静态资源和 API 分离) + // Mock 模式下配置为空字符串,让 MSW 拦截请求 const apiUrl = process.env.REACT_APP_API_URL; if (apiUrl !== undefined) { - return apiUrl; // Mock 模式下返回 '',其他情况返回配置的值 + return apiUrl; } - // 未配置时的默认后端地址 + // 未配置时的默认后端地址(开发环境) return 'http://49.232.185.254:5001'; };