diff --git a/.gitignore b/.gitignore index 7e89a46..dbd1a4e 100644 --- a/.gitignore +++ b/.gitignore @@ -276,6 +276,13 @@ e2e/storage-state.json trace.zip network-logs/ +# ============================================================ +# Task Planning Files +# ============================================================ +task_plan.md +progress.md +findings.md + # ============================================================ # IMPORTANT NOTES # ============================================================ diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 0000000..89ef546 --- /dev/null +++ b/.impeccable.md @@ -0,0 +1,341 @@ +# Novalon Website - 设计上下文 + +> 本文档定义了 Novalon Website 项目的设计原则、品牌定位和视觉规范,确保所有设计决策的一致性和连贯性。 + +--- + +## 设计上下文 + +### 用户画像 + +**主要用户群体:大型企业(500人以上)** + +**用户特征**: +- 企业决策者:CEO、CTO、CIO等高管层 +- 技术负责人:IT总监、技术架构师、项目经理 +- 采购决策者:采购总监、业务部门负责人 + +**决策场景**: +- 数字化转型战略规划阶段 +- 寻找可靠的技术合作伙伴 +- 评估供应商的专业能力和项目经验 +- 关注系统稳定性、安全性、可扩展性 +- 需要详细的技术方案和合规认证 + +**核心需求**: +- 信任感:需要看到专业能力和成功案例 +- 安全感:需要了解技术实力和安全保障 +- 确定性:需要清晰的服务流程和交付标准 +- 创新性:需要前沿的技术视野和解决方案 + +--- + +### 品牌个性 + +**品牌定位**:企业数字化转型服务商 + +**核心口号**:"智连未来,成长伙伴" + +**品牌个性关键词**: +1. **专业** - 展现深厚的技术积累和行业经验 +2. **可靠** - 传递稳定、可信、值得依赖的品牌形象 +3. **创新** - 体现前沿技术视野和持续创新能力 + +**品牌价值观**: +- 不是高高在上的"专家",而是并肩作战的"伙伴" +- 不是做完就跑的"卖家",而是长期陪伴的"同行者" +- 只做一件事:成为客户数字化转型路上信得过的成长伙伴 + +**情感目标**: +- 让用户感受到:专业、可信、有温度 +- 建立信任感:通过案例、数据、流程展示 +- 传递安全感:通过技术实力、安全保障、合规认证 +- 激发信心:通过创新方案、前沿视野、持续进化 + +--- + +### 视觉方向 + +**设计理念**:融合中国传统水墨画元素与现代科技感 + +**核心视觉元素**: + +#### 1. 色彩系统 + +**主色调 - 墨黑系(水墨画主色)**: +```css +--color-primary: #1C1C1C; /* 主色 */ +--color-primary-hover: #0A0A0A; /* 悬停色 */ +--color-primary-light: #3D3D3D; /* 浅色 */ +--color-primary-lighter: #F5F5F5; /* 更浅色 */ +``` + +**品牌色 - 朱砂红(印章红)**: +```css +--color-brand-primary: #C41E3A; /* 品牌主色 */ +--color-brand-primary-hover: #A01830; /* 品牌悬停色 */ +--color-brand-primary-light: #E04A68; /* 品牌浅色 */ +--color-brand-primary-bg: #FEF2F4; /* 品牌背景色 */ +``` + +**背景色系 - 宣纸白**: +```css +--color-bg-primary: #FFFFFF; /* 主背景 */ +--color-bg-secondary: #FFFBF5; /* 次背景(宣纸色) */ +--color-bg-tertiary: #F5F5F5; /* 三级背景 */ +--color-bg-hover: #EFEFEF; /* 悬停背景 */ +``` + +**文字色系 - 墨色层次**: +```css +--color-text-primary: #1C1C1C; /* 主文字 */ +--color-text-secondary: #3D3D3D; /* 次文字 */ +--color-text-tertiary: #4A4A4A; /* 三级文字 */ +--color-text-muted: #6B6B6B; /* 弱化文字 */ +``` + +#### 2. 字体系统 + +**中文字体**: +- **书法字体**:Aoyagi Reisho(青柳凉笙)- 用于品牌名称、标题装饰 +- **正文字体**:Noto Sans SC - 用于正文、UI元素 + +**英文字体**: +- **无衬线字体**:Geist Sans - 用于英文标题、正文 +- **等宽字体**:Geist Mono - 用于代码、技术内容 + +**字体应用原则**: +- 品牌名称"睿新致遠"使用书法字体,传递文化底蕴 +- 正文使用现代无衬线字体,确保可读性 +- 技术内容使用等宽字体,体现专业性 + +#### 3. 视觉特效 + +**水墨元素**: +- 水墨滴装饰(InkDrop) +- 水墨飞溅效果(InkSplash) +- 水墨背景(InkBackground) + +**科技元素**: +- 数据粒子流动(DataParticleFlow) +- 几何图形装饰(GeometricShapes) +- 渐变网格(GradientGrid) +- 科技网格流动(TechGridFlow) + +**动画效果**: +- 页面过渡动画(PageTransitions) +- 滚动动画(ScrollAnimations) +- 微交互效果(Hover、Click、Focus) +- 数字动画(AnimatedNumber) + +#### 4. 设计模式 + +**布局系统**: +- 响应式设计:桌面端、平板、移动端完美适配 +- 容器宽度:container-wide(最大1440px) +- 间距系统:基于4px基准的间距体系 + +**组件风格**: +- 卡片设计:圆角、阴影、边框 +- 按钮样式:填充、描边、幽灵按钮 +- 表单元素:清晰的输入框、下拉菜单 +- 导航系统:顶部导航、面包屑、移动端标签栏 + +**视觉层次**: +- 清晰的信息层次:标题 → 副标题 → 正文 → 辅助信息 +- 合理的视觉权重:通过字号、颜色、间距建立层次 +- 突出重点:使用品牌色、动画效果吸引注意力 + +--- + +### 设计原则 + +#### 1. 专业性优先 + +**原则描述**:所有设计决策必须服务于展现专业能力 + +**实施要点**: +- 使用清晰的信息架构,便于快速定位关键信息 +- 展示详细的技术方案、流程、案例 +- 提供完整的数据支撑(案例数量、客户规模、项目经验) +- 避免过度装饰,保持视觉简洁专业 + +**设计示例**: +- 服务详情页:展示完整的服务流程、技术栈、交付标准 +- 案例展示:包含客户背景、解决方案、实施效果、技术亮点 +- 关于我们:展示团队实力、资质认证、发展历程 + +#### 2. 信任感构建 + +**原则描述**:通过设计元素传递可靠、可信的品牌形象 + +**实施要点**: +- 展示真实案例和客户评价 +- 提供详细的公司信息和联系方式 +- 使用安全标识、认证徽章 +- 清晰的服务承诺和保障条款 + +**设计示例**: +- 首页:突出展示成功案例数量、客户规模 +- 联系页面:完整的公司信息、地址、电话、邮箱 +- 页脚:ICP备案、公安备案、版权信息 + +#### 3. 创新性表达 + +**原则描述**:在保持专业性的同时,展现创新能力和前沿视野 + +**实施要点**: +- 使用现代技术实现流畅的动画效果 +- 融合传统元素(水墨)与现代科技感 +- 展示前沿技术应用(AI、大数据、云计算) +- 持续优化用户体验和交互设计 + +**设计示例**: +- Hero区域:水墨背景 + 数据粒子流动效果 +- 服务介绍:使用3D效果展示技术架构 +- 新闻动态:展示最新的技术趋势和行业洞察 + +#### 4. 可访问性保障 + +**原则描述**:确保所有用户都能无障碍使用网站 + +**实施要点**: +- 遵循 WCAG 2.1 AA 标准 +- 色彩对比度:文本与背景对比度 ≥ 4.5:1 +- 键盘导航:所有交互元素可通过键盘访问 +- 屏幕阅读器支持:提供完整的 ARIA 标签 +- 减少动画:支持 prefers-reduced-motion 媒体查询 + +**设计示例**: +- 所有图片提供 alt 文本 +- 表单元素关联 label +- 焦点状态清晰可见 +- 色彩对比度检查通过 + +#### 5. 响应式优先 + +**原则描述**:确保所有设备上的体验一致性 + +**实施要点**: +- 移动端优先设计 +- 触摸友好的交互元素(最小触摸区域 44x44px) +- 自适应的布局和字体大小 +- 优化的移动端导航(标签栏、汉堡菜单) + +**设计示例**: +- 移动端:底部标签栏导航 +- 平板:侧边导航 + 内容区域 +- 桌面:顶部导航 + 完整布局 + +--- + +### 参考与反参考 + +**正面参考**: +- **阿里云官网**:企业级B2B网站的专业性和信任感 +- **腾讯云官网**:技术能力展示和案例呈现方式 +- **华为官网**:企业品牌形象和文化传递 + +**反参考**: +- 过度炫技的视觉效果(影响加载速度和可读性) +- 过于卡通化的设计风格(不符合企业级定位) +- 信息过载的页面布局(影响用户决策) + +--- + +### 技术实现规范 + +**前端技术栈**: +- Next.js 16(App Router) +- React 19 +- TypeScript +- Tailwind CSS 4 +- Framer Motion(动画) +- Three.js(3D效果) + +**设计工具**: +- Tailwind CSS:样式系统 +- CSS Variables:设计令牌 +- Framer Motion:动画库 +- Lucide React:图标库 + +**性能优化**: +- 图片优化:WebP/AVIF 格式,响应式图片 +- 代码分割:动态导入组件 +- 缓存策略:静态资源长期缓存 +- 预加载:关键资源预加载 + +--- + +### 质量保障 + +**代码质量**: +- ESLint:代码规范检查 +- TypeScript:类型安全 +- Prettier:代码格式化 +- Husky:Git Hooks + +**测试覆盖**: +- 单元测试:Jest +- 集成测试:Testing Library +- E2E测试:Playwright +- 可访问性测试:axe-core + +**CI/CD流水线**: +- 代码质量检查(Lint、Type Check) +- 单元测试和集成测试 +- E2E测试(分层测试) +- 安全扫描 +- Docker镜像构建 +- 自动化部署 + +--- + +## 使用指南 + +### 如何使用本文档 + +1. **新功能开发**:在设计新功能前,先查阅本文档,确保符合设计原则 +2. **设计评审**:使用本文档作为评审标准,检查设计决策是否一致 +3. **团队协作**:新成员加入时,阅读本文档快速了解设计方向 +4. **设计迭代**:定期回顾本文档,根据业务发展更新设计方向 + +### 设计决策流程 + +1. **明确目标**:确定设计目标是否服务于"专业、可靠、创新"的品牌个性 +2. **参考原则**:查阅设计原则,确保符合核心原则 +3. **视觉规范**:使用色彩系统、字体系统、组件库 +4. **技术实现**:遵循技术实现规范,确保性能和可维护性 +5. **质量验证**:通过测试和评审,确保质量达标 + +--- + +## 版本历史 + +| 版本 | 日期 | 变更内容 | 作者 | +|------|------|----------|------| +| 1.0 | 2026-03-27 | 初始版本,建立设计上下文 | 张翔 | + +--- + +## 维护说明 + +本文档是**活文档**,应随着项目发展持续更新: + +- **品牌升级**:更新品牌个性、视觉方向 +- **用户反馈**:根据用户反馈调整设计原则 +- **技术演进**:更新技术实现规范 +- **设计迭代**:记录设计决策的演变过程 + +**更新流程**: +1. 提出设计变更建议 +2. 团队讨论和评审 +3. 更新本文档 +4. 通知所有相关成员 +5. 在实际项目中验证 + +--- + +> **最后更新**:2026-03-27 +> **维护者**:张翔 +> **联系方式**:contact@novalon.cn diff --git a/.woodpecker.yml b/.woodpecker.yml index 29bc8bb..1285aa6 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -1,148 +1,401 @@ -pipeline: - e2e-tests: - image: node:18-alpine +# ============================================ +# Novalon Website - 全自动CI/CD工作流 +# ============================================ +# 发布策略:release分支发布 + main分支归档 +# +# 分支角色: +# - feature分支:开发新功能 +# - release分支:生产环境代码,合并后自动部署 +# - main分支:稳定代码归档,只读 +# +# 流水线阶段: +# 1. 代码质量检查 (lint, type-check, security) +# 2. 单元测试和集成测试 +# 3. E2E测试 (分层测试) +# 4. 构建Docker镜像 +# 5. 部署到生产环境 (release分支) +# 6. 归档到main分支 +# 7. 通知和监控 +# ============================================ + +# 全局环境变量 +variables: + - &node_image node:20-alpine + - &docker_image docker:24-cli + +# ============================================ +# 阶段1: 代码质量检查 +# ============================================ +steps: + # 1.1 Lint检查 + lint: + image: *node_image environment: - NODE_ENV: test - CI: true + NODE_ENV: development commands: - - cd e2e - npm ci - - npx playwright install --with-deps chromium - - npx playwright install --with-deps firefox - - npx playwright install --with-deps webkit - - npm run test:ci + - npm run lint when: event: - push - pull_request - branch: - - main - - develop - e2e-tests-smoke: - image: node:18-alpine + # 1.2 类型检查 + type-check: + image: *node_image + environment: + NODE_ENV: development + commands: + - npm ci + - npm run type-check + when: + event: + - push + - pull_request + + # 1.3 安全漏洞扫描 + security-scan: + image: *node_image + environment: + NODE_ENV: development + commands: + - npm ci + - npm audit --audit-level=moderate + when: + event: + - push + - pull_request + failure: ignore + + # ============================================ + # 阶段2: 单元测试和集成测试 + # ============================================ + unit-tests: + image: *node_image environment: NODE_ENV: test CI: true commands: - - cd e2e - npm ci - - npx playwright install --with-deps chromium + - npm run test:coverage:check + when: + event: + - push + - pull_request + + # ============================================ + # 阶段3: E2E测试 (分层测试) + # ============================================ + # 3.1 Smoke测试 (PR快速验证) + e2e-smoke: + image: mcr.microsoft.com/playwright:v1.48.0-jammy + environment: + NODE_ENV: test + CI: true + commands: + - npm ci + - cd e2e && npm ci + - npx playwright install chromium --with-deps - npm run test:smoke when: event: - - push - pull_request - e2e-tests-regression: - image: node:18-alpine + # 3.2 标准测试 (release分支) + e2e-standard: + image: mcr.microsoft.com/playwright:v1.48.0-jammy environment: NODE_ENV: test CI: true commands: - - cd e2e - npm ci - - npx playwright install --with-deps chromium + - cd e2e && npm ci + - npx playwright install chromium --with-deps - npm run test:tier:standard when: event: - push branch: - - main + - release + - release/* - e2e-tests-performance: - image: node:18-alpine + # 3.3 深度测试 (release分支) + e2e-deep: + image: mcr.microsoft.com/playwright:v1.48.0-jammy environment: NODE_ENV: test CI: true commands: - - cd e2e - npm ci - - npx playwright install --with-deps chromium + - cd e2e && npm ci + - npx playwright install chromium firefox webkit --with-deps + - npm run test:tier:deep + when: + event: + - push + branch: + - release + - release/* + + # 3.4 性能测试 (release分支) + e2e-performance: + image: mcr.microsoft.com/playwright:v1.48.0-jammy + environment: + NODE_ENV: test + CI: true + commands: + - npm ci + - cd e2e && npm ci + - npx playwright install chromium --with-deps - npm run test:performance when: event: - push branch: - - main + - release + - release/* - e2e-tests-responsive: - image: node:18-alpine + # 3.5 可访问性测试 (release分支) + e2e-accessibility: + image: mcr.microsoft.com/playwright:v1.48.0-jammy environment: NODE_ENV: test CI: true commands: - - cd e2e - npm ci - - npx playwright install --with-deps chromium - - npm run test:responsive - when: - event: - - push - branch: - - main - - e2e-tests-visual: - image: node:18-alpine - environment: - NODE_ENV: test - CI: true - commands: - - cd e2e - - npm ci - - npx playwright install --with-deps chromium - - npx playwright test --grep @visual - when: - event: - - push - branch: - - main - - e2e-tests-a11y: - image: node:18-alpine - environment: - NODE_ENV: test - CI: true - commands: - - cd e2e - - npm ci - - npx playwright install --with-deps chromium + - cd e2e && npm ci + - npx playwright install chromium --with-deps - npx playwright test --grep @accessibility when: event: - push branch: - - main + - release + - release/* - e2e-tests-report: - image: node:18-alpine + # 3.6 视觉回归测试 (release分支) + e2e-visual: + image: mcr.microsoft.com/playwright:v1.48.0-jammy environment: NODE_ENV: test CI: true commands: - - cd e2e - npm ci - - npx playwright install --with-deps chromium - - npm run test:report + - cd e2e && npm ci + - npx playwright install chromium --with-deps + - npx playwright test --grep @visual when: event: - push branch: - - main + - release + - release/* - e2e-tests-all-browsers: - image: node:18-alpine + # ============================================ + # 阶段4: 构建Docker镜像 (release分支) + # ============================================ + build-image: + image: *docker_image environment: - NODE_ENV: test - CI: true + DOCKER_HOST: tcp://docker:2375 + REGISTRY_PASSWORD: + from_secret: registry_password commands: - - cd e2e - - npm ci - - npx playwright install --with-deps chromium firefox webkit - - npm run test:all-browsers + - echo "Building Docker image..." + - docker build -t registry.f.novalon.cn/novalon-website:${CI_COMMIT_SHA} . + - docker tag registry.f.novalon.cn/novalon-website:${CI_COMMIT_SHA} registry.f.novalon.cn/novalon-website:latest + - docker tag registry.f.novalon.cn/novalon-website:${CI_COMMIT_SHA} registry.f.novalon.cn/novalon-website:release-${CI_COMMIT_SHA:0:7} + - echo "Pushing to registry..." + - echo "$REGISTRY_PASSWORD" | docker login -u novalon-admin --password-stdin registry.f.novalon.cn + - docker push registry.f.novalon.cn/novalon-website:${CI_COMMIT_SHA} + - docker push registry.f.novalon.cn/novalon-website:latest + - docker push registry.f.novalon.cn/novalon-website:release-${CI_COMMIT_SHA:0:7} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + when: + - event: push + branch: + - release + - release/* + + # ============================================ + # 阶段5: 部署到生产环境 (release分支) + # ============================================ + deploy-production: + image: alpine:latest + environment: + DEPLOY_ENV: production + SSH_PRIVATE_KEY: + from_secret: ssh_private_key + REGISTRY_PASSWORD: + from_secret: registry_password + commands: + - echo "Deploying to production environment..." + - apk add --no-cache openssh-client curl + - mkdir -p ~/.ssh + - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - ssh-keyscan -H 139.155.109.62 >> ~/.ssh/known_hosts + + # 前置检查 + - echo "Pre-deployment checks..." + - ssh root@139.155.109.62 "echo 'Server connection OK'" + - ssh root@139.155.109.62 "df -h | grep -E '/$|/home'" + - ssh root@139.155.109.62 "docker ps | grep novalon-website || echo 'No existing container'" + + # 部署 + - | + ssh root@139.155.109.62 << EOF + set -e # 任何命令失败立即退出 + cd /home/novalon/docker-app/novalon-website + + echo "=== Step 1: Login to Registry ===" + if ! echo "${REGISTRY_PASSWORD}" | docker login -u novalon-admin --password-stdin registry.f.novalon.cn; then + echo "❌ Registry login failed!" + exit 1 + fi + + echo "=== Step 2: Backup current version ===" + BACKUP_TIME=\$(date +%Y%m%d_%H%M%S) + docker tag registry.f.novalon.cn/novalon-website:latest registry.f.novalon.cn/novalon-website:backup-\${BACKUP_TIME} 2>/dev/null || echo "No existing image to backup" + + echo "=== Step 3: Pull new image ===" + if ! docker-compose pull novalon-website; then + echo "❌ Image pull failed!" + exit 1 + fi + + echo "=== Step 4: Rolling update ===" + docker-compose up -d --no-deps novalon-website + + echo "=== Step 5: Wait for service startup ===" + sleep 10 + + echo "=== Step 6: Database migration ===" + if ! docker-compose exec -T novalon-website npm run db:migrate; then + echo "❌ Database migration failed, rolling back..." + docker tag registry.f.novalon.cn/novalon-website:backup-\${BACKUP_TIME} registry.f.novalon.cn/novalon-website:latest 2>/dev/null || true + docker-compose pull novalon-website + docker-compose up -d --no-deps novalon-website + exit 1 + fi + + echo "=== Step 7: Health check ===" + for i in {1..30}; do + if curl -f https://novalon.cn/api/health; then + echo "✅ Health check passed!" + + echo "=== Step 8: Cleanup old images ===" + docker image prune -f + docker images registry.f.novalon.cn/novalon-website --format "{{.ID}} {{.CreatedAt}}" | tail -n +4 | awk '{print \$1}' | xargs -r docker rmi -f || true + exit 0 + fi + echo "Waiting for service to be ready... (\$i/30)" + sleep 2 + done + + echo "❌ Health check failed, rolling back..." + docker tag registry.f.novalon.cn/novalon-website:backup-\${BACKUP_TIME} registry.f.novalon.cn/novalon-website:latest 2>/dev/null || true + docker-compose pull novalon-website + docker-compose up -d --no-deps novalon-website + sleep 10 + + # 验证回滚 + if curl -f https://novalon.cn/api/health; then + echo "✅ Rollback succeeded, but deployment failed" + else + echo "❌ Rollback also failed!" + fi + exit 1 + EOF + - echo "✅ Production deployment completed!" when: event: - push branch: - - main - - develop + - release + - release/* + + # ============================================ + # 阶段6: 归档到main分支 (release分支) + # ============================================ + archive-to-main: + image: alpine:latest + environment: + SSH_PRIVATE_KEY: + from_secret: ssh_private_key + commands: + - echo "Archiving to main branch..." + - apk add --no-cache git openssh-client + - mkdir -p ~/.ssh + - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - ssh-keyscan -H git.f.novalon.cn >> ~/.ssh/known_hosts + - | + set -e + git config --global user.email "ci@novalon.cn" + git config --global user.name "Woodpecker CI" + + # 使用SSH而不是HTTPS+Token + git remote set-url origin git@git.f.novalon.cn:novalon/novalon-website.git + + # 拉取最新代码 + git fetch origin + git checkout main + git pull origin main + + # 合并release分支 + git merge release --no-ff -m "chore: 归档release ${CI_COMMIT_SHA:0:7}" + + # 创建版本标签 + VERSION_TAG="v$(date +%Y.%m.%d)-${CI_COMMIT_SHA:0:7}" + git tag -a "$VERSION_TAG" -m "Release $(date +%Y-%m-%d)" + + # 推送到远程(带重试) + for i in {1..3}; do + if git push origin main && git push origin --tags; then + echo "✅ Archive succeeded! Version: $VERSION_TAG" + exit 0 + fi + echo "Retry $i/3..." + sleep 5 + done + + echo "⚠️ Archive failed, but deployment succeeded" + echo "Manual archive may be needed" + exit 0 # 不阻止部署成功 + when: + event: + - push + branch: + - release + - release/* + status: + - success + +# ============================================ +# 服务配置 +# ============================================ +services: + docker: + image: docker:24-dind + privileged: true + environment: + DOCKER_TLS_CERTDIR: "" + +# ============================================ +# 工作区配置 +# ============================================ +workspace: + base: /woodpecker + path: src + +# ============================================ +# 克隆配置 +# ============================================ +clone: + git: + image: woodpeckerci/plugin-git + settings: + depth: 1 + partial: false diff --git a/config/test/jest.config.js b/config/test/jest.config.js index 397091c..8087d69 100644 --- a/config/test/jest.config.js +++ b/config/test/jest.config.js @@ -11,10 +11,10 @@ module.exports = { ], coverageThreshold: { global: { - branches: 70, - functions: 70, - lines: 70, - statements: 70, + branches: 80, + functions: 80, + lines: 80, + statements: 80, }, }, coverageReporters: ['text', 'lcov', 'html', 'json'], diff --git a/docker-compose.yml b/docker-compose.yml index e0dd140..1e84863 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: - ./novalon-nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./novalon-nginx/ssl:/etc/nginx/ssl:ro - ./novalon-nginx/logs:/var/log/nginx + - ./certbot:/var/www/certbot networks: - novalon-network depends_on: diff --git a/e2e/admin-frontend-interaction.spec.ts b/e2e/admin-frontend-interaction.spec.ts new file mode 100644 index 0000000..969a09f --- /dev/null +++ b/e2e/admin-frontend-interaction.spec.ts @@ -0,0 +1,332 @@ +import { test, expect, Page } from '@playwright/test'; + +const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'; +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@novalon.cn'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123456'; + +test.describe('后台与前台页面交互测试', () => { + test('首页展示所有内容类型入口', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + + const navLinks = page.locator('nav a, header a[href]'); + const count = await navLinks.count(); + + console.log(`首页导航链接数量: ${count}`); + + expect(count).toBeGreaterThan(0); + + const linkTexts = await navLinks.allTextContents(); + console.log('导航链接:', linkTexts); + }); + + test('新闻页面内容展示', async ({ page }) => { + await page.goto(`${BASE_URL}/news`); + await page.waitForLoadState('networkidle'); + + await expect(page).toHaveURL(/\/news/); + + const mainContent = page.locator('main, [role="main"]'); + await expect(mainContent).toBeVisible(); + + const heading = page.locator('h1, h2').first(); + const hasHeading = await heading.isVisible().catch(() => false); + console.log(`新闻页面标题${hasHeading ? '存在' : '不存在'}`); + }); + + test('产品页面内容展示', async ({ page }) => { + await page.goto(`${BASE_URL}/products`); + await page.waitForLoadState('networkidle'); + + await expect(page).toHaveURL(/\/products/); + + const mainContent = page.locator('main, [role="main"]'); + await expect(mainContent).toBeVisible(); + }); + + test('服务页面内容展示', async ({ page }) => { + await page.goto(`${BASE_URL}/services`); + await page.waitForLoadState('networkidle'); + + await expect(page).toHaveURL(/\/services/); + + const mainContent = page.locator('main, [role="main"]'); + await expect(mainContent).toBeVisible(); + }); + + test('案例页面内容展示', async ({ page }) => { + await page.goto(`${BASE_URL}/cases`); + await page.waitForLoadState('networkidle'); + + await expect(page).toHaveURL(/\/cases/); + + const mainContent = page.locator('main, [role="main"]'); + await expect(mainContent).toBeVisible(); + }); +}); + +test.describe('后台内容管理功能测试', () => { + test.beforeEach(async ({ page }) => { + await page.goto(`${BASE_URL}/admin/login`); + await page.waitForLoadState('networkidle'); + + const emailInput = page.locator('#email'); + const passwordInput = page.locator('#password'); + const submitButton = page.locator('button[type="submit"]'); + + await emailInput.fill(ADMIN_EMAIL); + await passwordInput.fill(ADMIN_PASSWORD); + await submitButton.click(); + + await page.waitForURL(/\/admin(?!\/login)/, { timeout: 15000 }); + }); + + test('后台仪表盘加载', async ({ page }) => { + await page.goto(`${BASE_URL}/admin`); + await page.waitForLoadState('networkidle'); + + const heading = page.locator('h1, .text-2xl').first(); + await expect(heading).toBeVisible(); + + console.log('后台仪表盘加载成功'); + }); + + test('后台内容列表页面加载', async ({ page }) => { + await page.goto(`${BASE_URL}/admin/content`); + await page.waitForLoadState('networkidle'); + + const table = page.locator('table'); + await expect(table).toBeVisible(); + + const rows = page.locator('tbody tr'); + const count = await rows.count(); + console.log(`后台内容列表数量: ${count}`); + }); + + test('后台新建内容页面表单完整性', async ({ page }) => { + await page.goto(`${BASE_URL}/admin/content/new`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForSelector('input[placeholder="请输入标题"]', { timeout: 60000 }); + + const titleInput = page.locator('input[placeholder="请输入标题"]'); + await expect(titleInput).toBeVisible(); + + const slugInput = page.locator('input[placeholder="url-slug"]'); + await expect(slugInput).toBeVisible(); + + const typeSelect = page.locator('select').first(); + await expect(typeSelect).toBeVisible(); + + const categoryInput = page.locator('input[placeholder="分类名称"]'); + const hasCategory = await categoryInput.isVisible().catch(() => false); + console.log(`分类输入框${hasCategory ? '存在' : '不存在'}`); + + const publishButton = page.locator('button:has-text("发布")'); + await expect(publishButton).toBeVisible(); + + const saveDraftButton = page.locator('button:has-text("保存草稿"), button:has-text("保存")'); + await expect(saveDraftButton).toBeVisible(); + }); + + test('后台内容编辑页面加载', async ({ page }) => { + await page.goto(`${BASE_URL}/admin/content`); + await page.waitForLoadState('networkidle'); + + const rows = page.locator('tbody tr'); + const count = await rows.count(); + + if (count > 0) { + const firstEditLink = page.locator('tbody tr:first-child a[href*="/admin/content/"]').first(); + const hasEditLink = await firstEditLink.isVisible().catch(() => false); + + if (hasEditLink) { + await firstEditLink.click(); + await page.waitForLoadState('domcontentloaded'); + + const titleInput = page.locator('input[placeholder="请输入标题"]'); + await expect(titleInput).toBeVisible({ timeout: 30000 }); + + console.log('编辑页面加载成功'); + } else { + console.log('没有可编辑的内容'); + } + } else { + console.log('内容列表为空'); + } + }); + + test('后台内容分类管理', async ({ page }) => { + await page.goto(`${BASE_URL}/admin/categories`); + await page.waitForLoadState('networkidle'); + + const heading = page.locator('h1, .text-2xl').first(); + const hasHeading = await heading.isVisible().catch(() => false); + + console.log(`分类管理页面${hasHeading ? '可访问' : '不存在或无权限'}`); + }); +}); + +test.describe('内容导航和链接测试', () => { + test('导航到不同内容类型页面', async ({ page }) => { + const pages = [ + { url: '/news', name: '新闻' }, + { url: '/products', name: '产品' }, + { url: '/services', name: '服务' }, + { url: '/cases', name: '案例' }, + ]; + + for (const p of pages) { + await page.goto(`${BASE_URL}${p.url}`); + await page.waitForLoadState('networkidle'); + + const url = page.url(); + console.log(`${p.name}页面: ${url.includes(p.url) ? '可访问' : '不可访问'}`); + } + }); + + test('内容详情页访问', async ({ page }) => { + await page.goto(`${BASE_URL}/news`); + await page.waitForLoadState('networkidle'); + + const links = page.locator('a[href*="/news/"]'); + const count = await links.count(); + + if (count > 0) { + const firstLink = links.first(); + const href = await firstLink.getAttribute('href'); + + if (href && !href.startsWith('http')) { + await page.goto(`${BASE_URL}${href}`); + await page.waitForLoadState('networkidle'); + + const mainContent = page.locator('main, article'); + const isVisible = await mainContent.isVisible().catch(() => false); + console.log(`详情页加载${isVisible ? '成功' : '失败'}`); + } + } else { + console.log('没有可访问的新闻详情链接'); + } + }); +}); + +test.describe('SEO和元数据测试', () => { + test('页面标题验证', async ({ page }) => { + const pages = [ + { url: '/', name: '首页' }, + { url: '/news', name: '新闻' }, + { url: '/products', name: '产品' }, + ]; + + for (const p of pages) { + await page.goto(`${BASE_URL}${p.url}`); + await page.waitForLoadState('networkidle'); + + const title = await page.title(); + console.log(`${p.name}标题: ${title}`); + + expect(title.length).toBeGreaterThan(0); + } + }); + + test('Meta描述标签验证', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + + const metaDesc = page.locator('meta[name="description"]'); + const hasMetaDesc = await metaDesc.count(); + + console.log(`Meta描述标签${hasMetaDesc > 0 ? '存在' : '不存在'}`); + }); +}); + +test.describe('响应式导航测试', () => { + test('移动端导航菜单', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + + const menuButton = page.locator('button[aria-label*="菜单"], button[class*="menu"], button[class*="Menu"]'); + const hasMenuButton = await menuButton.isVisible().catch(() => false); + + console.log(`移动端菜单按钮${hasMenuButton ? '存在' : '不存在'}`); + + if (hasMenuButton) { + await menuButton.click(); + await page.waitForSelector('nav, [class*="menu"], [class*="Menu"]', { state: 'visible', timeout: 5000 }); + + const navMenu = page.locator('nav, [class*="menu"], [class*="Menu"]'); + const isVisible = await navMenu.isVisible().catch(() => false); + console.log(`导航菜单${isVisible ? '展开' : '未展开'}`); + } + }); + + test('桌面端导航显示', async ({ page }) => { + await page.setViewportSize({ width: 1920, height: 1080 }); + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + + const navLinks = page.locator('nav a'); + const count = await navLinks.count(); + + console.log(`桌面端导航链接数量: ${count}`); + expect(count).toBeGreaterThan(0); + }); +}); + +test.describe('页面加载性能测试', () => { + test('各页面加载时间', async ({ page }) => { + const pages = [ + { url: '/', name: '首页' }, + { url: '/news', name: '新闻' }, + { url: '/products', name: '产品' }, + { url: '/services', name: '服务' }, + { url: '/cases', name: '案例' }, + ]; + + for (const p of pages) { + const startTime = Date.now(); + await page.goto(`${BASE_URL}${p.url}`); + await page.waitForLoadState('networkidle'); + const loadTime = Date.now() - startTime; + + console.log(`${p.name}页面加载时间: ${loadTime}ms`); + expect(loadTime).toBeLessThan(5000); + } + }); +}); + +test.describe('错误处理测试', () => { + test('访问不存在的页面', async ({ page }) => { + await page.goto(`${BASE_URL}/nonexistent-page-12345`); + await page.waitForLoadState('networkidle'); + + const errorElement = page.locator('[class*="error"], h1:has-text("404"), text=页面不存在'); + const hasError = await errorElement.isVisible().catch(() => false); + + console.log(`404页面${hasError ? '正确显示' : '未显示'}`); + }); + + test('后台访问无权限内容', async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto(`${BASE_URL}/admin/content/99999`); + await page.waitForLoadState('networkidle'); + await page.waitForURL(/\/admin/, { timeout: 5000 }); + + const url = page.url(); + console.log(`访问不存在内容后URL: ${url}`); + + await context.close(); + }); +}); + +test.describe('国际化支持测试', () => { + test('页面语言属性', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + + const htmlLang = await page.locator('html').getAttribute('lang'); + console.log(`页面语言: ${htmlLang || '未设置'}`); + }); +}); diff --git a/e2e/admin-publish-core.spec.ts b/e2e/admin-publish-core.spec.ts new file mode 100644 index 0000000..78e71b1 --- /dev/null +++ b/e2e/admin-publish-core.spec.ts @@ -0,0 +1,198 @@ +import { test, expect, Page } from '@playwright/test'; + +const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'; +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@novalon.cn'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123456'; + +test.describe('后台管理发布功能 - 核心测试', () => { + test.beforeEach(async ({ page }) => { + await page.goto(`${BASE_URL}/admin/login`); + await page.waitForLoadState('networkidle'); + + const emailInput = page.locator('#email'); + const passwordInput = page.locator('#password'); + const submitButton = page.locator('button[type="submit"]'); + + await emailInput.fill(ADMIN_EMAIL); + await passwordInput.fill(ADMIN_PASSWORD); + await submitButton.click(); + + await page.waitForURL(/\/admin(?!\/login)/, { timeout: 15000 }); + }); + + test('管理员登录成功', async ({ page }) => { + expect(page.url()).not.toContain('/admin/login'); + + await page.goto(`${BASE_URL}/admin/content`); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('h1, .text-2xl').first()).toContainText('内容管理'); + }); + + test('后台内容列表加载', async ({ page }) => { + await page.goto(`${BASE_URL}/admin/content`); + await page.waitForLoadState('networkidle'); + + const table = page.locator('table'); + await expect(table).toBeVisible(); + + const rows = page.locator('tbody tr'); + const count = await rows.count(); + expect(count).toBeGreaterThanOrEqual(0); + }); + + test('新建内容页面加载', async ({ page }) => { + await page.goto(`${BASE_URL}/admin/content/new`); + await page.waitForLoadState('domcontentloaded'); + + await page.waitForSelector('input[placeholder="请输入标题"]', { timeout: 60000 }); + await page.waitForSelector('input[placeholder="url-slug"]', { timeout: 60000 }); + + const heading = page.locator('h1, .text-2xl').first(); + await expect(heading).toBeVisible({ timeout: 10000 }); + + const titleInput = page.locator('input[placeholder="请输入标题"]'); + await expect(titleInput).toBeVisible({ timeout: 10000 }); + + const slugInput = page.locator('input[placeholder="url-slug"]'); + await expect(slugInput).toBeVisible({ timeout: 10000 }); + }); + + test('新建内容页面表单元素可见', async ({ page }) => { + await page.goto(`${BASE_URL}/admin/content/new`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForSelector('input[placeholder="请输入标题"]', { timeout: 60000 }); + + const typeSelect = page.locator('select').first(); + await expect(typeSelect).toBeVisible({ timeout: 10000 }); + + const categoryInput = page.locator('input[placeholder="分类名称"]'); + await expect(categoryInput).toBeVisible({ timeout: 10000 }); + + const saveButton = page.locator('button:has-text("保存草稿")'); + await expect(saveButton).toBeVisible({ timeout: 10000 }); + + const publishButton = page.locator('button:has-text("发布")'); + await expect(publishButton).toBeVisible({ timeout: 10000 }); + }); +}); + +test.describe('前端内容展示验证', () => { + test('首页加载正常', async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('header')).toBeVisible(); + await expect(page.locator('footer')).toBeVisible(); + }); + + test('新闻页面加载', async ({ page }) => { + await page.goto(`${BASE_URL}/news`); + await page.waitForLoadState('networkidle'); + + await expect(page).toHaveURL(/\/news/); + await expect(page.locator('header')).toBeVisible(); + }); + + test('产品页面加载', async ({ page }) => { + await page.goto(`${BASE_URL}/products`); + await page.waitForLoadState('networkidle'); + + await expect(page).toHaveURL(/\/products/); + await expect(page.locator('header')).toBeVisible(); + }); + + test('服务页面加载', async ({ page }) => { + await page.goto(`${BASE_URL}/services`); + await page.waitForLoadState('networkidle'); + + await expect(page).toHaveURL(/\/services/); + await expect(page.locator('header')).toBeVisible(); + }); + + test('案例页面加载', async ({ page }) => { + await page.goto(`${BASE_URL}/cases`); + await page.waitForLoadState('networkidle'); + + await expect(page).toHaveURL(/\/cases/); + await expect(page.locator('header')).toBeVisible(); + }); +}); + +test.describe('权限控制测试', () => { + test('未登录访问后台重定向到登录页', async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto(`${BASE_URL}/admin/content`); + await page.waitForURL(/\/admin\/login/, { timeout: 10000 }); + + expect(page.url()).toContain('/admin/login'); + + await context.close(); + }); + + test('API无权限访问返回403', async ({ request }) => { + const response = await request.post(`${BASE_URL}/api/admin/content`, { + data: { + type: 'news', + title: '测试', + slug: 'test', + content: 'test', + }, + }); + + expect([401, 403]).toContain(response.status()); + }); +}); + +test.describe('性能测试', () => { + test('首页加载性能', async ({ page }) => { + const startTime = Date.now(); + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + const loadTime = Date.now() - startTime; + + console.log(`首页加载时间: ${loadTime}ms`); + expect(loadTime).toBeLessThan(5000); + }); + + test('新闻页面加载性能', async ({ page }) => { + const startTime = Date.now(); + await page.goto(`${BASE_URL}/news`); + await page.waitForLoadState('networkidle'); + const loadTime = Date.now() - startTime; + + console.log(`新闻页面加载时间: ${loadTime}ms`); + expect(loadTime).toBeLessThan(5000); + }); +}); + +test.describe('响应式设计测试', () => { + test('移动端显示', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('header')).toBeVisible(); + await expect(page.locator('footer')).toBeVisible(); + }); + + test('平板端显示', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('header')).toBeVisible(); + await expect(page.locator('footer')).toBeVisible(); + }); + + test('桌面端显示', async ({ page }) => { + await page.setViewportSize({ width: 1920, height: 1080 }); + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('header')).toBeVisible(); + await expect(page.locator('footer')).toBeVisible(); + }); +}); diff --git a/e2e/admin-publish.spec.ts b/e2e/admin-publish.spec.ts new file mode 100644 index 0000000..36c4933 --- /dev/null +++ b/e2e/admin-publish.spec.ts @@ -0,0 +1,507 @@ +import { test, expect, Page } from '@playwright/test'; + +const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'; +const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@novalon.cn'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123456'; + +interface ContentData { + type: 'news' | 'product' | 'service' | 'case'; + title: string; + slug: string; + excerpt: string; + content: string; + category: string; + tags: string[]; + status: 'draft' | 'published' | 'archived'; +} + +const testContents: ContentData[] = [ + { + type: 'news', + title: `测试新闻-${Date.now()}`, + slug: `test-news-${Date.now()}`, + excerpt: '这是一条测试新闻的摘要内容', + content: '
这是测试新闻的正文内容
包含多个段落
', + category: '公司新闻', + tags: ['测试', '自动化'], + status: 'published', + }, + { + type: 'product', + title: `测试产品-${Date.now()}`, + slug: `test-product-${Date.now()}`, + excerpt: '这是一个测试产品的描述', + content: '测试产品的详细介绍
', + category: '软件产品', + tags: ['产品', '测试'], + status: 'published', + }, + { + type: 'service', + title: `测试服务-${Date.now()}`, + slug: `test-service-${Date.now()}`, + excerpt: '这是一个测试服务的描述', + content: '测试服务的详细介绍
', + category: '软件开发', + tags: ['服务', '测试'], + status: 'published', + }, + { + type: 'case', + title: `测试案例-${Date.now()}`, + slug: `test-case-${Date.now()}`, + excerpt: '这是一个测试案例的描述', + content: '测试案例的详细介绍
', + category: '企业服务', + tags: ['案例', '测试'], + status: 'published', + }, +]; + +async function loginAsAdmin(page: Page) { + await page.goto(`${BASE_URL}/admin/login`); + await page.waitForLoadState('networkidle'); + + const emailInput = page.locator('input[name="email"], input[type="email"]'); + const passwordInput = page.locator('input[name="password"], input[type="password"]'); + const submitButton = page.locator('button[type="submit"]'); + + await emailInput.fill(ADMIN_EMAIL); + await passwordInput.fill(ADMIN_PASSWORD); + await submitButton.click(); + + await page.waitForURL(/\/admin(?!\/login)/, { timeout: 10000 }); + await page.waitForLoadState('networkidle'); +} + +async function createContent(page: Page, contentData: ContentData): Promise草稿内容
', + category: '公司新闻', + tags: ['草稿'], + status: 'draft', + }; + + const contentId = await createContent(page, draftContent); + expect(contentId).not.toBeNull(); + + await page.goto(`${BASE_URL}/admin/content`); + await page.waitForLoadState('networkidle'); + + const contentRow = page.locator(`tr:has-text("${draftContent.title}")`); + await expect(contentRow).toBeVisible(); + + const statusBadge = contentRow.locator('td:has-text("草稿")'); + await expect(statusBadge).toBeVisible(); + + await page.goto(`${BASE_URL}/news`); + await page.waitForLoadState('networkidle'); + + const newsCard = page.locator(`text="${draftContent.title}"`); + await expect(newsCard).not.toBeVisible(); + + if (contentId) { + await deleteContent(page, contentId); + } + }); + + test('TC-006: 编辑已发布的内容', async ({ page }) => { + const contentData = testContents[0]; + const contentId = await createContent(page, contentData); + + expect(contentId).not.toBeNull(); + + await page.goto(`${BASE_URL}/admin/content/${contentId}`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForSelector('input[type="text"]', { state: 'visible', timeout: 10000 }); + + const updatedTitle = `${contentData.title}-已修改`; + const titleInput = page.locator('input[type="text"]').first(); + await titleInput.fill(updatedTitle); + + const saveButton = page.locator('button:has-text("保存草稿")'); + await saveButton.click(); + + await page.waitForResponse(resp => + resp.url().includes(`/api/admin/content/${contentId}`) && + resp.request().method() === 'PUT', + { timeout: 15000 } + ); + + await page.goto(`${BASE_URL}/news`); + await page.waitForLoadState('networkidle'); + + const updatedCard = page.locator(`text="${updatedTitle}"`); + await expect(updatedCard).toBeVisible(); + + if (contentId) { + await deleteContent(page, contentId); + } + }); + + test('TC-007: 删除内容', async ({ page }) => { + const contentData = testContents[0]; + const contentId = await createContent(page, contentData); + + expect(contentId).not.toBeNull(); + + await deleteContent(page, contentId!); + + await page.goto(`${BASE_URL}/admin/content`); + await page.waitForLoadState('networkidle'); + + const contentRow = page.locator(`tr:has-text("${contentData.title}")`); + await expect(contentRow).not.toBeVisible(); + + await page.goto(`${BASE_URL}/news`); + await page.waitForLoadState('networkidle'); + + const newsCard = page.locator(`text="${contentData.title}"`); + await expect(newsCard).not.toBeVisible(); + }); + + test('TC-008: 归档内容', async ({ page }) => { + const contentData = testContents[0]; + const contentId = await createContent(page, contentData); + + expect(contentId).not.toBeNull(); + + await page.goto(`${BASE_URL}/admin/content/${contentId}`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForSelector('select', { state: 'visible', timeout: 10000 }); + + const statusSelect = page.locator('select').nth(1); + await statusSelect.selectOption('archived'); + + const saveButton = page.locator('button:has-text("保存草稿")'); + await saveButton.click(); + + await page.waitForResponse(resp => + resp.url().includes(`/api/admin/content/${contentId}`) && + resp.request().method() === 'PUT', + { timeout: 15000 } + ); + + await page.goto(`${BASE_URL}/admin/content`); + await page.waitForLoadState('networkidle'); + + const contentRow = page.locator(`tr:has-text("${contentData.title}")`); + await expect(contentRow).toBeVisible(); + + const statusBadge = contentRow.locator('td:has-text("已归档")'); + await expect(statusBadge).toBeVisible(); + + await page.goto(`${BASE_URL}/news`); + await page.waitForLoadState('networkidle'); + + const newsCard = page.locator(`text="${contentData.title}"`); + await expect(newsCard).not.toBeVisible(); + + if (contentId) { + await deleteContent(page, contentId); + } + }); + + test('TC-015: 空内容提交验证', async ({ page }) => { + await page.goto(`${BASE_URL}/admin/content/new`); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(2000); + + const publishButton = page.locator('button:has-text("发布")'); + await publishButton.click(); + + await page.waitForTimeout(1000); + + const errorMessage = page.locator('text=/请输入标题|标题不能为空|请输入|必填/'); + await expect(errorMessage.first()).toBeVisible(); + }); + + test('TC-018: 未登录用户访问后台', async ({ context }) => { + const newPage = await context.newPage(); + + await newPage.goto(`${BASE_URL}/admin/content`); + await newPage.waitForLoadState('networkidle'); + + expect(newPage.url()).toContain('/admin/login'); + + await newPage.close(); + }); +}); + +test.describe('前端内容展示验证', () => { + test('新闻页面加载正常', async ({ page }) => { + await page.goto(`${BASE_URL}/news`); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('h1, .page-header')).toContainText('新闻'); + + const newsCards = page.locator('article, .card, [class*="news-item"]'); + const count = await newsCards.count(); + expect(count).toBeGreaterThan(0); + }); + + test('产品页面加载正常', async ({ page }) => { + await page.goto(`${BASE_URL}/products`); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('h1, .page-header')).toContainText('产品'); + + const productCards = page.locator('article, .card, [class*="product"]'); + const count = await productCards.count(); + expect(count).toBeGreaterThan(0); + }); + + test('服务页面加载正常', async ({ page }) => { + await page.goto(`${BASE_URL}/services`); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('h1, .page-header')).toContainText('服务'); + }); + + test('案例页面加载正常', async ({ page }) => { + await page.goto(`${BASE_URL}/cases`); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('h1, .page-header')).toContainText('案例'); + }); +}); + +test.describe('性能测试', () => { + test('TC-025: 后台列表加载性能', async ({ page }) => { + await loginAsAdmin(page); + + const startTime = Date.now(); + await page.goto(`${BASE_URL}/admin/content`); + await page.waitForLoadState('networkidle'); + const loadTime = Date.now() - startTime; + + console.log(`后台列表加载时间: ${loadTime}ms`); + expect(loadTime).toBeLessThan(3000); + }); + + test('前端新闻页面加载性能', async ({ page }) => { + const startTime = Date.now(); + await page.goto(`${BASE_URL}/news`); + await page.waitForLoadState('networkidle'); + const loadTime = Date.now() - startTime; + + console.log(`前端新闻页面加载时间: ${loadTime}ms`); + expect(loadTime).toBeLessThan(3000); + }); +}); + +test.describe('安全测试', () => { + test('TC-031: XSS攻击防护', async ({ page }) => { + await loginAsAdmin(page); + + const xssContent: ContentData = { + type: 'news', + title: `XSS测试-${Date.now()}`, + slug: `xss-test-${Date.now()}`, + excerpt: '测试摘要', + content: '测试内容
', + category: '公司新闻', + tags: ['安全测试'], + status: 'published', + }; + + const contentId = await createContent(page, xssContent); + + expect(contentId).not.toBeNull(); + + await page.goto(`${BASE_URL}/news`); + await page.waitForLoadState('networkidle'); + + const xssTriggered = await page.evaluate(() => { + return (window as any).xssTriggered === true; + }); + + expect(xssTriggered).toBe(false); + + if (contentId) { + await deleteContent(page, contentId); + } + }); + + test('TC-033: API权限验证', async ({ request }) => { + const response = await request.post(`${BASE_URL}/api/admin/content`, { + data: { + type: 'news', + title: '未授权测试', + slug: 'unauthorized-test', + content: '测试内容', + }, + }); + + expect(response.status()).toBe(403); + }); +}); + +test.describe('跨浏览器兼容性测试', () => { + test('响应式设计 - 移动端', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + + await page.goto(`${BASE_URL}/news`); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('header')).toBeVisible(); + await expect(page.locator('footer')).toBeVisible(); + }); + + test('响应式设计 - 平板端', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); + + await page.goto(`${BASE_URL}/news`); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('header')).toBeVisible(); + await expect(page.locator('footer')).toBeVisible(); + }); +}); diff --git a/forgejo-14.0.3-amd64.tar.gz b/forgejo-14.0.3-amd64.tar.gz new file mode 100644 index 0000000..ab0c7d2 Binary files /dev/null and b/forgejo-14.0.3-amd64.tar.gz differ diff --git a/forgejo-app.ini b/forgejo-app.ini new file mode 100644 index 0000000..97c1470 --- /dev/null +++ b/forgejo-app.ini @@ -0,0 +1,68 @@ +APP_NAME = Gitea: Git with a cup of tea +RUN_MODE = prod +WORK_PATH = /data/gitea + +[repository] +ROOT = /data/git/repositories + +[repository.local] +LOCAL_COPY_PATH = /data/gitea/tmp/local-repo + +[repository.upload] +TEMP_PATH = /data/gitea/uploads + +[server] +APP_DATA_PATH = /data/gitea +DOMAIN = localhost +SSH_DOMAIN = git.f.novalon.cn +HTTP_PORT = 3000 +ROOT_URL = https://git.f.novalon.cn +DISABLE_SSH = false +SSH_PORT = 22 +SSH_LISTEN_PORT = 22 +LFS_START_SERVER = true +LFS_JWT_SECRET = zaXtgFY-twRUX-ygYDPOkIcPg9SYYDOCZ6gDJEjMJFQ + +[database] +PATH = /data/gitea/gitea.db +DB_TYPE = postgres +HOST = postgresql:5432 +NAME = forgejo +USER = forgejo +PASSWD = forgejo_novalon_prod_f50f952069d79d00 +LOG_SQL = false + +[indexer] +ISSUE_INDEXER_PATH = /data/gitea/indexers/issues.bleve + +[session] +PROVIDER_CONFIG = /data/gitea/sessions + +[picture] +AVATAR_UPLOAD_PATH = /data/gitea/avatars +REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars + +[attachment] +PATH = /data/gitea/attachments + +[log] +MODE = console +LEVEL = info +ROOT_PATH = /data/gitea/log + +[security] +INSTALL_LOCK = true +SECRET_KEY = +REVERSE_PROXY_LIMIT = 1 +REVERSE_PROXY_TRUSTED_PROXIES = * +INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3NzQ1OTYzNjl9.6ts7iCdm7v26GwKlLug170fuECEQ7Dymnw7pMTxaOTY + +[service] +DISABLE_REGISTRATION = true +REQUIRE_SIGNIN_VIEW = false + +[lfs] +PATH = /data/git/lfs + +[oauth2] +JWT_SECRET = 955VyK_E5nJvYcNqNLutqm1A4h3E-BfRQiwk3oNS3oo diff --git a/nginx-docker-compose.yml b/nginx-docker-compose.yml new file mode 100644 index 0000000..1347895 --- /dev/null +++ b/nginx-docker-compose.yml @@ -0,0 +1,22 @@ +version: "3.8" + +services: + nginx: + image: nginx:alpine + container_name: novalon-nginx + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./ssl:/etc/nginx/ssl:ro + - ./logs:/var/log/nginx + - ../certbot:/var/www/certbot + networks: + - novalon-network + +networks: + novalon-network: + driver: bridge + external: true diff --git a/nginx-individual.conf b/nginx-individual.conf new file mode 100644 index 0000000..1fdbdac --- /dev/null +++ b/nginx-individual.conf @@ -0,0 +1,270 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 100M; + + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_buffers 16 8k; + gzip_http_version 1.1; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+text text/javascript; + + upstream novalon_app { + server novalon-website:3000; + } + + upstream forgejo_app { + server forgejo:3000; + } + + upstream woodpecker_app { + server woodpecker-server:8000; + } + + upstream registry_app { + server registry:5000; + } + + # ========== novalon.cn 主域名 ========== + server { + listen 80; + server_name novalon.cn www.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 novalon.cn www.novalon.cn; + + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / { + proxy_pass http://novalon_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_cache_bypass $http_upgrade; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location /_next/static { + proxy_pass http://novalon_app; + proxy_cache_valid 200 60m; + add_header Cache-Control "public, immutable, max-age=31536000, s-maxage=31536000"; + } + + location /static { + proxy_pass http://novalon_app; + proxy_cache_valid 200 60m; + add_header Cache-Control "public, immutable, max-age=31536000, s-maxage=31536000"; + } + } + + # ========== git.f.novalon.cn (Forgejo) - 使用单独证书 ========== + 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 HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / { + proxy_pass http://forgejo_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_cache_bypass $http_upgrade; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + } + + # ========== ci.f.novalon.cn (Woodpecker CI) - 使用单独证书 ========== + server { + listen 80; + server_name ci.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 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 HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / { + proxy_pass http://woodpecker_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_cache_bypass $http_upgrade; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + } + + # ========== registry.f.novalon.cn (Docker Registry) - 使用单独证书 ========== + server { + listen 80; + server_name registry.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 registry.f.novalon.cn; + + ssl_certificate /etc/nginx/ssl/registry.f.novalon.cn/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/registry.f.novalon.cn/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / { + proxy_pass http://registry_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_cache_bypass $http_upgrade; + + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + + proxy_buffering off; + proxy_request_buffering off; + } + + location /v2/ { + proxy_pass http://registry_app/v2/; + 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; + } + } +} diff --git a/nginx-temp-for-cert.conf b/nginx-temp-for-cert.conf new file mode 100644 index 0000000..21d8428 --- /dev/null +++ b/nginx-temp-for-cert.conf @@ -0,0 +1,216 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 100M; + + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_buffers 16 8k; + gzip_http_version 1.1; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+text text/javascript; + + upstream novalon_app { + server novalon-website:3000; + } + + upstream forgejo_app { + server forgejo:3000; + } + + upstream woodpecker_app { + server woodpecker-server:8000; + } + + upstream registry_app { + server registry:5000; + } + + # ========== novalon.cn 主域名 ========== + server { + listen 80; + server_name novalon.cn www.novalon.cn; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } + } + + server { + listen 443 ssl; + server_name novalon.cn www.novalon.cn; + + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / { + proxy_pass http://novalon_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_cache_bypass $http_upgrade; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location /_next/static { + proxy_pass http://novalon_app; + proxy_cache_valid 200 60m; + add_header Cache-Control "public, immutable, max-age=31536000, s-maxage=31536000"; + } + + location /static { + proxy_pass http://novalon_app; + proxy_cache_valid 200 60m; + add_header Cache-Control "public, immutable, max-age=31536000, s-maxage=31536000"; + } + } + + # ========== git.f.novalon.cn (临时HTTP配置用于证书申请) ========== + server { + listen 80; + server_name git.f.novalon.cn; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + proxy_pass http://forgejo_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_cache_bypass $http_upgrade; + } + } + + # ========== ci.f.novalon.cn (临时HTTP配置用于证书申请) ========== + server { + listen 80; + server_name ci.f.novalon.cn; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + proxy_pass http://woodpecker_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_cache_bypass $http_upgrade; + } + } + + # ========== registry.f.novalon.cn (已有证书,配置HTTPS) ========== + server { + listen 80; + server_name registry.f.novalon.cn; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } + } + + server { + listen 443 ssl; + server_name registry.f.novalon.cn; + + ssl_certificate /etc/nginx/ssl/registry.f.novalon.cn/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/registry.f.novalon.cn/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / { + proxy_pass http://registry_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_cache_bypass $http_upgrade; + + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + + proxy_buffering off; + proxy_request_buffering off; + } + + location /v2/ { + proxy_pass http://registry_app/v2/; + 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; + } + } +} diff --git a/nginx-wildcard.conf b/nginx-wildcard.conf new file mode 100644 index 0000000..c8d0322 --- /dev/null +++ b/nginx-wildcard.conf @@ -0,0 +1,270 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 100M; + + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_buffers 16 8k; + gzip_http_version 1.1; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+text text/javascript; + + upstream novalon_app { + server novalon-website:3000; + } + + upstream forgejo_app { + server forgejo:3000; + } + + upstream woodpecker_app { + server woodpecker-server:8000; + } + + upstream registry_app { + server registry:5000; + } + + # ========== novalon.cn 主域名 ========== + server { + listen 80; + server_name novalon.cn www.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 novalon.cn www.novalon.cn; + + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / { + proxy_pass http://novalon_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_cache_bypass $http_upgrade; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location /_next/static { + proxy_pass http://novalon_app; + proxy_cache_valid 200 60m; + add_header Cache-Control "public, immutable, max-age=31536000, s-maxage=31536000"; + } + + location /static { + proxy_pass http://novalon_app; + proxy_cache_valid 200 60m; + add_header Cache-Control "public, immutable, max-age=31536000, s-maxage=31536000"; + } + } + + # ========== git.f.novalon.cn (Forgejo) - 使用通配符证书 ========== + 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/wildcard/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/wildcard/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / { + proxy_pass http://forgejo_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_cache_bypass $http_upgrade; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + } + + # ========== ci.f.novalon.cn (Woodpecker CI) - 使用通配符证书 ========== + server { + listen 80; + server_name ci.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 ci.f.novalon.cn; + + ssl_certificate /etc/nginx/ssl/wildcard/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/wildcard/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / { + proxy_pass http://woodpecker_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_cache_bypass $http_upgrade; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + } + + # ========== registry.f.novalon.cn (Docker Registry) - 使用通配符证书 ========== + server { + listen 80; + server_name registry.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 registry.f.novalon.cn; + + ssl_certificate /etc/nginx/ssl/wildcard/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/wildcard/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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 / { + proxy_pass http://registry_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_cache_bypass $http_upgrade; + + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + + proxy_buffering off; + proxy_request_buffering off; + } + + location /v2/ { + proxy_pass http://registry_app/v2/; + 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; + } + } +} diff --git a/public/images/备案图标.png b/public/images/备案图标.png deleted file mode 100644 index 2a13ba2..0000000 Binary files a/public/images/备案图标.png and /dev/null differ diff --git a/scripts/deploy-subdomain-ssl.sh b/scripts/deploy-subdomain-ssl.sh new file mode 100644 index 0000000..111019f --- /dev/null +++ b/scripts/deploy-subdomain-ssl.sh @@ -0,0 +1,137 @@ +#!/bin/bash + +set -e + +echo "=========================================" +echo "二级域名SSL证书配置部署脚本" +echo "=========================================" +echo "" +echo "请选择SSL证书申请方案:" +echo "" +echo "方案A: 通配符证书 (DNS验证)" +echo " - 一个证书覆盖所有 *.f.novalon.cn" +echo " - 需要腾讯云API密钥" +echo " - 适合: 有API密钥且希望简化证书管理" +echo "" +echo "方案B: 单独证书 (HTTP验证)" +echo " - 为每个域名单独申请证书" +echo " - 无需API密钥" +echo " - 适合: 没有API密钥或希望独立管理每个域名" +echo "" +read -p "请选择方案 [A/B]: " choice + +case $choice in + [Aa]) + echo "" + echo "选择方案A: 通配符证书" + + if [ -f "scripts/ssl-wildcard-dns.sh" ]; then + echo "" + echo "上传SSL证书申请脚本..." + scp scripts/ssl-wildcard-dns.sh root@139.155.109.62:/home/novalon/docker-app/ + ssh root@139.155.109.62 "chmod +x /home/novalon/docker-app/ssl-wildcard-dns.sh" + echo "✓ SSL证书申请脚本已上传" + else + echo "✗ 找不到ssl-wildcard-dns.sh文件" + exit 1 + fi + + echo "" + echo "上传Nginx配置..." + if [ -f "nginx-wildcard.conf" ]; then + scp nginx-wildcard.conf root@139.155.109.62:/home/novalon/docker-app/novalon-nginx/nginx.conf + echo "✓ Nginx配置已上传" + else + echo "✗ 找不到nginx-wildcard.conf文件" + exit 1 + fi + + echo "" + echo "=========================================" + echo "请在服务器上执行以下命令:" + echo "=========================================" + echo "" + echo "ssh root@139.155.109.62" + echo "" + echo "export TENCENTCLOUD_SECRET_ID=your-secret-id" + echo "export TENCENTCLOUD_SECRET_KEY=your-secret-key" + echo "" + echo "cd /home/novalon/docker-app" + echo "./ssl-wildcard-dns.sh" + echo "" + echo "docker restart novalon-nginx" + echo "" + echo "=========================================" + ;; + + [Bb]) + echo "" + echo "选择方案B: 单独证书" + + if [ -f "scripts/ssl-individual-http.sh" ]; then + echo "" + echo "上传SSL证书申请脚本..." + scp scripts/ssl-individual-http.sh root@139.155.109.62:/home/novalon/docker-app/ + ssh root@139.155.109.62 "chmod +x /home/novalon/docker-app/ssl-individual-http.sh" + echo "✓ SSL证书申请脚本已上传" + else + echo "✗ 找不到ssl-individual-http.sh文件" + exit 1 + fi + + echo "" + echo "上传Nginx配置..." + if [ -f "nginx-individual.conf" ]; then + scp nginx-individual.conf root@139.155.109.62:/home/novalon/docker-app/novalon-nginx/nginx.conf + echo "✓ Nginx配置已上传" + else + echo "✗ 找不到nginx-individual.conf文件" + exit 1 + fi + + echo "" + read -p "是否现在申请证书? [y/N]: " confirm + if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then + echo "" + echo "申请SSL证书..." + ssh root@139.155.109.62 "cd /home/novalon/docker-app && ./ssl-individual-http.sh" + + echo "" + echo "重启Nginx容器..." + ssh root@139.155.109.62 "docker restart novalon-nginx" + else + echo "" + echo "=========================================" + echo "请在服务器上执行以下命令:" + echo "=========================================" + echo "" + echo "ssh root@139.155.109.62" + echo "" + echo "cd /home/novalon/docker-app" + echo "./ssl-individual-http.sh" + echo "" + echo "docker restart novalon-nginx" + echo "" + echo "=========================================" + fi + ;; + + *) + echo "无效选择" + exit 1 + ;; +esac + +echo "" +echo "=========================================" +echo "部署完成!" +echo "=========================================" +echo "" +echo "测试访问:" +echo " - https://git.f.novalon.cn" +echo " - https://ci.f.novalon.cn" +echo " - https://registry.f.novalon.cn" +echo "" +echo "检查SSL证书:" +echo " openssl s_client -connect git.f.novalon.cn:443 -servername git.f.novalon.cn | openssl x509 -noout -text | grep -A 1 'Subject Alternative Name'" +echo "=========================================" diff --git a/scripts/deploy-wildcard-domain.sh b/scripts/deploy-wildcard-domain.sh new file mode 100755 index 0000000..c81e9bd --- /dev/null +++ b/scripts/deploy-wildcard-domain.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +set -e + +echo "=========================================" +echo "二级域名配置部署脚本" +echo "=========================================" + +echo "" +echo "步骤1: 验证DNS解析..." +echo "检查 *.f.novalon.cn 解析..." + +if nslookup git.f.novalon.cn | grep -q "139.155.109.62"; then + echo "✓ DNS解析正常" +else + echo "✗ DNS解析未生效,请等待DNS传播" + exit 1 +fi + +echo "" +echo "步骤2: 上传Nginx配置..." +if [ -f "nginx-wildcard.conf" ]; then + scp nginx-wildcard.conf root@139.155.109.62:/home/novalon/docker-app/nginx.conf + echo "✓ Nginx配置已上传" +else + echo "✗ 找不到nginx-wildcard.conf文件" + exit 1 +fi + +echo "" +echo "步骤3: 上传SSL证书申请脚本..." +if [ -f "scripts/setup-wildcard-ssl.sh" ]; then + scp scripts/setup-wildcard-ssl.sh root@139.155.109.62:/home/novalon/docker-app/ + ssh root@139.155.109.62 "chmod +x /home/novalon/docker-app/setup-wildcard-ssl.sh" + echo "✓ SSL证书申请脚本已上传" +else + echo "✗ 找不到setup-wildcard-ssl.sh文件" + exit 1 +fi + +echo "" +echo "步骤4: 申请通配符SSL证书..." +echo "注意: 需要腾讯云API密钥" +echo "" +echo "请在服务器上执行以下命令:" +echo "ssh root@139.155.109.62" +echo "export TENCENTCLOUD_SECRET_ID=your-secret-id" +echo "export TENCENTCLOUD_SECRET_KEY=your-secret-key" +echo "cd /home/novalon/docker-app && ./setup-wildcard-ssl.sh" +echo "" +echo "或者直接运行 (需要提供密钥):" +read -p "是否现在申请证书? (需要腾讯云API密钥) [y/N]: " confirm + +if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then + read -p "请输入腾讯云Secret ID: " secret_id + read -p "请输入腾讯云Secret Key: " secret_key + + ssh root@139.155.109.62 "export TENCENTCLOUD_SECRET_ID='$secret_id' && export TENCENTCLOUD_SECRET_KEY='$secret_key' && cd /home/novalon/docker-app && ./setup-wildcard-ssl.sh" +fi + +echo "" +echo "=========================================" +echo "部署完成!" +echo "=========================================" +echo "" +echo "后续步骤:" +echo "1. 如果未自动申请证书,请手动执行SSL证书申请脚本" +echo "2. 重启Nginx容器: docker restart novalon-nginx" +echo "3. 测试访问:" +echo " - https://git.f.novalon.cn" +echo " - https://ci.f.novalon.cn" +echo " - https://registry.f.novalon.cn" +echo "=========================================" diff --git a/scripts/setup-gitea-oauth2-auto.sh b/scripts/setup-gitea-oauth2-auto.sh new file mode 100644 index 0000000..b13c326 --- /dev/null +++ b/scripts/setup-gitea-oauth2-auto.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +echo "=========================================" +echo "Gitea OAuth2应用自动配置" +echo "=========================================" + +echo "" +echo "步骤1: 生成管理员Access Token..." +# 使用正确的scope (all包含所有权限) +OUTPUT=$(docker exec -u git forgejo gitea admin user generate-access-token \ + --username novalon-admin \ + --token-name oauth2-setup-$(date +%s) \ + --scopes all 2>&1) + +echo "$OUTPUT" + +# 从输出中提取token +TOKEN=$(echo "$OUTPUT" | grep -oP 'Access token: \K.*' || echo "") + +echo "" +echo "步骤2: 使用Token创建OAuth2应用..." + +if [ -n "$TOKEN" ]; then + echo "Token已生成: ${TOKEN:0:20}..." + + # 使用API创建OAuth2应用 + RESPONSE=$(docker exec forgejo curl -s -X POST "http://localhost:3000/api/v1/applications/oauth2" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Woodpecker CI", + "redirect_uri": "https://ci.f.novalon.cn/authorize", + "confidential_client": true + }') + + echo "API响应: $RESPONSE" + + # 提取Client ID和Secret + CLIENT_ID=$(echo "$RESPONSE" | grep -oP '"client_id":"\K[^"]+' || echo "") + CLIENT_SECRET=$(echo "$RESPONSE" | grep -oP '"client_secret":"\K[^"]+' || echo "") + + if [ -n "$CLIENT_ID" ] && [ -n "$CLIENT_SECRET" ]; then + echo "" + echo "=========================================" + echo "✅ OAuth2应用创建成功!" + echo "=========================================" + echo "" + echo "Client ID: $CLIENT_ID" + echo "Client Secret: $CLIENT_SECRET" + echo "" + echo "请将以下内容添加到.env文件:" + echo "WOODPECKER_FORGEJO_CLIENT=$CLIENT_ID" + echo "WOODPECKER_FORGEJO_SECRET=$CLIENT_SECRET" + echo "" + echo "然后重启Woodpecker服务:" + echo "cd /home/novalon/docker-app/novalon-cicd" + echo "docker-compose restart woodpecker-server" + echo "=========================================" + exit 0 + else + echo "警告: 无法从API响应中提取凭证" + fi +else + echo "警告: 无法生成Token" +fi + +echo "" +echo "=========================================" +echo "⚠️ 自动配置失败,请手动完成" +echo "=========================================" +echo "" +echo "1. 访问 https://git.f.novalon.cn" +echo "2. 登录凭证:" +echo " 用户名: novalon-admin" +echo " 密码: Novalon@Admin2026" +echo "" +echo "3. 创建OAuth2应用:" +echo " 头像 -> 设置 -> 应用 -> OAuth2应用 -> 创建应用" +echo " 名称: Woodpecker CI" +echo " 重定向URI: https://ci.f.novalon.cn/authorize" +echo "" +echo "4. 记录Client ID和Secret并更新.env文件" +echo "=========================================" diff --git a/scripts/setup-gitea-oauth2.sh b/scripts/setup-gitea-oauth2.sh new file mode 100644 index 0000000..68859c8 --- /dev/null +++ b/scripts/setup-gitea-oauth2.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +echo "=========================================" +echo "Gitea OAuth2应用配置" +echo "=========================================" + +echo "" +echo "步骤1: 生成管理员Access Token..." +# 生成access token +docker exec -u git forgejo gitea admin user generate-access-token \ + --username novalon-admin \ + --token-name oauth2-setup \ + --scopes write:application,read:application,write:user,read:user + +echo "" +echo "步骤2: 从数据库获取Token..." +# 从数据库获取token (Gitea存储的是hash,我们需要原始token) +# 查看access_token表 +docker exec postgresql psql -U forgejo -d forgejo -c \ + "SELECT id, uid, name, created_unix FROM access_token WHERE name='oauth2-setup' ORDER BY created_unix DESC LIMIT 1;" + +echo "" +echo "步骤3: 尝试使用API创建OAuth2应用..." +# 由于我们无法直接获取原始token,让我们使用Web UI方式 +echo "" +echo "=========================================" +echo "请手动完成以下步骤:" +echo "=========================================" +echo "" +echo "1. 访问 https://git.f.novalon.cn" +echo "2. 使用以下凭证登录:" +echo " 用户名: novalon-admin" +echo " 密码: Novalon@Admin2026" +echo "" +echo "3. 点击右上角头像 -> 设置 -> 应用 -> OAuth2应用" +echo "4. 点击'创建新的OAuth2应用'" +echo "5. 填写以下信息:" +echo " 应用名称: Woodpecker CI" +echo " 重定向URI: https://ci.f.novalon.cn/authorize" +echo "6. 点击'创建应用'" +echo "7. 记录生成的Client ID和Client Secret" +echo "" +echo "8. 将凭证更新到.env文件:" +echo " WOODPECKER_FORGEJO_CLIENT={description}
+请先登录
- - 前往登录 - -