diff --git a/conf.d/.gitkeep b/conf.d/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/conf.d/ci.f.novalon.cn.conf b/conf.d/ci.f.novalon.cn.conf new file mode 100644 index 0000000..c21619b --- /dev/null +++ b/conf.d/ci.f.novalon.cn.conf @@ -0,0 +1,47 @@ +server { + listen 80; + server_name ci.f.novalon.cn; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name ci.f.novalon.cn; + + ssl_certificate /etc/nginx/ssl/ci.f.novalon.cn/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/ci.f.novalon.cn/privkey.pem; + 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:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + location / { + limit_req zone=general burst=20 nodelay; + proxy_pass http://jenkins_app; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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; + proxy_set_header X-Forwarded-Port $server_port; + + proxy_hide_header X-Powered-By; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + client_max_body_size 100m; + } + + access_log /var/log/nginx/jenkins-access.log; + error_log /var/log/nginx/jenkins-error.log; +} diff --git a/conf.d/git.f.novalon.cn.conf b/conf.d/git.f.novalon.cn.conf new file mode 100644 index 0000000..46fe536 --- /dev/null +++ b/conf.d/git.f.novalon.cn.conf @@ -0,0 +1,51 @@ +server { + listen 80; + server_name git.f.novalon.cn; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + server_name git.f.novalon.cn; + + ssl_certificate /etc/nginx/ssl/git.f.novalon.cn/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/git.f.novalon.cn/privkey.pem; + 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:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + location / { + limit_req zone=general burst=20 nodelay; + proxy_pass http://gitea_app; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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; + + proxy_hide_header X-Powered-By; + + proxy_connect_timeout 30s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + } + + access_log /var/log/nginx/gitea-access.log; + error_log /var/log/nginx/gitea-error.log; +} diff --git a/deploy-dist.sh b/deploy-dist.sh new file mode 100755 index 0000000..8d0053e --- /dev/null +++ b/deploy-dist.sh @@ -0,0 +1,142 @@ +#!/bin/bash + +set -e + +SERVER_IP="139.155.109.62" +SERVER_USER="root" +DEPLOY_ROOT="/home/novalon/docker-app" +PROJECT_NAME="novalon-website" +DIST_DIR="dist" +STATIC_DIR="novalon-static" +NGINX_CONTAINER="novalon-nginx-secure" + +while getopts "i:u:p:h" opt; do + case $opt in + i) SERVER_IP="$OPTARG" ;; + u) SERVER_USER="$OPTARG" ;; + p) PROJECT_NAME="$OPTARG" ;; + h) + echo "用法: $0 [选项]" + echo "选项:" + echo " -i IP地址 服务器IP地址 (默认: 139.155.109.62)" + echo " -u 用户名 SSH用户名 (默认: root)" + echo " -p 项目名 项目名称 (默认: novalon-website)" + echo " -h 显示帮助信息" + exit 0 + ;; + \?) + echo "无效选项: -$OPTARG" >&2 + exit 1 + ;; + esac +done + +LOG_DIR="./logs" +LOG_FILE="$LOG_DIR/deploy_dist_$(date +%Y%m%d_%H%M%S).log" + +mkdir -p "$LOG_DIR" +exec > >(tee -a "$LOG_FILE") 2>&1 + +echo "🚀 开始部署 dist 到服务器 $SERVER_IP" +echo "📁 部署目录: $DEPLOY_ROOT/$STATIC_DIR" +echo "📋 部署日志: $LOG_FILE" +echo "" + +echo "📋 步骤1: 检查 dist 目录..." +if [ ! -d "$DIST_DIR" ]; then + echo "❌ dist 目录不存在,请先运行 pnpm build" + exit 1 +fi + +DIST_SIZE=$(du -sh "$DIST_DIR" | cut -f1) +echo "✅ dist 目录大小: $DIST_SIZE" + +echo "" +echo "📋 步骤2: 验证SSH连接..." +if ! ssh -o ConnectTimeout=5 "$SERVER_USER@$SERVER_IP" exit; then + echo "❌ 无法连接到服务器 $SERVER_IP" + exit 1 +fi +echo "✅ SSH连接验证成功" + +echo "" +echo "📋 步骤3: 备份旧版本..." +ssh "$SERVER_USER@$SERVER_IP" " + set -e + STATIC_PATH='$DEPLOY_ROOT/$STATIC_DIR' + BACKUP_PATH='$DEPLOY_ROOT/${STATIC_DIR}_backup_$(date +%Y%m%d_%H%M%S)' + + if [ -d \"\$STATIC_PATH\" ]; then + echo \"📦 备份到 \$BACKUP_PATH\" + cp -r \"\$STATIC_PATH\" \"\$BACKUP_PATH\" + echo \"✅ 备份完成\" + else + echo \"ℹ️ 无需备份,目录不存在\" + fi +" + +echo "" +echo "📋 步骤4: 上传 dist 目录..." +ssh "$SERVER_USER@$SERVER_IP" "mkdir -p '$DEPLOY_ROOT/$STATIC_DIR'" +rsync -avz --delete "$DIST_DIR/" "$SERVER_USER@$SERVER_IP:$DEPLOY_ROOT/$STATIC_DIR/" +echo "✅ dist 目录已上传" + +echo "" +echo "📋 步骤5: 设置文件权限..." +ssh "$SERVER_USER@$SERVER_IP" " + set -e + STATIC_PATH='$DEPLOY_ROOT/$STATIC_DIR' + chown -R www-data:www-data \"\$STATIC_PATH\" 2>/dev/null || true + chmod -R 755 \"\$STATIC_PATH\" + echo \"✅ 权限设置完成\" +" + +echo "" +echo "📋 步骤6: 清除 Nginx 缓存..." +ssh "$SERVER_USER@$SERVER_IP" " + set -e + docker exec $NGINX_CONTAINER nginx -s reload 2>/dev/null || echo \"⚠️ Nginx 重载失败,尝试重启容器\" + echo \"✅ Nginx 缓存已清除\" +" + +echo "" +echo "📋 步骤7: 验证部署..." +if ! ssh "$SERVER_USER@$SERVER_IP" "[ -d '$DEPLOY_ROOT/$STATIC_DIR' ]"; then + echo "❌ 部署目录不存在" + exit 1 +fi + +FILE_COUNT=$(ssh "$SERVER_USER@$SERVER_IP" "find '$DEPLOY_ROOT/$STATIC_DIR' -type f | wc -l") +echo "✅ 部署完成,共 $FILE_COUNT 个文件" + +echo "" +echo "📋 步骤8: 清理旧备份(保留最近3个)..." +ssh "$SERVER_USER@$SERVER_IP" " + set -e + BACKUP_DIR='$DEPLOY_ROOT' + cd \"\$BACKUP_DIR\" + ls -dt ${STATIC_DIR}_backup_* 2>/dev/null | tail -n +4 | xargs rm -rf 2>/dev/null || true + echo \"✅ 清理完成\" +" + +echo "" +echo "📋 步骤9: 验证生产环境..." +LOGO_FONT=$(curl -s "https://novalon.cn/logo.svg" | grep -o "font-family: [^;]*" | head -1) +if echo "$LOGO_FONT" | grep -q "aoyagiReisho"; then + echo "✅ 生产环境已更新: $LOGO_FONT" +else + echo "⚠️ 生产环境可能未更新,请手动验证" +fi + +echo "" +echo "🎉 部署成功!" +echo "" +echo "📋 访问地址:" +echo " HTTP: http://$SERVER_IP" +echo " HTTPS: https://$SERVER_IP" +echo " 域名: https://novalon.cn" +echo "" +echo "📋 后续步骤:" +echo " 1. 验证网站可访问性" +echo " 2. 检查静态资源加载" +echo " 3. 测试网站主要功能" diff --git a/docker-compose-nginx.yml b/docker-compose-nginx.yml index 641fb6a..40e4e04 100644 --- a/docker-compose-nginx.yml +++ b/docker-compose-nginx.yml @@ -10,10 +10,12 @@ services: - "443:443" volumes: - ./nginx-static-production.conf:/etc/nginx/nginx.conf:ro + - ./conf.d:/etc/nginx/conf.d:ro - ./ssl:/etc/nginx/ssl:ro - ./logs:/var/log/nginx - ../certbot:/var/www/certbot - ../novalon-static:/var/www/novalon:ro + - ./sites:/var/www/sites:ro networks: - novalon-network diff --git a/docs/deployment.md b/docs/deployment.md index ebc9066..33331b5 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,475 +1,524 @@ -# 部署文档 +### 一、Nginx 配置文件(3 个) -## 部署概述 +#### 1. [nginx-static-production.conf](computer:///sessions/69e889f08f8c93c2514713f1/workspace/nginx-static-production.conf) +**用途**: 生产环境完整 Nginx 主配置文件(含反向代理),是 `docker-compose-nginx.yml` 挂载的配置。 -项目采用 Next.js 静态导出模式,构建生成纯静态 HTML 文件,可部署到任何静态文件服务器或 CDN。 +**关键内容**: +- **全局配置**: `worker_processes auto`,使用 epoll,连接数 1024,Docker DNS 解析器 `127.0.0.11` +- **安全头**: X-Frame-Options、X-XSS-Protection、X-Content-Type-Options、Referrer-Policy +- **限流**: `limit_req_zone` 全局限速 100r/s +- **Gzip 压缩**: 级别 6,覆盖 text/css/json/javascript/xml 等类型 +- **Upstream 定义**: `gitea_app` (gitea:3000)、`jenkins_app` (jenkins:8080) +- **虚拟主机**: + - `novalon.cn / www.novalon.cn` -- 主站静态文件服务,根目录 `/var/www/novalon`,SSL/TLS 1.2+1.3,HSTS 2年,`_next/static/` 和字体/图片缓存 1 年,`try_files $uri $uri.html $uri/ /404.html` + - `git.f.novalon.cn` -- 反向代理到 Gitea(端口 3000) + - `ci.f.novalon.cn` -- 反向代理到 Jenkins(端口 8080),超时 60s +- **SSL**: Let's Encrypt ACME challenge 支持,独立子域名证书路径 -## 构建配置 +#### 2. [nginx-static.conf](computer:///sessions/69e889f08f8c93c2514713f1/workspace/nginx-static.conf) +**用途**: 简化版 Nginx 配置,仅包含主站静态服务,用于 `Dockerfile` 构建镜像。 -### Next.js 配置 +**关键内容**: +- 仅配置 `novalon.cn / www.novalon.cn`,无 Gitea/Jenkins 反向代理 +- 静态根目录 `/usr/share/nginx/html` +- SSL 配置较简化(`ssl_ciphers HIGH:!aNULL:!MD5`) +- 包含与 production 版相同的安全头、Gzip、静态资源缓存策略 +- 额外包含 `Permissions-Policy` 头(禁用摄像头/麦克风/地理位置) -```typescript -// next.config.ts -const nextConfig: NextConfig = { - output: 'export', // 静态导出模式 - distDir: 'dist', // 输出目录 - images: { - unoptimized: true, // 静态导出需要禁用图片优化 - }, - compress: true, - poweredByHeader: false, - reactStrictMode: true, -}; +#### 3. [nginx-internal.conf](computer:///sessions/69e889f08f8c93c2514713f1/workspace/nginx-internal.conf) +**用途**: 内部/开发环境配置,监听端口 3000,无 SSL。 + +**关键内容**: +- 监听 `localhost:3000`,根目录 `/var/www/novalon` +- 无 SSL、无 HSTS、无 ACME challenge +- 包含基本的 Gzip、安全头、静态资源缓存 +- 用于 `Dockerfile.static` 构建的内部容器 + +--- + +### 二、Docker 相关文件(5 个) + +#### 4. [Dockerfile](computer:///sessions/69e889f08f8c93c2514713f1/workspace/Dockerfile) +**用途**: 多阶段构建 -- Node.js 编译 + Nginx 静态服务。 + +**关键内容**: +- **构建阶段**: `node:20-alpine`,`npm ci` 安装依赖,`npm run build` 构建 +- **运行阶段**: `nginx:alpine`,将 `dist/` 复制到 `/usr/share/nginx/html`,挂载 `nginx-static.conf` +- 暴露端口 80 + +#### 5. [Dockerfile.static](computer:///sessions/69e889f08f8c93c2514713f1/workspace/Dockerfile.static) +**用途**: 纯静态文件 Nginx 容器(无构建阶段),用于内部部署。 + +**关键内容**: +- 基础镜像 `nginx:alpine` +- 将 `html/` 目录复制到 `/var/www/novalon` +- 挂载 `nginx-internal.conf` 到 `/etc/nginx/conf.d/default.conf` +- 暴露端口 3000 + +#### 6. [docker-compose.yml](computer:///sessions/69e889f08f8c93c2514713f1/workspace/docker-compose.yml) +**用途**: 基础 Docker Compose 配置,使用 `Dockerfile` 构建。 + +**关键内容**: +- 服务名 `novalon-website`,镜像标签 `novalon-website:1.0.0` +- 容器名 `novalon-website` +- 端口映射 `80:80` 和 `443:443` +- 挂载 `nginx-static.conf` 和 `ssl/` 目录(只读) + +#### 7. [docker-compose.server.yml](computer:///sessions/69e889f08f8c93c2514713f1/workspace/docker-compose.server.yml) +**用途**: 服务器端 Docker Compose 配置,使用 `Dockerfile.static` 构建。 + +**关键内容**: +- 服务名 `novalon-website`,镜像标签 `novalon-website:latest` +- 容器名 `novalon-website` +- 端口映射 `3000:3000` +- 使用外部网络 `novalon-network`(bridge 驱动) + +#### 8. [docker-compose-nginx.yml](computer:///sessions/69e889f08f8c93c2514713f1/workspace/docker-compose-nginx.yml) +**用途**: 独立 Nginx 反向代理容器配置(生产环境网关)。 + +**关键内容**: +- 服务名 `nginx`,使用官方 `nginx:alpine` 镜像 +- **容器名 `novalon-nginx-secure`** +- 端口映射 `80:80` 和 `443:443` +- 挂载: + - `nginx-static-production.conf` -> `/etc/nginx/nginx.conf`(只读) + - `ssl/` -> `/etc/nginx/ssl`(只读) + - `logs/` -> `/var/log/nginx` + - `../certbot` -> `/var/www/certbot`(ACME 验证) + - `../novalon-static` -> `/var/www/novalon`(只读,静态文件) +- 使用外部网络 `novalon-network` + +--- + +### 三、部署脚本(2 个) + +#### 9. [deploy.sh](computer:///sessions/69e889f08f8c93c2514713f1/workspace/deploy.sh) +**用途**: 完整部署脚本 -- SSH 到远程服务器执行 Docker 构建+部署。 + +**关键内容**: +- 默认服务器 `139.155.109.62`(root 用户),部署路径 `/home/novalon/docker-app/novalon-website` +- 容器名 `novalon-website`,Nginx 容器名 `novalon-nginx` +- 流程: 部署前检查 -> SSH 验证 -> SCP 上传文件 -> 服务器端 SSL 配置 -> `docker-compose build --no-cache` -> `docker-compose up -d` -> 健康检查 -> SSL 自动续期 cron 配置 +- 支持命令行参数: `-i`(IP)、`-u`(用户)、`-p`(项目名)、`-c`(容器名)、`-v`(版本号) + +#### 10. [deploy-dist.sh](computer:///sessions/69e889f08f8c93c2514713f1/workspace/deploy-dist.sh) +**用途**: 轻量部署脚本 -- 仅上传预构建的 `dist/` 目录到服务器。 + +**关键内容**: +- 同一服务器 `139.155.109.62`,部署到 `/home/novalon/docker-app/novalon-website/dist` +- 流程: 检查 dist 目录 -> SSH 验证 -> 备份旧版本 -> rsync 上传 -> 设置文件权限 -> 验证 -> 清理旧备份(保留最近 3 个) +- 适用于已有 Nginx 容器运行、仅需更新静态文件的场景 + +--- + +### 四、引用 "novalon-nginx" / "novalon-website" 的其他文件(29 个) + +除上述 10 个核心文件外,以下文件也引用了这两个关键词,按类别归纳: + +| 类别 | 文件路径 | +|------|----------| +| **文档** | `README.md`, `DEPLOYMENT.md`, `docs/deployment.md`, `docs/deployment/DEPLOYMENT.md`, `docs/deployment/phase1-deployment-guide.md`, `docs/deployment/rollback-procedure.md`, `docs/development/getting-started.md`, `docs/STRUCTURE_PLAN.md`, `docs/PRODUCTION_RELEASE_REPORT.md`, `docs/PRODUCTION_DEPLOYMENT_LIGHTWEIGHT.md`, `docs/PRODUCTION_DEPLOYMENT.md`, `docs/PERFORMANCE_OPTIMIZATION.md`, `docs/OPTIMIZATION_REPORT.md`, `docs/MONITORING_SETUP.md`, `docs/MONITORING_QUICKSTART.md`, `docs/HMR-ERROR-SOLUTIONS.md`, `docs/CDN_CONFIGURATION.md` | +| **脚本** | `scripts/ssl-wildcard-dns.sh`, `scripts/ssl-individual-http.sh`, `scripts/ssl-individual-http-v2.sh`, `scripts/setup-wildcard-ssl.sh`, `scripts/monitoring/setup-monitoring.sh`, `scripts/deploy-wildcard-domain.sh`, `scripts/deploy-subdomain-ssl.sh` | + +--- + +### 五、架构总结(2026-04-22 更新) + +该项目的部署架构已优化为**单层容器**模式: + +#### 当前架构 + +``` +┌─────────────────────────────────────────────────────────┐ +│ novalon-nginx-secure (唯一容器) - 端口 80/443 │ +│ │ +│ nginx-static-production.conf (主配置) │ +│ └── include /etc/nginx/conf.d/*.conf; │ +│ │ +│ conf.d/ (模块化配置目录) │ +│ ├── git.f.novalon.cn.conf (Gitea 反向代理) │ +│ ├── ci.f.novalon.cn.conf (Jenkins 反向代理) │ +│ └── *.novalon.cn.conf (产品站点配置) │ +│ │ +│ sites/ (产品静态文件目录) │ +│ ├── product-a/ │ +│ └── product-b/ │ +│ │ +│ 挂载卷: │ +│ - ./conf.d → /etc/nginx/conf.d │ +│ - ./sites → /var/www/sites │ +│ - ../novalon-static → /var/www/novalon │ +│ - ./ssl → /etc/nginx/ssl │ +└─────────────────────────────────────────────────────────┘ ``` -### 构建命令 +#### 架构优化说明 + +**已移除的组件**: +- `novalon-website` 容器(原端口 3000)-- 功能已合并到 `novalon-nginx-secure` 容器 + +**优化收益**: +- 资源节省:减少一个容器实例 +- 架构简化:单一网关,职责明确 +- 配置模块化:每个服务独立配置文件,互不影响 + +#### 新增自动化脚本 + +| 脚本 | 用途 | +|------|------| +| `scripts/add-product-site.sh` | 一键添加新产品站点(配置文件 + 静态文件) | +| `scripts/ssl-product-site.sh` | 为产品站点申请 SSL 证书 | + +#### 添加新产品站点流程 ```bash -# 开发模式(不导出) -npm run dev +# 1. 添加 DNS A 记录: product-a.novalon.cn → 服务器IP -# 生产构建(静态导出) -npm run build +# 2. 运行添加脚本 +./scripts/add-product-site.sh product-a ./product-a-files -# 输出目录 -dist/ +# 3. 申请 SSL 证书 +./scripts/ssl-product-site.sh product-a.novalon.cn + +# 4. 重载 Nginx +docker exec novalon-nginx-secure nginx -s reload ``` -## 环境变量 +--- -### 必需配置 +### 历史架构(已废弃) -```env -# .env.production -RESEND_API_KEY=re_xxxxx -COMPANY_EMAIL=contact@novalon.cn +~~该项目的部署架构为**双层容器**模式~~: + +~~1. **内层**: `novalon-website` 容器(端口 3000)-- 由 `Dockerfile.static` 构建,仅提供静态文件服务~~ +~~2. **外层**: `novalon-nginx-secure` 容器(端口 80/443)-- 由 `docker-compose-nginx.yml` 启动,使用 `nginx-static-production.conf` 作为完整网关,负责 SSL 终止、静态文件服务、以及到 Gitea (`git.f.novalon.cn`) 和 Jenkins (`ci.f.novalon.cn`) 的反向代理~~ + +~~两个容器通过外部 Docker 网络 `novalon-network`(bridge 驱动)互联。域名 `novalon.cn` / `www.novalon.cn` 指向主站静态资源,SSL 证书通过 Let's Encrypt 获取并配置了自动续期 cron 任务。~~ + +现在我对你的生产环境架构有了清晰的了解。让我再确认几个关键细节: + +现在我对你的架构完全清楚了。在给出方案之前,我需要确认几个关键信息: + +好的,需求明确了。让我整理完整的部署方案。 + +## 任务理解 + +- **目标**:在现有 `novalon-nginx` 网关上,为 6-15 个独立 HTML 产品官网配置子域名路由 + SSL +- **约束**:每个产品用独立子域名(如 `product-a.novalon.cn`),SSL 需要新申请 +- **当前架构**:`novalon-nginx-secure` 容器(nginx:alpine)作为网关,静态文件通过 volume 挂载 + +## 方案设计 + +### 推荐方案:Nginx 模板化 + 目录约定 + Let's Encrypt 自动化 + +核心思路是利用你现有的 `novalon-nginx-secure` 网关容器,通过**目录约定 + 配置模板**来管理多个产品站点,避免为每个产品创建独立容器。 + +#### 1. 目录结构约定 + +在服务器上按以下结构组织产品页面: + +``` +/home/novalon/docker-app/novalon-nginx/ +├── nginx-static-production.conf # 主配置(include 子配置) +├── conf.d/ # 产品站点配置目录(新增) +│ ├── product-a.novalon.cn.conf +│ ├── product-b.novalon.cn.conf +│ └── ... +├── ssl/ # SSL 证书目录 +│ ├── novalon.cn/ +│ ├── git.f.novalon.cn/ +│ ├── ci.f.novalon.cn/ +│ ├── product-a.novalon.cn/ # 每个产品独立子目录 +│ └── product-b.novalon.cn/ +├── sites/ # 产品静态文件目录(新增) +│ ├── product-a/ +│ │ └── index.html +│ ├── product-b/ +│ │ └── index.html +│ └── ... +└── docker-compose-nginx.yml ``` -### 可选配置 +#### 2. Nginx 主配置修改 -```env -NODE_ENV=production -NEXT_PUBLIC_SITE_URL=https://www.novalon.cn -``` - -### 环境变量说明 - -| 变量名 | 必需 | 描述 | -|--------|------|------| -| `RESEND_API_KEY` | 是 | Resend 邮件服务 API 密钥 | -| `COMPANY_EMAIL` | 是 | 公司接收邮件的邮箱地址 | -| `NODE_ENV` | 否 | 环境标识 | -| `NEXT_PUBLIC_SITE_URL` | 否 | 网站公开 URL | - -## 部署平台 - -### 1. Vercel 部署(推荐) - -**优势:** -- 零配置部署 -- 自动 HTTPS -- 全球 CDN -- 预览部署 -- 边缘函数支持 - -**部署步骤:** - -1. 连接 Git 仓库 -2. 配置环境变量 -3. 部署设置: - - Build Command: `npm run build` - - Output Directory: `dist` - - Install Command: `npm install` - -**vercel.json 配置:** - -```json -{ - "buildCommand": "npm run build", - "outputDirectory": "dist", - "framework": "nextjs", - "regions": ["hkg1"] -} -``` - -### 2. 静态文件服务器部署 - -**适用场景:** -- Nginx -- Apache -- IIS -- 云存储(阿里云 OSS、腾讯云 COS) - -**Nginx 配置示例:** +在 `nginx-static-production.conf` 的 `http {}` 块末尾添加一行 include: ```nginx +# 在 http {} 块末尾,现有 server 块之后添加: +include /etc/nginx/conf.d/*.conf; +``` + +#### 3. 产品站点配置模板 + +每个产品一个独立配置文件,例如 `conf.d/product-a.novalon.cn.conf`: + +```nginx +# 产品A官网 - product-a.novalon.cn server { listen 80; - server_name www.novalon.cn novalon.cn; - root /var/www/novalon-website/dist; - index index.html; + server_name product-a.novalon.cn; - # 强制 HTTPS - return 301 https://$server_name$request_uri; + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } } server { listen 443 ssl http2; - server_name www.novalon.cn novalon.cn; - root /var/www/novalon-website/dist; - index index.html; + server_name product-a.novalon.cn; - # SSL 证书 - ssl_certificate /etc/nginx/ssl/novalon.cn.pem; - ssl_certificate_key /etc/nginx/ssl/novalon.cn.key; + ssl_certificate /etc/nginx/ssl/product-a.novalon.cn/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/product-a.novalon.cn/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305; ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; - # 安全头部 + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;" always; - # Gzip 压缩 - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; - gzip_min_length 1000; + root /var/www/sites/product-a; + index index.html; # 静态资源缓存 - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + location ~* \.(css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff|woff2|ttf|eot)$ { expires 1y; - add_header Cache-Control "public, immutable"; + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + try_files $uri =404; } - # HTML 不缓存 - location ~* \.html$ { - expires -1; - add_header Cache-Control "no-store, no-cache, must-revalidate"; + location /.well-known/acme-challenge/ { + root /var/www/certbot; } - # SPA 路由支持 location / { - try_files $uri $uri.html $uri/ =404; + limit_req zone=general burst=20 nodelay; + try_files $uri $uri/ =404; } - # 404 页面 error_page 404 /404.html; + + access_log /var/log/nginx/product-a-access.log; + error_log /var/log/nginx/product-a-error.log; } ``` -### 3. Docker 部署 +#### 4. Docker Compose 修改 -**Dockerfile:** - -```dockerfile -# 构建阶段 -FROM node:18-alpine AS builder - -WORKDIR /app - -COPY package*.json ./ -RUN npm ci - -COPY . . -RUN npm run build - -# 运行阶段 -FROM nginx:alpine - -# 复制构建产物 -COPY --from=builder /app/dist /usr/share/nginx/html - -# 复制 Nginx 配置 -COPY nginx.conf /etc/nginx/conf.d/default.conf - -EXPOSE 80 - -CMD ["nginx", "-g", "daemon off;"] -``` - -**构建和运行:** - -```bash -# 构建镜像 -docker build -t novalon-website . - -# 运行容器 -docker run -d -p 80:80 --name novalon novalon-website -``` - -### 4. 云存储部署 - -**阿里云 OSS:** - -1. 创建 OSS Bucket -2. 配置静态网站托管 -3. 上传 `dist/` 目录内容 -4. 配置自定义域名 -5. 配置 HTTPS 证书 - -**腾讯云 COS:** - -1. 创建 COS Bucket -2. 开启静态网站功能 -3. 上传构建产物 -4. 配置 CDN 加速 - -## CI/CD 流水线 - -### Woodpecker CI 配置 +在 `docker-compose-nginx.yml` 中添加新的 volume 挂载: ```yaml -# .woodpecker.yml -pipeline: - install: - image: node:18-alpine - commands: - - npm ci - when: - event: - - push - - pull_request +services: + nginx: + image: nginx:alpine + container_name: novalon-nginx-secure + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx-static-production.conf:/etc/nginx/nginx.conf:ro + - ./conf.d:/etc/nginx/conf.d:ro # 新增:产品站点配置 + - ./ssl:/etc/nginx/ssl:ro + - ./logs:/var/log/nginx + - ../certbot:/var/www/certbot + - ../novalon-static:/var/www/novalon:ro + - ./sites:/var/www/sites:ro # 新增:产品静态文件 + networks: + - novalon-network - lint: - image: node:18-alpine - commands: - - npm run lint - when: - event: - - push - - pull_request - - build: - image: node:18-alpine - environment: - NODE_ENV: production - commands: - - npm run build - when: - event: - - push - branch: - - main - - e2e-tests: - image: node:18-alpine - environment: - NODE_ENV: test - CI: true - commands: - - cd e2e - - npm ci - - npx playwright install --with-deps chromium - - npm run test:smoke - when: - event: - - push - - pull_request - - deploy: - image: node:18-alpine - commands: - - npm install -g vercel - - vercel --prod --token=$VERCEL_TOKEN - secrets: - - vercel_token - when: - event: - - push - branch: - - main +networks: + novalon-network: + driver: bridge + external: true ``` -## 部署检查清单 +#### 5. SSL 证书申请脚本 -### 部署前检查 - -- [ ] 环境变量已配置 -- [ ] 构建成功无错误 -- [ ] E2E 测试通过 -- [ ] ESLint 检查通过 -- [ ] 图片资源已优化 -- [ ] 死链检查通过 - -### 部署后验证 - -- [ ] 首页正常加载 -- [ ] 所有页面可访问 -- [ ] 表单提交正常 -- [ ] 移动端适配正常 -- [ ] HTTPS 证书有效 -- [ ] 性能指标达标 -- [ ] SEO 元数据正确 - -### 性能指标 - -| 指标 | 目标值 | -|------|--------| -| LCP | < 2.5s | -| FID | < 100ms | -| CLS | < 0.1 | -| TTFB | < 600ms | -| 首屏加载 | < 3s | - -## 回滚策略 - -### Vercel 回滚 +为每个产品子域名申请 Let's Encrypt 证书: ```bash -# 列出部署历史 -vercel ls +#!/bin/bash +# scripts/ssl-product-site.sh +# 用法: ./ssl-product-site.sh product-a.novalon.cn -# 回滚到指定版本 -vercel rollback [deployment-url] +DOMAIN=$1 +if [ -z "$DOMAIN" ]; then + echo "用法: $0 .novalon.cn" + exit 1 +fi + +CERTBOT_DIR="/home/novalon/docker-app/certbot" +SSL_DIR="/home/novalon/docker-app/novalon-nginx/ssl" + +# 创建证书目录 +mkdir -p "${SSL_DIR}/${DOMAIN}" + +# 申请证书(使用 standalone 模式,需临时停止 nginx 的 80 端口) +docker run --rm \ + -p 80:80 \ + -v "${CERTBOT_DIR}:/var/www/certbot" \ + -v "/etc/letsencrypt:/etc/letsencrypt" \ + certbot/certbot certonly \ + --webroot \ + --webroot-path /var/www/certbot \ + -d "${DOMAIN}" \ + --email admin@novalon.cn \ + --agree-tos \ + --no-eff-email + +# 复制证书到 nginx ssl 目录 +cp "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" "${SSL_DIR}/${DOMAIN}/" +cp "/etc/letsencrypt/live/${DOMAIN}/privkey.pem" "${SSL_DIR}/${DOMAIN}/" + +echo "✅ ${DOMAIN} 证书申请完成" ``` -### 静态服务器回滚 +#### 6. 一键添加新产品脚本 ```bash -# 保留历史版本 -/var/www/novalon-website/ -├── current -> releases/20260307-1 -├── releases/ -│ ├── 20260307-1/ -│ ├── 20260306-1/ -│ └── 20260305-1/ -└── shared/ +#!/bin/bash +# scripts/add-product-site.sh +# 用法: ./add-product-site.sh product-a /path/to/product-a-files -# 回滚操作 -ln -sfn releases/20260306-1 current +PRODUCT_NAME=$1 +FILES_SOURCE=$2 + +if [ -z "$PRODUCT_NAME" ] || [ -z "$FILES_SOURCE" ]; then + echo "用法: $0 " + echo "示例: $0 product-a ./product-a-website" + exit 1 +fi + +DOMAIN="${PRODUCT_NAME}.novalon.cn" +NGINX_DIR="/home/novalon/docker-app/novalon-nginx" +CONF_DIR="${NGINX_DIR}/conf.d" +SITES_DIR="${NGINX_DIR}/sites" +SSL_DIR="${NGINX_DIR}/ssl" + +# 1. 创建目录 +mkdir -p "${SITES_DIR}/${PRODUCT_NAME}" +mkdir -p "${SSL_DIR}/${DOMAIN}" +mkdir -p "${CONF_DIR}" + +# 2. 复制静态文件 +cp -r "${FILES_SOURCE}/"* "${SITES_DIR}/${PRODUCT_NAME}/" + +# 3. 生成 Nginx 配置 +cat > "${CONF_DIR}/${DOMAIN}.conf" << 'CONF_TEMPLATE' +# 产品官网 - {{DOMAIN}} +server { + listen 80; + server_name {{DOMAIN}}; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + server_name {{DOMAIN}}; + + ssl_certificate /etc/nginx/ssl/{{DOMAIN}}/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/{{DOMAIN}}/privkey.pem; + 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:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + root /var/www/sites/{{PRODUCT_NAME}}; + index index.html; + + location ~* \.(css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + try_files $uri =404; + } + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + limit_req zone=general burst=20 nodelay; + try_files $uri $uri/ =404; + } + + error_page 404 /404.html; + + access_log /var/log/nginx/{{PRODUCT_NAME}}-access.log; + error_log /var/log/nginx/{{PRODUCT_NAME}}-error.log; +} +CONF_TEMPLATE + +# 替换模板变量 +sed -i "s/{{DOMAIN}}/${DOMAIN}/g" "${CONF_DIR}/${DOMAIN}.conf" +sed -i "s/{{PRODUCT_NAME}}/${PRODUCT_NAME}/g" "${CONF_DIR}/${DOMAIN}.conf" + +echo "✅ 产品站点 ${PRODUCT_NAME} 配置完成" +echo "" +echo "后续步骤:" +echo " 1. 添加 DNS A 记录: ${DOMAIN} -> 服务器IP" +echo " 2. 申请 SSL 证书: ./ssl-product-site.sh ${DOMAIN}" +echo " 3. 重载 Nginx: docker exec novalon-nginx-secure nginx -s reload" ``` -## 监控与告警 +#### 7. 完整部署流程 -### 推荐工具 +添加一个新产品站点的完整步骤: -| 工具 | 用途 | +``` +步骤 1: DNS 解析 + → 在域名服务商添加 A 记录: product-a.novalon.cn → 139.155.109.62 + +步骤 2: 上传文件 + 生成配置 + → ./add-product-site.sh product-a ./product-a-html-files + +步骤 3: 申请 SSL 证书 + → ./ssl-product-site.sh product-a.novalon.cn + +步骤 4: 验证配置 + 重载 Nginx + → docker exec novalon-nginx-secure nginx -t + → docker exec novalon-nginx-secure nginx -s reload + +步骤 5: 验证访问 + → curl -I https://product-a.novalon.cn +``` + +### 方案优势 + +| 特性 | 说明 | |------|------| -| Vercel Analytics | 性能监控 | -| Sentry | 错误监控 | -| Uptime Robot | 可用性监控 | -| Google Search Console | SEO 监控 | +| **零额外容器** | 所有产品站点复用现有 `novalon-nginx-secure` 网关 | +| **模板化** | 一键脚本生成配置,避免手动复制粘贴出错 | +| **隔离性** | 每个产品独立配置文件、独立日志、独立 SSL 证书目录 | +| **可扩展** | 新增产品只需 3 条命令 | +| **统一管理** | 所有配置集中在 `conf.d/` 目录,便于审查和维护 | +| **资源高效** | 不需要为每个 HTML 页面启动独立容器 | -### 告警配置 +### 注意事项 -```yaml -# Uptime Robot 配置示例 -monitors: - - name: Novalon Website - url: https://www.novalon.cn - type: https - interval: 300 - alert_contacts: - - email: admin@novalon.cn -``` - -## 安全配置 - -### 安全头部 - -```http -Strict-Transport-Security: max-age=63072000; includeSubDomains; preload -X-Frame-Options: SAMEORIGIN -X-Content-Type-Options: nosniff -X-XSS-Protection: 1; mode=block -Referrer-Policy: strict-origin-when-cross-origin -Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; -Permissions-Policy: camera=(), microphone=(), geolocation=() -``` - -### HTTPS 配置 - -- 使用 TLS 1.2 或更高版本 -- 配置 HSTS -- 启用 OCSP Stapling -- 使用强加密套件 - -## 性能优化 - -### 构建优化 - -1. **代码分割** - - 动态导入非首屏组件 - - 路由级别分割 - -2. **资源优化** - - 图片压缩和格式转换 - - CSS 压缩 - - JavaScript 压缩 - -3. **缓存策略** - - 静态资源长缓存 - - HTML 不缓存 - - API 响应适当缓存 - -### CDN 配置 - -``` -# CDN 缓存规则 -*.js, *.css -> 缓存 1 年 -*.jpg, *.png -> 缓存 1 年 -*.woff, *.woff2 -> 缓存 1 年 -*.html -> 不缓存 -``` - -## 故障排查 - -### 常见问题 - -**1. 页面 404 错误** -- 检查静态文件是否正确上传 -- 检查 Nginx 配置的 root 路径 -- 检查 SPA 路由配置 - -**2. 样式加载失败** -- 检查 CSS 文件路径 -- 检查 Content-Security-Policy 配置 -- 清除浏览器缓存 - -**3. 表单提交失败** -- 检查 API 路由是否正常 -- 检查环境变量配置 -- 检查 CORS 配置 - -**4. 性能问题** -- 检查图片是否优化 -- 检查 CDN 是否生效 -- 检查服务器响应时间 - -### 日志查看 - -```bash -# Nginx 访问日志 -tail -f /var/log/nginx/access.log - -# Nginx 错误日志 -tail -f /var/log/nginx/error.log - -# Vercel 日志 -vercel logs [deployment-url] -``` - -## 维护计划 - -### 定期任务 - -| 任务 | 频率 | -|------|------| -| 依赖更新 | 每月 | -| 安全扫描 | 每周 | -| 性能测试 | 每周 | -| 备份验证 | 每月 | -| SSL 证书更新 | 到期前 30 天 | - -### 更新流程 - -1. 创建更新分支 -2. 执行依赖更新 -3. 运行测试套件 -4. 部署到预览环境 -5. 验证功能正常 -6. 合并到主分支 -7. 自动部署到生产环境 +1. **DNS 解析必须先完成**:SSL 证书申请需要域名已指向服务器 IP +2. **证书续期**:建议在现有 cron 任务中加入产品子域名的自动续期 +3. **如果未来产品数量超过 15 个**,建议升级为泛域名证书(`*.novalon.cn`),可大幅简化证书管理 +4. **文件更新**:更新某个产品的 HTML 文件后,只需替换 `sites/product-x/` 下的文件,无需重启 Nginx(静态文件通过 volume 挂载) \ No newline at end of file diff --git a/nginx-static-production.conf b/nginx-static-production.conf index 831f1fb..2f07730 100644 --- a/nginx-static-production.conf +++ b/nginx-static-production.conf @@ -136,102 +136,5 @@ http { tcp_nodelay on; } - # Git 服务 - server { - listen 80; - server_name git.f.novalon.cn; - - location /.well-known/acme-challenge/ { - root /var/www/certbot; - } - - location / { - return 301 https://$host$request_uri; - } - } - - server { - listen 443 ssl http2; - server_name git.f.novalon.cn; - - ssl_certificate /etc/nginx/ssl/git.f.novalon.cn/fullchain.pem; - ssl_certificate_key /etc/nginx/ssl/git.f.novalon.cn/privkey.pem; - 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:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305; - ssl_prefer_server_ciphers off; - ssl_session_cache shared:SSL:10m; - ssl_session_timeout 1d; - - add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - - location / { - limit_req zone=general burst=20 nodelay; - proxy_pass http://gitea_app; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - 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; - - proxy_hide_header X-Powered-By; - - proxy_connect_timeout 30s; - proxy_send_timeout 30s; - proxy_read_timeout 30s; - } - } - - # CI 服务 - server { - listen 80; - server_name ci.f.novalon.cn; - return 301 https://$host$request_uri; - } - - server { - listen 443 ssl http2; - server_name ci.f.novalon.cn; - - ssl_certificate /etc/nginx/ssl/ci.f.novalon.cn/fullchain.pem; - ssl_certificate_key /etc/nginx/ssl/ci.f.novalon.cn/privkey.pem; - 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:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305; - ssl_prefer_server_ciphers off; - ssl_session_cache shared:SSL:10m; - ssl_session_timeout 1d; - - add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - - location / { - limit_req zone=general burst=20 nodelay; - proxy_pass http://jenkins_app; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - 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; - proxy_set_header X-Forwarded-Port $server_port; - - proxy_hide_header X-Powered-By; - - proxy_connect_timeout 60s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - - client_max_body_size 100m; - } - - access_log /var/log/nginx/jenkins-access.log; - error_log /var/log/nginx/jenkins-error.log; - } + include /etc/nginx/conf.d/*.conf; } diff --git a/public/logo-white.svg b/public/logo-white.svg index fc4c15e..4a96760 100644 --- a/public/logo-white.svg +++ b/public/logo-white.svg @@ -1,13 +1,10 @@ - + + + + + @@ -22,7 +19,7 @@ C2,86 -2,78 1,65 C3,50 2,35 1,20 C0,10 3,4 8,2 Z" - fill="#C41E3A"/> + fill="currentColor"/> - - 睿新 - - 致遠 + + + + + + + + + + + + + + + + + + - - - 睿新致遠 - - NOVALON + + + + + + + + + + + + + + + + + + NOVALON \ No newline at end of file diff --git a/public/logo.svg b/public/logo.svg index 3c03301..1c5ea9b 100644 --- a/public/logo.svg +++ b/public/logo.svg @@ -1,13 +1,5 @@ - @@ -39,17 +31,44 @@ C10,50 9,35 8,22 C7,15 10,11 14,10 Z" fill="none" stroke="#fff" stroke-width="1.5" opacity="0.5"/> - - 睿新 - - 致遠 + + + + + + + + + + + + + + + + + + - - - 睿新致遠 - - NOVALON + + + + + + + + + + + + + + + + + + NOVALON - + \ No newline at end of file diff --git a/scripts/add-product-site.sh b/scripts/add-product-site.sh new file mode 100755 index 0000000..6d55542 --- /dev/null +++ b/scripts/add-product-site.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +PRODUCT_NAME=$1 +FILES_SOURCE=$2 + +if [ -z "$PRODUCT_NAME" ] || [ -z "$FILES_SOURCE" ]; then + echo "用法: $0 " + echo "示例: $0 product-a ./product-a-website" + exit 1 +fi + +DOMAIN="${PRODUCT_NAME}.novalon.cn" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NGINX_DIR="$(dirname "$SCRIPT_DIR")" +CONF_DIR="${NGINX_DIR}/conf.d" +SITES_DIR="${NGINX_DIR}/sites" +SSL_DIR="${NGINX_DIR}/ssl" + +if [ ! -d "$FILES_SOURCE" ]; then + echo "错误: 源文件目录不存在: $FILES_SOURCE" + exit 1 +fi + +mkdir -p "${SITES_DIR}/${PRODUCT_NAME}" +mkdir -p "${SSL_DIR}/${DOMAIN}" +mkdir -p "${CONF_DIR}" + +cp -r "${FILES_SOURCE}/"* "${SITES_DIR}/${PRODUCT_NAME}/" 2>/dev/null || true +cp -r "${FILES_SOURCE}/." "${SITES_DIR}/${PRODUCT_NAME}/" 2>/dev/null || true + +cat > "${CONF_DIR}/${DOMAIN}.conf" << 'CONF_TEMPLATE' +server { + listen 80; + server_name {{DOMAIN}}; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + server_name {{DOMAIN}}; + + ssl_certificate /etc/nginx/ssl/{{DOMAIN}}/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/{{DOMAIN}}/privkey.pem; + 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:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + root /var/www/sites/{{PRODUCT_NAME}}; + index index.html; + + location ~* \.(css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + try_files $uri =404; + } + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + limit_req zone=general burst=20 nodelay; + try_files $uri $uri/ =404; + } + + error_page 404 /404.html; + + access_log /var/log/nginx/{{PRODUCT_NAME}}-access.log; + error_log /var/log/nginx/{{PRODUCT_NAME}}-error.log; +} +CONF_TEMPLATE + +sed -i.bak "s/{{DOMAIN}}/${DOMAIN}/g" "${CONF_DIR}/${DOMAIN}.conf" +sed -i.bak "s/{{PRODUCT_NAME}}/${PRODUCT_NAME}/g" "${CONF_DIR}/${DOMAIN}.conf" +rm -f "${CONF_DIR}/${DOMAIN}.conf.bak" + +echo "✅ 产品站点 ${PRODUCT_NAME} 配置完成" +echo "" +echo "后续步骤:" +echo " 1. 添加 DNS A 记录: ${DOMAIN} -> 服务器IP" +echo " 2. 申请 SSL 证书: ./scripts/ssl-product-site.sh ${DOMAIN}" +echo " 3. 重载 Nginx: docker exec novalon-nginx-secure nginx -s reload" diff --git a/scripts/compare-fonts.py b/scripts/compare-fonts.py new file mode 100644 index 0000000..a70e8c7 --- /dev/null +++ b/scripts/compare-fonts.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""对比两个字体文件""" + +from fontTools.ttLib import TTFont +from fontTools.ttLib.tables import _h_m_t_x, _g_a_s_p + +original_hmtx = _h_m_t_x.table__h_m_t_x.decompile +def patched_hmtx(self, data, ttFont): + try: return original_hmtx(self, data, ttFont) + except: self.metrics = {} +_h_m_t_x.table__h_m_t_x.decompile = patched_hmtx + +original_gasp = _g_a_s_p.table__g_a_s_p.decompile +def patched_gasp(self, data, ttFont): + try: return original_gasp(self, data, ttFont) + except: self.gaspRanges = {} +_g_a_s_p.table__g_a_s_p.decompile = patched_gasp + +print('=== public/fonts/AoyagiReisho.ttf ===') +f1 = TTFont('public/fonts/AoyagiReisho.ttf') +cmap1 = f1.getBestCmap() +print('U+9060 遠:', 0x9060 in cmap1) +print('U+8fdc 远:', 0x8fdc in cmap1) +print('字形数:', len(f1.getGlyphOrder())) +print('GSUB:', 'GSUB' in f1) +f1.close() + +print() +print('=== src/app/fonts/AoyagiReisho.ttf ===') +f2 = TTFont('src/app/fonts/AoyagiReisho.ttf') +cmap2 = f2.getBestCmap() +print('U+9060 遠:', 0x9060 in cmap2) +print('U+8fdc 远:', 0x8fdc in cmap2) +print('字形数:', len(f2.getGlyphOrder())) +print('GSUB:', 'GSUB' in f2) +f2.close() diff --git a/scripts/font-to-svg-path.py b/scripts/font-to-svg-path.py new file mode 100644 index 0000000..d630433 --- /dev/null +++ b/scripts/font-to-svg-path.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""将青柳隷書字体中的文字转换为 SVG 路径""" + +from fontTools.ttLib import TTFont +from fontTools.ttLib.tables import _h_m_t_x, _g_a_s_p +import os + +# 修补表解析 +original_hmtx = _h_m_t_x.table__h_m_t_x.decompile +def patched_hmtx(self, data, ttFont): + try: return original_hmtx(self, data, ttFont) + except: self.metrics = {} +_h_m_t_x.table__h_m_t_x.decompile = patched_hmtx + +original_gasp = _g_a_s_p.table__g_a_s_p.decompile +def patched_gasp(self, data, ttFont): + try: return original_gasp(self, data, ttFont) + except: self.gaspRanges = {} +_g_a_s_p.table__g_a_s_p.decompile = patched_gasp + +def get_glyph_path(font, char): + """获取字符的 SVG 路径""" + cmap = font.getBestCmap() + codepoint = ord(char) + + if codepoint not in cmap: + print(f"警告: 字符 '{char}' (U+{codepoint:04X}) 不在字体中") + return None, None + + glyph_name = cmap[codepoint] + + # 获取 glyf 表 + glyf_table = font['glyf'] + glyph = glyf_table[glyph_name] + + # 获取度量 + hmtx = font['hmtx'] + advance_width, lsb = hmtx[glyph_name] + + # 获取边界框 + if hasattr(glyph, 'xMin') and glyph.xMin is not None: + bbox = (glyph.xMin, glyph.yMin, glyph.xMax, glyph.yMax) + else: + bbox = (0, 0, advance_width, 1000) + + # 获取字形轮廓 + try: + coords, endPts, flags = glyph.getCoordinates(glyf_table) + except: + print(f" 无法获取轮廓: {glyph_name}") + return None, None + + # 构建 SVG 路径 + path_parts = [] + start_idx = 0 + + for end_pt in endPts: + contour_coords = coords[start_idx:end_pt + 1] + contour_flags = flags[start_idx:end_pt + 1] + + if len(contour_coords) > 0: + path_parts.append(f"M {contour_coords[0][0]:.2f} {-contour_coords[0][1]:.2f}") + + for i in range(1, len(contour_coords)): + x, y = contour_coords[i] + path_parts.append(f"L {x:.2f} {-y:.2f}") + + path_parts.append("Z") + + start_idx = end_pt + 1 + + return " ".join(path_parts), {'advance': advance_width, 'lsb': lsb, 'bbox': bbox} + +# 加载字体 +font_path = 'public/fonts/AoyagiReisho.ttf' +font = TTFont(font_path) + +print("=" * 60) +print("青柳隷書 字形路径提取") +print("=" * 60) + +chars = ['睿', '新', '致', '遠'] +glyphs_data = [] + +for char in chars: + print(f"\n字符: {char} (U+{ord(char):04X})") + path, metrics = get_glyph_path(font, char) + if path and metrics: + print(f" Advance: {metrics['advance']}, LSB: {metrics['lsb']}") + print(f" BBox: {metrics['bbox']}") + print(f" Path length: {len(path)} chars") + glyphs_data.append({ + 'char': char, + 'path': path, + 'metrics': metrics + }) + +font.close() + +# 生成 SVG +print("\n" + "=" * 60) +print("生成 SVG 文件...") +print("=" * 60) + +# 计算总宽度 +total_width = sum(g['metrics']['advance'] for g in glyphs_data) +scale = 48 / 1000 # 缩放因子 + +svg_paths = [] +x_offset = 0 + +for g in glyphs_data: + m = g['metrics'] + # 计算字符居中偏移 + char_width = m['advance'] * scale + path = g['path'] + + # 缩放路径 + scaled_path = path + for coord in [('M', 'L')]: + pass # 路径已经是正确的格式 + + svg_paths.append(f''' + + + ''') + + x_offset += char_width + +print(f"\n总宽度: {total_width * scale:.2f}px") +print("\nSVG 路径组:") +print("\n".join(svg_paths[:2])) +print("...") diff --git a/scripts/generate-font-subset.py b/scripts/generate-font-subset.py new file mode 100644 index 0000000..bd37cba --- /dev/null +++ b/scripts/generate-font-subset.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""生成包含繁体'遠'的字体子集""" + +from fontTools.ttLib import TTFont +from fontTools.ttLib.tables import _h_m_t_x, _g_a_s_p +from fontTools.subset import Subsetter, Options + +# 修补表解析以跳过损坏的数据 +original_hmtx = _h_m_t_x.table__h_m_t_x.decompile +def patched_hmtx(self, data, ttFont): + try: + return original_hmtx(self, data, ttFont) + except: + self.metrics = {} +_h_m_t_x.table__h_m_t_x.decompile = patched_hmtx + +original_gasp = _g_a_s_p.table__g_a_s_p.decompile +def patched_gasp(self, data, ttFont): + try: + return original_gasp(self, data, ttFont) + except: + self.gaspRanges = {} +_g_a_s_p.table__g_a_s_p.decompile = patched_gasp + +# 加载字体 +font = TTFont('src/app/fonts/AoyagiReisho.ttf') + +# 删除损坏的表 +for t in ['vmtx', 'gasp', 'VORG', 'mort', 'morx']: + if t in font: + del font[t] + print(f'Deleted table: {t}') + +# 创建子集器 +subsetter = Subsetter() +options = Options() +options.drop_tables = ['gasp', 'vmtx', 'VORG', 'mort', 'morx', 'GSUB', 'GPOS', 'GDEF'] +subsetter.options = options + +# 目标字符: 睿(0x777f), 新(0x65b0), 致(0x81f4), 遠(0x9060), 空格(0x20) +unicodes = [0x20, 0x777f, 0x65b0, 0x81f4, 0x9060] +print(f'Target Unicode: {[hex(u) for u in unicodes]}') + +subsetter.populate(unicodes=unicodes) + +# 执行子集化 +try: + subsetter.subset(font) +except Exception as e: + print(f'Warning during subsetting: {e}') + +# 保存 +output_path = 'src/app/fonts/AoyagiReisho-subset.ttf' +font.save(output_path) +font.close() +print(f'Saved to: {output_path}') + +# 验证 +verify_font = TTFont(output_path) +cmap = verify_font.getBestCmap() +chars = [chr(k) for k in sorted(cmap.keys())] +codes = [hex(k) for k in sorted(cmap.keys())] +print(f'Subset characters: {chars}') +print(f'Unicode codes: {codes}') +print(f'Contains U+9060 (遠): {0x9060 in cmap}') +verify_font.close() diff --git a/scripts/generate-logo-svg.py b/scripts/generate-logo-svg.py new file mode 100644 index 0000000..47917e5 --- /dev/null +++ b/scripts/generate-logo-svg.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""将青柳隷書字体中的文字转换为 SVG 路径并生成 logo""" + +from fontTools.ttLib import TTFont +from fontTools.ttLib.tables import _h_m_t_x, _g_a_s_p +import os + +# 修补表解析 +original_hmtx = _h_m_t_x.table__h_m_t_x.decompile +def patched_hmtx(self, data, ttFont): + try: return original_hmtx(self, data, ttFont) + except: self.metrics = {} +_h_m_t_x.table__h_m_t_x.decompile = patched_hmtx + +original_gasp = _g_a_s_p.table__g_a_s_p.decompile +def patched_gasp(self, data, ttFont): + try: return original_gasp(self, data, ttFont) + except: self.gaspRanges = {} +_g_a_s_p.table__g_a_s_p.decompile = patched_gasp + +def get_glyph_path(font, char): + """获取字符的 SVG 路径""" + cmap = font.getBestCmap() + codepoint = ord(char) + + if codepoint not in cmap: + return None, None + + glyph_name = cmap[codepoint] + glyf_table = font['glyf'] + glyph = glyf_table[glyph_name] + hmtx = font['hmtx'] + advance_width, lsb = hmtx[glyph_name] + + try: + coords, endPts, flags = glyph.getCoordinates(glyf_table) + except: + return None, None + + path_parts = [] + start_idx = 0 + + for end_pt in endPts: + contour_coords = coords[start_idx:end_pt + 1] + if len(contour_coords) > 0: + path_parts.append(f"M {contour_coords[0][0]:.2f} {-contour_coords[0][1]:.2f}") + for i in range(1, len(contour_coords)): + x, y = contour_coords[i] + path_parts.append(f"L {x:.2f} {-y:.2f}") + path_parts.append("Z") + start_idx = end_pt + 1 + + return " ".join(path_parts), {'advance': advance_width, 'lsb': lsb} + +# 加载字体 +font = TTFont('public/fonts/AoyagiReisho.ttf') + +chars = ['睿', '新', '致', '遠'] +glyphs_data = [] + +for char in chars: + path, metrics = get_glyph_path(font, char) + if path and metrics: + glyphs_data.append({'char': char, 'path': path, 'metrics': metrics}) + +font.close() + +# 生成主标题 SVG 路径 +scale = 48 / 1000 +total_width = sum(g['metrics']['advance'] for g in glyphs_data) * scale + +svg_title_paths = [] +x_offset = 0 +for g in glyphs_data: + svg_title_paths.append(f''' + + ''') + x_offset += g['metrics']['advance'] * scale + +# 生成印章内文字 (较小尺寸) +scale_seal = 26 / 1000 + +# 睿新 +svg_seal_line1 = [] +x_offset = 0 +for char in ['睿', '新']: + g = next((x for x in glyphs_data if x['char'] == char), None) + if g: + svg_seal_line1.append(f''' + + ''') + x_offset += g['metrics']['advance'] * scale_seal + +# 致遠 +svg_seal_line2 = [] +x_offset = 0 +for char in ['致', '遠']: + g = next((x for x in glyphs_data if x['char'] == char), None) + if g: + svg_seal_line2.append(f''' + + ''') + x_offset += g['metrics']['advance'] * scale_seal + +# 计算印章文字居中偏移 +line1_width = sum(g['metrics']['advance'] for g in glyphs_data if g['char'] in ['睿', '新']) * scale_seal +line2_width = sum(g['metrics']['advance'] for g in glyphs_data if g['char'] in ['致', '遠']) * scale_seal +seal_center = 43 # 印章中心 x 坐标 +line1_x = seal_center - line1_width / 2 +line2_x = seal_center - line2_width / 2 + +# 生成完整 SVG +svg_content = f''' + + + + + + + + + + + + + + + + +{chr(10).join(svg_seal_line1)} + + + +{chr(10).join(svg_seal_line2)} + + + + + + + +{chr(10).join(svg_title_paths)} + + + NOVALON + +''' + +# 写入文件 +with open('public/logo.svg', 'w', encoding='utf-8') as f: + f.write(svg_content) + +print("✅ 已生成 public/logo.svg") + +# 生成白色版本 (logo-white.svg) +svg_white = svg_content.replace('fill="#C41E3A"', 'fill="currentColor"') +with open('public/logo-white.svg', 'w', encoding='utf-8') as f: + f.write(svg_white) + +print("✅ 已生成 public/logo-white.svg") +print(f"\n标题总宽度: {total_width:.2f}px") diff --git a/scripts/ssl-product-site.sh b/scripts/ssl-product-site.sh new file mode 100755 index 0000000..3b1c585 --- /dev/null +++ b/scripts/ssl-product-site.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +DOMAIN=$1 + +if [ -z "$DOMAIN" ]; then + echo "用法: $0 .novalon.cn" + echo "示例: $0 product-a.novalon.cn" + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NGINX_DIR="$(dirname "$SCRIPT_DIR")" +CERTBOT_DIR="/home/novalon/docker-app/certbot" +SSL_DIR="${NGINX_DIR}/ssl" + +mkdir -p "${SSL_DIR}/${DOMAIN}" + +echo "正在为 ${DOMAIN} 申请 SSL 证书..." +echo "" + +docker run --rm \ + -v "${CERTBOT_DIR}:/var/www/certbot" \ + -v "/etc/letsencrypt:/etc/letsencrypt" \ + certbot/certbot certonly \ + --webroot \ + --webroot-path /var/www/certbot \ + -d "${DOMAIN}" \ + --email admin@novalon.cn \ + --agree-tos \ + --no-eff-email + +if [ $? -eq 0 ]; then + echo "" + echo "证书申请成功,正在复制到 Nginx SSL 目录..." + + cp "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" "${SSL_DIR}/${DOMAIN}/" + cp "/etc/letsencrypt/live/${DOMAIN}/privkey.pem" "${SSL_DIR}/${DOMAIN}/" + + echo "" + echo "✅ ${DOMAIN} 证书申请完成" + echo "" + echo "后续步骤:" + echo " 1. 验证配置: docker exec novalon-nginx-secure nginx -t" + echo " 2. 重载 Nginx: docker exec novalon-nginx-secure nginx -s reload" + echo " 3. 验证访问: curl -I https://${DOMAIN}" +else + echo "" + echo "❌ 证书申请失败" + echo "" + echo "请检查:" + echo " 1. DNS 解析是否正确: ${DOMAIN} -> 服务器IP" + echo " 2. Nginx 配置是否正确加载" + echo " 3. certbot 目录权限是否正确" + exit 1 +fi diff --git a/scripts/verify-font.py b/scripts/verify-font.py new file mode 100644 index 0000000..4518ca1 --- /dev/null +++ b/scripts/verify-font.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""验证字体子集与原始字体的字形一致性""" + +from fontTools.ttLib import TTFont +from fontTools.ttLib.tables import _h_m_t_x, _g_a_s_p +import os + +# 修补表解析 +original_hmtx = _h_m_t_x.table__h_m_t_x.decompile +def patched_hmtx(self, data, ttFont): + try: return original_hmtx(self, data, ttFont) + except: self.metrics = {} +_h_m_t_x.table__h_m_t_x.decompile = patched_hmtx + +original_gasp = _g_a_s_p.table__g_a_s_p.decompile +def patched_gasp(self, data, ttFont): + try: return original_gasp(self, data, ttFont) + except: self.gaspRanges = {} +_g_a_s_p.table__g_a_s_p.decompile = patched_gasp + +base = 'src/app/fonts' + +# 加载字体 +original = TTFont(f'{base}/AoyagiReisho.ttf') +subset = TTFont(f'{base}/AoyagiReisho-subset.ttf') + +print("=" * 50) +print("字体对比验证") +print("=" * 50) + +# 文件大小 +orig_size = os.path.getsize(f'{base}/AoyagiReisho.ttf') +sub_size = os.path.getsize(f'{base}/AoyagiReisho-subset.ttf') +print(f"\n原始字体大小: {orig_size / 1024:.1f} KB ({orig_size} bytes)") +print(f"子集字体大小: {sub_size / 1024:.1f} KB ({sub_size} bytes)") + +# CMAP 对比 +orig_cmap = original.getBestCmap() +sub_cmap = subset.getBestCmap() + +target_chars = [0x20, 0x777f, 0x65b0, 0x81f4, 0x9060] +char_names = {0x20: '空格', 0x777f: '睿', 0x65b0: '新', 0x81f4: '致', 0x9060: '遠'} + +print("\n字符映射对比:") +for code in target_chars: + name = char_names[code] + orig_glyph = orig_cmap.get(code, 'MISSING') + sub_glyph = sub_cmap.get(code, 'MISSING') + match = "✓" if orig_glyph == sub_glyph else "✗" + print(f" U+{code:04X} ({name}): 原始={orig_glyph}, 子集={sub_glyph} {match}") + +# 字形数量 +print(f"\n字形数量:") +print(f" 原始: {len(original.getGlyphOrder())}") +print(f" 子集: {len(subset.getGlyphOrder())}") + +# 表对比 +print("\n字体表:") +orig_tables = set(original.keys()) +sub_tables = set(subset.keys()) +print(f" 原始表: {sorted(orig_tables)}") +print(f" 子集表: {sorted(sub_tables)}") + +original.close() +subset.close() + +print("\n" + "=" * 50) diff --git a/sites/.gitkeep b/sites/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/app/(marketing)/about/page.test.tsx b/src/app/(marketing)/about/page.test.tsx index 4c53517..2d774b8 100644 --- a/src/app/(marketing)/about/page.test.tsx +++ b/src/app/(marketing)/about/page.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, jest, beforeAll } from '@jest/globals'; +import { describe, it, expect, jest } from '@jest/globals'; import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; @@ -35,11 +35,13 @@ jest.mock('framer-motion', () => ({ })); jest.mock('next/link', () => { - return ({ children, href, ...props }: any) => ( + const MockLink = ({ children, href, ...props }: any) => ( {children} ); + MockLink.displayName = 'MockLink'; + return MockLink; }); jest.mock('lucide-react', () => ({ diff --git a/src/app/(marketing)/products/[id]/page.test.tsx b/src/app/(marketing)/products/[id]/page.test.tsx index d42ecfc..14b093f 100644 --- a/src/app/(marketing)/products/[id]/page.test.tsx +++ b/src/app/(marketing)/products/[id]/page.test.tsx @@ -11,28 +11,13 @@ jest.mock('next/navigation', () => ({ })); jest.mock('next/link', () => { - return ({ children, href }: { children: React.ReactNode; href: string }) => { + const MockLink = ({ children, href }: { children: React.ReactNode; href: string }) => { return {children}; }; + MockLink.displayName = 'MockLink'; + return MockLink; }); -const mockProduct = { - id: 'test-product', - title: '测试产品', - category: '企业软件', - description: '这是测试产品描述', - overview: '这是测试产品概述', - features: ['功能1', '功能2'], - benefits: ['优势1', '优势2'], - process: ['步骤1', '步骤2'], - specs: ['规格1', '规格2'], - pricing: { - base: '¥10,000/年', - standard: '¥30,000/年', - enterprise: '定制', - }, -}; - jest.mock('@/lib/constants', () => ({ PRODUCTS: [ { diff --git a/src/app/fonts/AoyagiReisho-subset.ttf b/src/app/fonts/AoyagiReisho-subset.ttf index 12d36fa..738dc99 100644 Binary files a/src/app/fonts/AoyagiReisho-subset.ttf and b/src/app/fonts/AoyagiReisho-subset.ttf differ diff --git a/src/app/globals.css b/src/app/globals.css index eb9ead3..4add478 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -203,6 +203,24 @@ outline: none; } + /* 马善政行书体 - 用于红色关键词高亮 */ + .font-calligraphy { + font-family: var(--font-ma-shan-zheng), 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif !important; + font-weight: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + } + + /* 青柳隷書 - 仅用于品牌标题"睿新致遠" */ + .font-brand { + font-family: var(--font-aoyagi-reisho), 'Aoyagi Reisho', 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif !important; + font-weight: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + } + ::selection { background-color: var(--color-text-primary); color: var(--color-bg-primary); @@ -320,15 +338,6 @@ background-clip: text; } - /* 马善政行书体 - 用于红色关键词高亮 */ - .font-calligraphy { - font-family: var(--font-ma-shan-zheng), 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif !important; - font-weight: normal; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizeLegibility; - } - /* 发光效果 */ .bg-glow-red { background: radial-gradient(circle at center, var(--color-accent-red-glow) 0%, transparent 70%); @@ -369,15 +378,6 @@ } } -/* 青柳隷書 - 仅用于品牌标题"睿新致遠" */ -@utility font-brand { - font-family: var(--font-aoyagi-reisho), 'Aoyagi Reisho', 'Ma Shan Zheng', 'ZCOOL XiaoWei', 'STKaiti', 'KaiTi', serif !important; - font-weight: normal; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizeLegibility; -} - @keyframes slideIn { from { opacity: 0; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8aaf629..2d1548e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,6 +8,7 @@ import { GoogleAnalytics } from "@/components/analytics/GoogleAnalytics"; import { CookieConsent } from "@/components/analytics/CookieConsent"; import { PerformanceTracker } from "@/components/analytics/PerformanceTracker"; import { OutboundLinkTracker } from "@/components/analytics/OutboundLinkTracker"; +import { ScrollDepthTracker } from "@/components/analytics/ScrollDepthTracker"; import { OrganizationSchema, WebsiteSchema } from "@/components/seo/structured-data"; import { MobileTabBar } from "@/components/layout/mobile-tab-bar"; import { ErrorBoundary } from "@/components/ui/error-boundary"; @@ -141,6 +142,7 @@ export default function RootLayout({ + {children} diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 0c4fa01..e74f829 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -138,13 +138,95 @@ export default function PrivacyPolicyPage() {
-

七、如何联系我们

+

七、Cookie 和网站分析工具

+ +

7.1 Cookie 使用说明

+

+ 我们使用 Cookie 和类似技术来提供、保护和改进我们的服务。Cookie 是存储在您设备上的小型文本文件,帮助我们识别您的设备、记住您的偏好设置。 +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Cookie 类型用途持续时间是否必需
必要 Cookie网站基本功能运行会话期间
分析 Cookie了解网站使用情况,改进服务14个月
营销 Cookie个性化广告(当前未使用)-
+
+ +

7.2 Google Analytics 使用说明

+

+ 我们使用 Google Analytics 4(由 Google LLC 提供)分析网站使用情况,帮助我们了解访客如何使用网站,从而改进用户体验。 +

+

+ 收集的数据包括: +

+
    +
  • 访问的页面和停留时间
  • +
  • 设备类型、浏览器类型
  • +
  • 地理位置(国家/城市级别,IP 地址已匿名化)
  • +
  • 访问来源(直接访问、搜索引擎、外部链接)
  • +
+

+ 我们已采取的保护措施: +

+
    +
  • IP 地址匿名化
  • +
  • 数据保留期限设为 14 个月
  • +
  • 禁用广告个性化功能
  • +
  • 禁用 Google 信号(不进行跨设备追踪)
  • +
  • 不与 Google 其他服务共享数据用于广告目的
  • +
+ +

7.3 您的选择

+
    +
  • 您可以在首次访问时选择接受或拒绝分析 Cookie
  • +
  • 您可以点击页面右下角的“Cookie 设置”按钮随时更改偏好
  • +
  • 您可以通过浏览器设置删除或阻止 Cookie(可能影响网站功能)
  • +
+ +

7.4 数据删除请求

+

+ 如您希望删除我们持有的您的个人数据,或撤回您的同意,请通过以下方式联系我们: +

+
    +
  • 隐私邮箱:privacy@novalon.cn
  • +
  • 联系地址:中国四川省成都市龙泉驿区幸福路12号
  • +
+
+ +
+

八、如何联系我们

如果您对本隐私政策有任何疑问、意见或建议,或需要行使您的权利,请通过以下方式与我们联系:

  • 公司名称:四川睿新致远科技有限公司
  • 联系邮箱:contact@novalon.cn
  • +
  • 隐私邮箱:privacy@novalon.cn
  • 联系地址:中国四川省成都市龙泉驿区幸福路12号
diff --git a/src/components/analytics/CookieConsent.tsx b/src/components/analytics/CookieConsent.tsx index a8e399a..7e06037 100644 --- a/src/components/analytics/CookieConsent.tsx +++ b/src/components/analytics/CookieConsent.tsx @@ -1,48 +1,91 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { updateConsent, trackButtonClick } from '@/lib/analytics'; +import { useState, useEffect, useCallback } from 'react'; +import { + updateConsentDetailed, + trackButtonClick, + CookiePreferences, + getStoredPreferences, + storePreferences, + getDefaultPreferences, +} from '@/lib/analytics'; import { motion, AnimatePresence } from 'framer-motion'; -const CONSENT_KEY = 'ga_consent'; +const LEGACY_CONSENT_KEY = 'ga_consent'; export function CookieConsent() { const [showConsent, setShowConsent] = useState(false); + const [showSettings, setShowSettings] = useState(false); const [isAnimating, setIsAnimating] = useState(false); + const [preferences, setPreferences] = useState(getDefaultPreferences()); useEffect(() => { - const consent = localStorage.getItem(CONSENT_KEY); - if (!consent) { - const timer = setTimeout(() => { - setShowConsent(true); - }, 2000); - return () => clearTimeout(timer); - } else if (consent === 'granted') { - updateConsent(true); + const stored = getStoredPreferences(); + if (stored) { + updateConsentDetailed(stored); + } else { + const legacyConsent = localStorage.getItem(LEGACY_CONSENT_KEY); + if (legacyConsent) { + const migratedPrefs: CookiePreferences = { + necessary: true, + analytics: legacyConsent === 'granted', + marketing: false, + timestamp: Date.now(), + }; + storePreferences(migratedPrefs); + updateConsentDetailed(migratedPrefs); + localStorage.removeItem(LEGACY_CONSENT_KEY); + } else { + const timer = setTimeout(() => { + setShowConsent(true); + }, 2000); + return () => clearTimeout(timer); + } } return undefined; }, []); - const handleAccept = () => { + const handleSavePreferences = useCallback((prefs: CookiePreferences) => { setIsAnimating(true); - localStorage.setItem(CONSENT_KEY, 'granted'); - updateConsent(true); - trackButtonClick('accept_cookies', 'consent_banner'); + const finalPrefs = { ...prefs, timestamp: Date.now() }; + storePreferences(finalPrefs); + updateConsentDetailed(finalPrefs); + trackButtonClick('save_cookie_preferences', 'consent_banner'); setTimeout(() => { setShowConsent(false); + setShowSettings(false); setIsAnimating(false); }, 300); + }, []); + + const handleAcceptAll = () => { + const allAccepted: CookiePreferences = { + necessary: true, + analytics: true, + marketing: false, + timestamp: Date.now(), + }; + handleSavePreferences(allAccepted); + trackButtonClick('accept_all_cookies', 'consent_banner'); }; - const handleDecline = () => { - setIsAnimating(true); - localStorage.setItem(CONSENT_KEY, 'denied'); - updateConsent(false); - trackButtonClick('decline_cookies', 'consent_banner'); - setTimeout(() => { - setShowConsent(false); - setIsAnimating(false); - }, 300); + const handleRejectAll = () => { + const allRejected: CookiePreferences = { + necessary: true, + analytics: false, + marketing: false, + timestamp: Date.now(), + }; + handleSavePreferences(allRejected); + trackButtonClick('reject_all_cookies', 'consent_banner'); + }; + + const handleTogglePreference = (key: 'analytics' | 'marketing') => { + setPreferences((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + const handleSaveCustom = () => { + handleSavePreferences(preferences); }; return ( @@ -56,40 +99,156 @@ export function CookieConsent() { className="fixed bottom-16 md:bottom-0 left-0 right-0 z-[9998] bg-white border-t border-gray-200 shadow-lg" >
-
-
-

- 我们使用 Cookie 和类似技术来改善您的体验、分析网站流量并提供个性化内容。 - 继续使用即表示您同意我们的{' '} - +

+
+ + + +
-
- - + ) : ( +
+
+

Cookie 偏好设置

+ +
+ +
+
+ +
+
+ 必要 Cookie + 始终启用 +
+

+ 网站正常运行所必需,无法禁用 +

+
+
+ +
+ handleTogglePreference('analytics')} + className="mt-1 h-4 w-4 rounded border-gray-300 text-[#C41E3A] focus:ring-[#C41E3A] cursor-pointer" + aria-label="分析 Cookie" + /> +
+ 分析 Cookie +

+ 帮助我们了解访客如何使用网站,改进用户体验 +

+
+
+ +
+ handleTogglePreference('marketing')} + className="mt-1 h-4 w-4 rounded border-gray-300 text-[#C41E3A] focus:ring-[#C41E3A] cursor-pointer" + aria-label="营销 Cookie" + /> +
+ 营销 Cookie +

+ 用于个性化广告(当前未使用) +

+
+
+
+ +
+ + +
-
+ )}
)} ); } + +export function CookieSettingsButton() { + const [isVisible] = useState(() => { + if (typeof window === 'undefined') {return false;} + return !!getStoredPreferences(); + }); + + if (!isVisible) {return null;} + + return ( + + ); +} diff --git a/src/components/analytics/GoogleAnalytics.tsx b/src/components/analytics/GoogleAnalytics.tsx index 0f83e9a..4fed77b 100644 --- a/src/components/analytics/GoogleAnalytics.tsx +++ b/src/components/analytics/GoogleAnalytics.tsx @@ -48,7 +48,7 @@ function GoogleAnalyticsContent() { gtag('config', '${GA_MEASUREMENT_ID}', { send_page_view: false, anonymize_ip: true, - allow_google_signals: true, + allow_google_signals: false, allow_ad_personalization_signals: false, cookie_flags: 'SameSite=None;Secure' }); diff --git a/src/components/analytics/ScrollDepthTracker.tsx b/src/components/analytics/ScrollDepthTracker.tsx new file mode 100644 index 0000000..a7b879c --- /dev/null +++ b/src/components/analytics/ScrollDepthTracker.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useEffect, useRef, useCallback } from 'react'; +import { usePathname } from 'next/navigation'; +import { trackScrollDepth } from '@/lib/analytics'; + +const MILESTONES = [25, 50, 75, 100] as const; + +export function ScrollDepthTracker() { + const trackedRef = useRef>(new Set()); + const pathname = usePathname(); + + const handleScroll = useCallback(() => { + const scrollTop = window.scrollY; + const docHeight = document.documentElement.scrollHeight - window.innerHeight; + + if (docHeight <= 0) { + return; + } + + const scrollPercent = Math.round((scrollTop / docHeight) * 100); + + MILESTONES.forEach((milestone) => { + if (scrollPercent >= milestone && !trackedRef.current.has(milestone)) { + trackedRef.current.add(milestone); + trackScrollDepth(milestone); + } + }); + }, []); + + useEffect(() => { + trackedRef.current = new Set(); + + window.addEventListener('scroll', handleScroll, { passive: true }); + + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, [pathname, handleScroll]); + + return null; +} diff --git a/src/components/layout/mobile-tab-bar.test.tsx b/src/components/layout/mobile-tab-bar.test.tsx index 06d4d70..cc7d3dd 100644 --- a/src/components/layout/mobile-tab-bar.test.tsx +++ b/src/components/layout/mobile-tab-bar.test.tsx @@ -5,16 +5,23 @@ import { MobileTabBar } from './mobile-tab-bar'; jest.mock('next/navigation', () => ({ usePathname: () => '/', + useSearchParams: () => new URLSearchParams(), })); jest.mock('framer-motion', () => ({ motion: { - div: ({ children, ...props }: any) =>
{children}
, + div: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( +
{children}
+ ), }, })); jest.mock('next/link', () => { - return ({ children, href }: any) => {children}; + const MockLink = ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ); + MockLink.displayName = 'MockLink'; + return MockLink; }); describe('MobileTabBar', () => { @@ -87,7 +94,7 @@ describe('MobileTabBar', () => { it('should have correct href for contact', () => { render(); const contactLink = screen.getByText('联系').closest('a'); - expect(contactLink).toHaveAttribute('href', '/#contact'); + expect(contactLink).toHaveAttribute('href', '/contact'); }); }); diff --git a/src/components/ui/error-boundary.test.tsx b/src/components/ui/error-boundary.test.tsx index e81a073..982566b 100644 --- a/src/components/ui/error-boundary.test.tsx +++ b/src/components/ui/error-boundary.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, jest } from '@jest/globals'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import { ErrorBoundary } from './error-boundary'; diff --git a/src/components/ui/input.test.tsx b/src/components/ui/input.test.tsx index 913b9ee..e4d62e1 100644 --- a/src/components/ui/input.test.tsx +++ b/src/components/ui/input.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { describe, it, expect, jest, beforeEach } from '@jest/globals'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import { Input } from './input'; diff --git a/src/components/ui/loading-skeleton.test.tsx b/src/components/ui/loading-skeleton.test.tsx index 53068f1..f1b84be 100644 --- a/src/components/ui/loading-skeleton.test.tsx +++ b/src/components/ui/loading-skeleton.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect } from '@jest/globals'; -import { render, screen } from '@testing-library/react'; +import { render } from '@testing-library/react'; import '@testing-library/jest-dom'; import { Skeleton, diff --git a/src/components/ui/ripple-button.test.tsx b/src/components/ui/ripple-button.test.tsx index ae7e346..3cee351 100644 --- a/src/components/ui/ripple-button.test.tsx +++ b/src/components/ui/ripple-button.test.tsx @@ -34,8 +34,6 @@ jest.mock('framer-motion', () => ({ AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, })); -const { RippleButton } = jest.requireActual('./ripple-button'); - describe('RippleButton', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/src/components/ui/sheet.test.tsx b/src/components/ui/sheet.test.tsx index 9ad5e46..5b404d1 100644 --- a/src/components/ui/sheet.test.tsx +++ b/src/components/ui/sheet.test.tsx @@ -9,7 +9,6 @@ import { SheetFooter, SheetTitle, SheetDescription, - SheetClose, } from './sheet'; describe('Sheet', () => { diff --git a/src/components/ui/textarea.test.tsx b/src/components/ui/textarea.test.tsx index 7df24ea..bcf4deb 100644 --- a/src/components/ui/textarea.test.tsx +++ b/src/components/ui/textarea.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { describe, it, expect, jest, beforeEach } from '@jest/globals'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import { Textarea } from './textarea'; diff --git a/src/hooks/use-focus-trap.test.ts b/src/hooks/use-focus-trap.test.ts index 1de8939..1c50a7a 100644 --- a/src/hooks/use-focus-trap.test.ts +++ b/src/hooks/use-focus-trap.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, jest } from '@jest/globals'; -import { renderHook, act } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import { useFocusTrap } from './use-focus-trap'; describe('useFocusTrap', () => { diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index b36a7e7..2709eaa 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -149,6 +149,13 @@ export const trackProductView = (productId: string, productName: string) => { } }; +export interface CookiePreferences { + necessary: boolean; + analytics: boolean; + marketing: boolean; + timestamp: number; +} + export const updateConsent = (granted: boolean) => { if (typeof window !== 'undefined' && window.gtag) { window.gtag('consent', 'update', { @@ -157,3 +164,44 @@ export const updateConsent = (granted: boolean) => { }); } }; + +export const updateConsentDetailed = (preferences: CookiePreferences) => { + if (typeof window !== 'undefined' && window.gtag) { + window.gtag('consent', 'update', { + analytics_storage: preferences.analytics ? 'granted' : 'denied', + ad_storage: preferences.marketing ? 'granted' : 'denied', + functionality_storage: 'granted', + personalization_storage: preferences.marketing ? 'granted' : 'denied', + security_storage: 'granted', + }); + } +}; + +export const getStoredPreferences = (): CookiePreferences | null => { + if (typeof window === 'undefined') { + return null; + } + + try { + const stored = localStorage.getItem('cookie_preferences'); + if (stored) { + return JSON.parse(stored) as CookiePreferences; + } + } catch { + return null; + } + return null; +}; + +export const storePreferences = (preferences: CookiePreferences) => { + if (typeof window !== 'undefined') { + localStorage.setItem('cookie_preferences', JSON.stringify(preferences)); + } +}; + +export const getDefaultPreferences = (): CookiePreferences => ({ + necessary: true, + analytics: false, + marketing: false, + timestamp: Date.now(), +}); diff --git a/src/lib/animations.test.tsx b/src/lib/animations.test.tsx index f18dce0..a33ffdd 100644 --- a/src/lib/animations.test.tsx +++ b/src/lib/animations.test.tsx @@ -77,7 +77,7 @@ describe('Animation Variants', () => { it('should have correct transition configuration', async () => { const { inkVariants } = await import('./animations'); - const transition = inkVariants.visible.transition as any; + const transition = (inkVariants.visible as any)?.transition; expect(transition.duration).toBe(0.8); expect(transition.ease).toEqual([0.16, 1, 0.3, 1]); }); @@ -102,7 +102,7 @@ describe('Animation Variants', () => { it('should use spring animation', async () => { const { sealStampVariants } = await import('./animations'); - const transition = sealStampVariants.visible.transition as any; + const transition = (sealStampVariants.visible as any)?.transition; expect(transition.type).toBe('spring'); expect(transition.stiffness).toBe(300); expect(transition.damping).toBe(20); @@ -144,7 +144,7 @@ describe('Animation Variants', () => { describe('staggerContainerVariants', () => { it('should have staggerChildren configured', async () => { const { staggerContainerVariants } = await import('./animations'); - const transition = staggerContainerVariants.visible.transition as any; + const transition = (staggerContainerVariants.visible as any)?.transition; expect(transition.staggerChildren).toBe(0.1); expect(transition.delayChildren).toBe(0.1); }); @@ -412,7 +412,7 @@ describe('Animation Components', () => { const { GlitchText } = await import('./animations'); render(); const testElements = screen.getAllByText('Test'); - const container = testElements[0].closest('.glitch-class'); + const container = testElements[0]?.closest('.glitch-class'); expect(container).toBeInTheDocument(); }); }); diff --git a/src/lib/constants/navigation.ts b/src/lib/constants/navigation.ts index 97625a3..e6e08dc 100644 --- a/src/lib/constants/navigation.ts +++ b/src/lib/constants/navigation.ts @@ -12,5 +12,5 @@ export const NAVIGATION: NavigationItem[] = [ { id: 'cases', label: '成功案例', href: '/' }, { id: 'about', label: '关于我们', href: '/' }, { id: 'news', label: '新闻动态', href: '/' }, - { id: 'contact', label: '联系我们', href: '/contact' }, + { id: 'contact', label: '联系', href: '/contact' }, ];