feat: monorepo 重构 + 新增 5 个平台适配器
项目从单体结构重构为 pnpm monorepo (shared/backend/frontend), 新增 YouTube、Instagram、Twitter/X、哔哩哔哩、微博 5 个平台适配器, 包含完整的单元测试和 E2E 测试覆盖。 - 完成 T-031~T-044: 5 个适配器实现、注册、配置和测试 - 重构前后端分离: Hono 后端 + Next.js 前端 - 151 个单元测试 + 21 个 Mock E2E + 25 个真实 E2E - 适配器基于真实 TikHub API 响应结构实现 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,641 @@
|
||||
# CI/CD 集成方案(最终落地版)
|
||||
|
||||
## 概述
|
||||
|
||||
本文档记录抖音评论管理系统的 CI/CD 方案:**Drone CI + 私有 Docker Registry + Docker Compose 自动部署**。
|
||||
|
||||
目标:
|
||||
|
||||
- 每天凌晨 0 点(北京时间)自动拉取 `main` 最新代码并部署
|
||||
- 创建 Tag(如 `v1.9.0210.7`)时自动构建并部署
|
||||
- 不在每次 `push` 时触发
|
||||
- 生产环境继续沿用 `docker-compose.prod.yml`
|
||||
|
||||
---
|
||||
|
||||
## 基础设施
|
||||
|
||||
| 角色 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| Gitea 服务器 | 腾讯云 `git.internal.intelligrow.cn` | 代码仓库 |
|
||||
| Drone CI 服务器 | Ubuntu x64 `192.168.31.107` | 执行构建任务 + 运行私有 Docker Registry |
|
||||
| 生产服务器 | Ubuntu x64 `192.168.31.48` | 运行 Docker Compose 生产服务,SSH 端口 3141 |
|
||||
|
||||
---
|
||||
|
||||
## 架构流程
|
||||
|
||||
```text
|
||||
定时触发(cron)或 创建 tag
|
||||
|
|
||||
v
|
||||
Drone 拉取代码
|
||||
|
|
||||
+-- 构建 backend 镜像并推送 Registry
|
||||
+-- 构建 frontend 镜像并推送 Registry
|
||||
v
|
||||
SSH 到生产服务器执行 deploy-remote.sh
|
||||
|
|
||||
+-- pull 新镜像
|
||||
+-- 停止 celery_beat
|
||||
+-- 更新 backend + celery_worker
|
||||
+-- 健康检查(容器内 /health)
|
||||
+-- alembic upgrade head(失败即中断)
|
||||
+-- 启动 celery_beat + frontend
|
||||
+-- 最终健康检查
|
||||
v
|
||||
发送企业微信通知(成功/失败)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```text
|
||||
.drone.yml # Drone CI 流水线
|
||||
scripts/
|
||||
+-- deploy.sh # 原有手动部署脚本
|
||||
+-- deploy-remote.sh # Drone 远程调用的自动部署脚本
|
||||
+-- server-setup.sh # 原有服务器初始化脚本
|
||||
docker-compose.prod.yml # 生产环境 compose 配置
|
||||
docs/
|
||||
+-- cicd_integration_updated.md # 本文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1:Drone CI 服务器准备(一次性)
|
||||
|
||||
### 1.1 Drone CI 服务 (docker-compose.yml)
|
||||
|
||||
在 Drone CI 服务器 `~/drone/docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
drone-server:
|
||||
image: drone/drone:2
|
||||
container_name: drone-server
|
||||
restart: always
|
||||
ports:
|
||||
- "3080:80"
|
||||
environment:
|
||||
- DRONE_GITEA_SERVER=https://git.internal.intelligrow.cn
|
||||
- DRONE_GITEA_CLIENT_ID=<your-client-id>
|
||||
- DRONE_GITEA_CLIENT_SECRET=<your-client-secret>
|
||||
- DRONE_SERVER_HOST=drone.internal.intelligrow.cn
|
||||
- DRONE_SERVER_PROTO=https
|
||||
- DRONE_RPC_SECRET=<your-rpc-secret>
|
||||
- DRONE_USER_CREATE=username:<your-gitea-username>,admin:true
|
||||
volumes:
|
||||
- ./data:/data
|
||||
|
||||
drone-runner:
|
||||
image: drone/drone-runner-docker:1
|
||||
container_name: drone-runner
|
||||
restart: always
|
||||
depends_on:
|
||||
- drone-server
|
||||
environment:
|
||||
- DRONE_RPC_PROTO=http
|
||||
- DRONE_RPC_HOST=drone-server
|
||||
- DRONE_RPC_SECRET=<your-rpc-secret>
|
||||
- DRONE_RUNNER_CAPACITY=2
|
||||
- DRONE_RUNNER_NAME=drone-runner-1
|
||||
- DRONE_RUNNER_PRIVILEGED_IMAGES=plugins/docker
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
```
|
||||
|
||||
关键配置说明:
|
||||
|
||||
- **`DRONE_RPC_PROTO=http`**:Runner 在 Docker 内网直连 drone-server 容器的 80 端口,不走 HTTPS。外部通过反向代理 `drone.internal.intelligrow.cn` 访问时才用 HTTPS。
|
||||
- **`DRONE_USER_CREATE`**:`username` 必须与 Gitea 登录用户名完全一致(不是邮箱),否则管理员权限不生效。
|
||||
- **`DRONE_RUNNER_PRIVILEGED_IMAGES=plugins/docker`**:允许 plugins/docker 以特权模式运行(本方案最终未使用 plugins/docker,但保留配置以备后用)。
|
||||
|
||||
### 1.2 启动私有 Registry
|
||||
|
||||
在 Drone CI 服务器执行:
|
||||
|
||||
```bash
|
||||
docker run -d --name registry \
|
||||
-p 5000:5000 \
|
||||
-v /opt/registry-data:/var/lib/registry \
|
||||
--restart always \
|
||||
registry:2
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
curl http://localhost:5000/v2/_catalog
|
||||
# 预期输出: {"repositories":[]}
|
||||
```
|
||||
|
||||
### 1.3 配置 insecure registry
|
||||
|
||||
**Drone CI 服务器** `/etc/docker/daemon.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"registry-mirrors": [
|
||||
"https://docker.1panel.live",
|
||||
"https://docker.1panel.dev",
|
||||
"https://docker.1ms.run"
|
||||
],
|
||||
"insecure-registries": [
|
||||
"docker.internal.intelligrow.cn:5000",
|
||||
"192.168.31.107:5000"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**生产服务器** `/etc/docker/daemon.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"registry-mirrors": [
|
||||
"https://docker.1panel.live",
|
||||
"https://docker.1panel.dev",
|
||||
"https://docker.1ms.run"
|
||||
],
|
||||
"insecure-registries": ["docker.internal.intelligrow.cn:5000"]
|
||||
}
|
||||
```
|
||||
|
||||
修改后重启 Docker:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
> 注意:`insecure-registries` 中不要带 `http://` 前缀,直接写 `host:port` 格式。
|
||||
|
||||
### 1.4 配置 SSH 免密
|
||||
|
||||
在 Drone CI 服务器生成密钥并添加到生产服务器:
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -C "drone-ci" -f ~/.ssh/drone_deploy -N ""
|
||||
ssh-copy-id -i ~/.ssh/drone_deploy.pub -p 3141 miaosi@192.168.31.48
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/drone_deploy -p 3141 miaosi@192.168.31.48 "echo ok"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2:Drone 仓库设置
|
||||
|
||||
### 2.1 开启 Trusted 模式
|
||||
|
||||
在 Drone 面板 → 仓库 Settings → General → Project Settings → 勾选 **Trusted**。
|
||||
|
||||
> 需要管理员权限。如果看不到 Trusted 选项,检查 `DRONE_USER_CREATE` 的 username 是否与 Gitea 用户名一致。
|
||||
|
||||
### 2.2 配置 Secrets
|
||||
|
||||
在 Drone 面板(仓库设置 → Secrets)添加:
|
||||
|
||||
| Secret 名称 | 用途 | 实际值示例 |
|
||||
|-------------|------|-----------|
|
||||
| `backend_repo` | 后端镜像完整仓库地址 | `docker.internal.intelligrow.cn:5000/douyin-backend` |
|
||||
| `frontend_repo` | 前端镜像完整仓库地址 | `docker.internal.intelligrow.cn:5000/douyin-frontend` |
|
||||
| `deploy_host` | 生产服务器 IP | `192.168.31.48` |
|
||||
| `deploy_user` | SSH 用户 | `miaosi` |
|
||||
| `deploy_ssh_key` | SSH 私钥内容 | `-----BEGIN OPENSSH PRIVATE KEY-----...-----END OPENSSH PRIVATE KEY-----` |
|
||||
| `deploy_path` | 生产服务器部署目录 | `/opt/docker/douyin_comments_management` |
|
||||
| `wecom_webhook` | 企业微信 Webhook(可选) | `https://qyapi.weixin.qq.com/...` |
|
||||
|
||||
> 注意:`backend_repo` 和 `frontend_repo` 的 Registry 地址必须与生产服务器 `.env` 中的 `DOCKER_REGISTRY` 使用相同的主机名(都用域名或都用 IP),否则 Docker 会认为是不同的镜像。
|
||||
|
||||
### 2.3 配置 Cron Job
|
||||
|
||||
在 Drone 面板(仓库设置 → Cron Jobs)添加:
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|------|
|
||||
| Name | `nightly-build` |
|
||||
| Branch | `main` |
|
||||
| Schedule | `0 16 * * *` |
|
||||
|
||||
说明:
|
||||
|
||||
- Drone 默认按 UTC 解释 Cron
|
||||
- `0 16 * * *` = UTC 16:00 = 北京时间次日 00:00
|
||||
|
||||
---
|
||||
|
||||
## Phase 3:配置文件(最终落地版本)
|
||||
|
||||
### 3.1 `.drone.yml`
|
||||
|
||||
```yaml
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: build-and-deploy
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
- cron
|
||||
|
||||
volumes:
|
||||
- name: dockersock
|
||||
host:
|
||||
path: /var/run/docker.sock
|
||||
|
||||
steps:
|
||||
- name: build-backend
|
||||
image: docker:27-cli
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
environment:
|
||||
BACKEND_REPO:
|
||||
from_secret: backend_repo
|
||||
commands:
|
||||
- '[ -n "$BACKEND_REPO" ] || (echo "backend_repo secret is empty" && exit 1)'
|
||||
- echo "Building backend image tag:${DRONE_TAG:-latest}"
|
||||
- docker build -t "$BACKEND_REPO:${DRONE_TAG:-latest}" -t "$BACKEND_REPO:latest" ./backend
|
||||
- docker push "$BACKEND_REPO:${DRONE_TAG:-latest}"
|
||||
- docker push "$BACKEND_REPO:latest"
|
||||
|
||||
- name: build-frontend
|
||||
image: docker:27-cli
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
environment:
|
||||
FRONTEND_REPO:
|
||||
from_secret: frontend_repo
|
||||
commands:
|
||||
- '[ -n "$FRONTEND_REPO" ] || (echo "frontend_repo secret is empty" && exit 1)'
|
||||
- echo "Building frontend image tag:${DRONE_TAG:-latest}"
|
||||
- docker build -t "$FRONTEND_REPO:${DRONE_TAG:-latest}" -t "$FRONTEND_REPO:latest" ./frontend
|
||||
- docker push "$FRONTEND_REPO:${DRONE_TAG:-latest}"
|
||||
- docker push "$FRONTEND_REPO:latest"
|
||||
|
||||
- name: deploy
|
||||
image: appleboy/drone-ssh
|
||||
environment:
|
||||
DEPLOY_PATH:
|
||||
from_secret: deploy_path
|
||||
settings:
|
||||
host:
|
||||
from_secret: deploy_host
|
||||
username:
|
||||
from_secret: deploy_user
|
||||
key:
|
||||
from_secret: deploy_ssh_key
|
||||
port: 3141
|
||||
command_timeout: 1800s
|
||||
script_stop: true
|
||||
envs:
|
||||
- DRONE_TAG
|
||||
- DEPLOY_PATH
|
||||
script:
|
||||
- IMAGE_TAG="$DRONE_TAG"; [ -n "$IMAGE_TAG" ] || IMAGE_TAG="latest"
|
||||
- cd "$DEPLOY_PATH"
|
||||
- bash scripts/deploy-remote.sh "$IMAGE_TAG"
|
||||
|
||||
- name: notify-success
|
||||
image: curlimages/curl
|
||||
environment:
|
||||
WECOM_WEBHOOK:
|
||||
from_secret: wecom_webhook
|
||||
commands:
|
||||
- |
|
||||
if [ -n "${WECOM_WEBHOOK:-}" ]; then
|
||||
VERSION="$DRONE_TAG"
|
||||
[ -n "$VERSION" ] || VERSION="nightly-$(date +%Y%m%d)"
|
||||
curl -sS -X POST "$WECOM_WEBHOOK" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"msgtype\":\"text\",\"text\":{\"content\":\"✅ 部署成功\\n版本: ${VERSION}\\n仓库: ${DRONE_REPO}\\n时间: $(date '+%Y-%m-%d %H:%M:%S')\"}}"
|
||||
fi
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
|
||||
- name: notify-failure
|
||||
image: curlimages/curl
|
||||
environment:
|
||||
WECOM_WEBHOOK:
|
||||
from_secret: wecom_webhook
|
||||
commands:
|
||||
- |
|
||||
if [ -n "${WECOM_WEBHOOK:-}" ]; then
|
||||
VERSION="$DRONE_TAG"
|
||||
[ -n "$VERSION" ] || VERSION="nightly-$(date +%Y%m%d)"
|
||||
curl -sS -X POST "$WECOM_WEBHOOK" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"msgtype\":\"text\",\"text\":{\"content\":\"❌ 部署失败\\n版本: ${VERSION}\\n仓库: ${DRONE_REPO}\\n构建: ${DRONE_BUILD_LINK}\"}}"
|
||||
fi
|
||||
when:
|
||||
status:
|
||||
- failure
|
||||
```
|
||||
|
||||
### 3.2 `scripts/deploy-remote.sh`
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# ========================================
|
||||
# 远程部署脚本 - 被 Drone CI SSH 调用
|
||||
# ========================================
|
||||
# 用法: bash scripts/deploy-remote.sh [image_tag]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; }
|
||||
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/opt/docker/douyin_comments_management}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
|
||||
IMAGE_TAG="${1:-latest}"
|
||||
LOCK_FILE="/tmp/douyin-deploy.lock"
|
||||
MAX_ATTEMPTS="${MAX_ATTEMPTS:-30}"
|
||||
|
||||
cleanup_lock() {
|
||||
rm -f "$LOCK_FILE"
|
||||
}
|
||||
|
||||
acquire_lock() {
|
||||
if [ -f "$LOCK_FILE" ]; then
|
||||
local old_pid
|
||||
old_pid=$(cat "$LOCK_FILE" 2>/dev/null || true)
|
||||
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
|
||||
log_error "已有部署进程运行中 (PID: $old_pid)"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$LOCK_FILE"
|
||||
fi
|
||||
|
||||
echo "$$" > "$LOCK_FILE"
|
||||
trap cleanup_lock EXIT
|
||||
}
|
||||
|
||||
compose() {
|
||||
docker compose -f "$COMPOSE_FILE" "$@"
|
||||
}
|
||||
|
||||
wait_backend_healthy() {
|
||||
local attempt=1
|
||||
|
||||
while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do
|
||||
if compose exec -T backend python -c "import sys,urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3);sys.exit(0)" >/dev/null 2>&1; then
|
||||
log_info "后端健康检查通过"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "等待后端就绪... (${attempt}/${MAX_ATTEMPTS})"
|
||||
sleep 3
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
main() {
|
||||
acquire_lock
|
||||
|
||||
[ -d "$DEPLOY_PATH" ] || { log_error "部署目录不存在: $DEPLOY_PATH"; exit 1; }
|
||||
cd "$DEPLOY_PATH"
|
||||
|
||||
[ -f "$COMPOSE_FILE" ] || { log_error "Compose 文件不存在: $COMPOSE_FILE"; exit 1; }
|
||||
|
||||
export IMAGE_TAG
|
||||
export VERSION="$IMAGE_TAG"
|
||||
|
||||
log_info "开始部署,镜像版本: $IMAGE_TAG"
|
||||
|
||||
log_info "拉取最新镜像..."
|
||||
compose pull backend celery_worker celery_beat frontend
|
||||
|
||||
log_info "停止 celery_beat..."
|
||||
compose stop celery_beat || true
|
||||
|
||||
log_info "更新 backend 与 celery_worker..."
|
||||
compose up -d --no-deps backend celery_worker
|
||||
|
||||
if ! wait_backend_healthy; then
|
||||
log_error "后端未在预期时间内就绪"
|
||||
compose logs --tail=200 backend || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "执行数据库迁移..."
|
||||
compose exec -T backend alembic upgrade head
|
||||
|
||||
log_info "启动 celery_beat..."
|
||||
compose up -d --no-deps celery_beat
|
||||
|
||||
log_info "更新 frontend..."
|
||||
compose up -d --no-deps frontend
|
||||
|
||||
log_info "清理孤儿容器..."
|
||||
compose up -d --remove-orphans
|
||||
|
||||
if ! wait_backend_healthy; then
|
||||
log_error "部署完成后健康检查失败"
|
||||
compose logs --tail=200 backend || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker image prune -f >/dev/null 2>&1 || log_warn "镜像清理失败,已跳过"
|
||||
|
||||
log_info "部署完成!版本: $IMAGE_TAG"
|
||||
compose ps
|
||||
}
|
||||
|
||||
main "$@"
|
||||
```
|
||||
|
||||
> 注意:脚本同时 export `IMAGE_TAG` 和 `VERSION`,因为生产服务器的 `docker-compose.prod.yml` 使用 `${VERSION:-latest}` 作为镜像 tag 变量。
|
||||
|
||||
---
|
||||
|
||||
## 生产服务器 `.env` 配置
|
||||
|
||||
确保 `/opt/docker/douyin_comments_management/.env` 至少包含:
|
||||
|
||||
```bash
|
||||
DOCKER_REGISTRY=docker.internal.intelligrow.cn:5000
|
||||
|
||||
POSTGRES_PASSWORD=xxx
|
||||
REDIS_PASSWORD=xxx
|
||||
RABBITMQ_PASSWORD=xxx
|
||||
# 其余生产配置保持现有值
|
||||
```
|
||||
|
||||
> `DOCKER_REGISTRY` 的值必须与 Drone Secret 中 `backend_repo` / `frontend_repo` 的 Registry 主机名一致。
|
||||
|
||||
---
|
||||
|
||||
## 回滚方案
|
||||
|
||||
### 方式 1:推送旧版本 tag 触发重新部署
|
||||
|
||||
```bash
|
||||
git tag v1.8.1-rollback v1.8.1
|
||||
git push origin v1.8.1-rollback
|
||||
```
|
||||
|
||||
### 方式 2:生产服务器手动回滚
|
||||
|
||||
```bash
|
||||
ssh -p 3141 miaosi@192.168.31.48
|
||||
cd /opt/docker/douyin_comments_management
|
||||
bash scripts/deploy-remote.sh v1.8.1
|
||||
```
|
||||
|
||||
### 方式 3:手动 compose 回滚
|
||||
|
||||
```bash
|
||||
cd /opt/docker/douyin_comments_management
|
||||
export VERSION=v1.8.1
|
||||
docker compose -f docker-compose.prod.yml pull backend celery_worker celery_beat frontend
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps backend celery_worker celery_beat frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证方式
|
||||
|
||||
### 验证 Registry
|
||||
|
||||
```bash
|
||||
# Drone CI 服务器
|
||||
curl http://localhost:5000/v2/_catalog
|
||||
curl http://localhost:5000/v2/douyin-backend/tags/list
|
||||
curl http://localhost:5000/v2/douyin-frontend/tags/list
|
||||
```
|
||||
|
||||
### 验证构建与部署触发
|
||||
|
||||
```bash
|
||||
# 方式1:Tag 触发
|
||||
git tag v1.9.0210.8
|
||||
git push origin v1.9.0210.8
|
||||
|
||||
# 方式2:Drone 面板手动触发 cron 或点击 NEW BUILD
|
||||
```
|
||||
|
||||
### 验证生产服务
|
||||
|
||||
```bash
|
||||
# 检查容器状态
|
||||
ssh -p 3141 miaosi@192.168.31.48 "cd /opt/docker/douyin_comments_management && docker compose -f docker-compose.prod.yml ps"
|
||||
|
||||
# 后端健康检查(容器内)
|
||||
ssh -p 3141 miaosi@192.168.31.48 "cd /opt/docker/douyin_comments_management && docker compose -f docker-compose.prod.yml exec -T backend python -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3);print('ok')\""
|
||||
|
||||
# 数据库迁移状态
|
||||
ssh -p 3141 miaosi@192.168.31.48 "cd /opt/docker/douyin_comments_management && docker compose -f docker-compose.prod.yml exec -T backend alembic current"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
| 问题 | 排查方式 |
|
||||
|------|---------|
|
||||
| YAML 解析错误 | 检查 `.drone.yml` 语法,Drone 对 `volumes`/`environment` 格式敏感 |
|
||||
| 构建失败 | Drone 面板查看 pipeline 日志 |
|
||||
| 镜像推送失败 (HTTPS) | 确认两台服务器 `insecure-registries` 配置正确(不带 `http://` 前缀) |
|
||||
| Secret 为空 | 使用 `environment: { VAR: { from_secret: name } }` 而非 `secrets` 字段 |
|
||||
| Drone 变量替换冲突 | `${DRONE_TAG}` 是 Drone 变量可直接使用;自定义 shell 变量用 `$$VAR` 转义 |
|
||||
| Tag 不触发构建 | 检查 Gitea Webhook 是否勾选"创建"事件;`.drone.yml` trigger 不要加 `cron: [name]` |
|
||||
| Step is pending | 检查 Runner 是否连通 Server;仓库是否开启 Trusted |
|
||||
| DinD 启动失败 | 改用 `docker:27-cli` + 挂载宿主机 Docker socket(本方案采用的方式) |
|
||||
| deploy 找不到脚本 | 确认 `deploy-remote.sh` 已复制到生产服务器部署目录的 `scripts/` 下 |
|
||||
| Docker 权限不足 | 生产服务器执行 `sudo usermod -aG docker <user>` 后重新登录 |
|
||||
| 镜像名 invalid reference | 检查生产服务器 `.env` 中 `DOCKER_REGISTRY` 变量是否正确设置 |
|
||||
| 数据库迁移失败 | `docker compose -f docker-compose.prod.yml logs -f backend` |
|
||||
| Cron 未触发 | 核对 Drone Cron 名称、分支是否 `main`、Schedule 是否正确 |
|
||||
|
||||
---
|
||||
|
||||
## 踩坑记录
|
||||
|
||||
以下是实际部署过程中遇到的问题及解决方案,供后续参考:
|
||||
|
||||
### 1. Drone YAML 解析错误 (`cannot unmarshal !!map into string`)
|
||||
|
||||
**原因**:Drone Docker pipeline 对 `environment` 中 `from_secret` 语法和 `volumes` 格式有特定要求。早期版本同时使用 `volumes` + `environment: from_secret` 会触发解析错误。
|
||||
|
||||
**解决**:确保仓库开启 Trusted 模式后,`volumes` 和 `environment: from_secret` 可以正常共存。
|
||||
|
||||
### 2. Tag 推送不触发构建
|
||||
|
||||
**原因**:`.drone.yml` 中同时配置了 `event: [tag, cron]` 和 `cron: [nightly-build]`,Drone 将触发条件做 AND 运算。Tag 事件无法满足 cron 条件,导致永远不触发。
|
||||
|
||||
**解决**:移除 `cron: [nightly-build]` 过滤,只保留 `event: [tag, cron]`。
|
||||
|
||||
### 3. plugins/docker DinD 启动失败
|
||||
|
||||
**原因**:`plugins/docker` 插件内部启动 Docker 守护进程(Docker-in-Docker),可能因 cgroup/存储驱动兼容性问题无法启动。
|
||||
|
||||
**解决**:放弃 `plugins/docker`,改用 `docker:27-cli` 镜像 + 挂载宿主机 Docker socket 的方式构建镜像。
|
||||
|
||||
### 4. plugins/docker 要求 semver 格式 tag
|
||||
|
||||
**原因**:`auto_tag: true` 配置要求 Git tag 符合语义化版本(如 `v1.0.0`),非标准格式(如 `v1.9.0210.1`)会解析失败。
|
||||
|
||||
**解决**:移除 `auto_tag`,改用 `${DRONE_TAG:-latest}` 手动指定镜像 tag。
|
||||
|
||||
### 5. Drone 变量替换与 Shell 变量冲突
|
||||
|
||||
**原因**:Drone 会在执行前对 `${VAR}` 语法做自身的变量替换。自定义 shell 变量(如 `TAG="xxx"; echo ${TAG}`)中的 `${TAG}` 会被 Drone 替换为空。
|
||||
|
||||
**解决**:直接使用 Drone 内置变量 `${DRONE_TAG:-latest}`,避免中间 shell 变量。
|
||||
|
||||
### 6. `secrets` 字段注入环境变量不生效
|
||||
|
||||
**原因**:Drone 步骤级 `secrets:` 字段在某些场景下不会将 secret 注入为环境变量。
|
||||
|
||||
**解决**:改用 `environment: { VAR: { from_secret: name } }` 显式声明。
|
||||
|
||||
### 7. Runner 连接 Server 失败
|
||||
|
||||
**原因**:Runner 配置 `DRONE_RPC_PROTO=https` 但 drone-server 容器内部只监听 80 端口(HTTPS 由外部反向代理终止)。
|
||||
|
||||
**解决**:Runner 通过外部域名 `drone.internal.intelligrow.cn` 连接(走反向代理的 HTTPS),而非直连容器内网。
|
||||
|
||||
### 8. 管理员权限不生效(看不到 Trusted 选项)
|
||||
|
||||
**原因**:`DRONE_USER_CREATE=username:zhanghuayu@intelligrow.ai,admin:true` 中的 username 使用了邮箱而非 Gitea 登录用户名。
|
||||
|
||||
**解决**:改为 `username:zhanghuayu,admin:true`(与 Gitea 用户名完全一致)。
|
||||
|
||||
### 9. 生产服务器镜像名 invalid reference
|
||||
|
||||
**原因**:`docker-compose.prod.yml` 使用 `${DOCKER_REGISTRY}` 变量,但 `.env` 中未设置或变量名不匹配(曾误设为 `REGISTRY_HOST`)。
|
||||
|
||||
**解决**:在 `.env` 中添加 `DOCKER_REGISTRY=docker.internal.intelligrow.cn:5000`,确保变量名与 compose 文件一致。
|
||||
|
||||
### 10. insecure-registries 格式错误
|
||||
|
||||
**原因**:`/etc/docker/daemon.json` 中 `insecure-registries` 配置了 `http://docker.internal.intelligrow.cn:5000`(带协议前缀),Docker 不识别。
|
||||
|
||||
**解决**:去掉 `http://` 前缀,直接写 `docker.internal.intelligrow.cn:5000`。
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **Registry 无认证**:仅建议内网使用;公网请加 TLS + 认证(或使用 Harbor)。
|
||||
2. **Docker Socket 挂载**:构建步骤挂载宿主机 Docker socket,需仓库开启 Trusted 模式。
|
||||
3. **并发部署保护**:`deploy-remote.sh` 使用 `/tmp/douyin-deploy.lock` 防止并发部署。
|
||||
4. **迁移失败即中断**:`alembic upgrade head` 失败会使整个部署失败,防止"假成功"。
|
||||
5. **健康检查不依赖 curl**:采用容器内 Python 请求 `/health`,与当前镜像一致。
|
||||
6. **deploy-remote.sh 需手动同步**:该脚本存放在生产服务器上,代码更新后需手动复制或通过部署流程同步。
|
||||
7. **VERSION 与 IMAGE_TAG**:`deploy-remote.sh` 同时 export 两个变量,兼容不同 compose 文件的命名。
|
||||
@@ -0,0 +1,947 @@
|
||||
# Drone CI/CD 通用部署指南
|
||||
|
||||
基于 **Drone CI + 私有 Docker Registry + Docker Compose** 的自动化部署方案。适用于任何基于 Docker 镜像部署的项目。
|
||||
|
||||
本指南基于抖音评论管理系统的实际落地经验编写,涵盖从零搭建到生产可用的全流程。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [前提条件](#前提条件)
|
||||
- [架构概览](#架构概览)
|
||||
- [第一步:基础设施准备(一次性)](#第一步基础设施准备一次性)
|
||||
- [第二步:项目接入 Drone CI](#第二步项目接入-drone-ci)
|
||||
- [第三步:编写 .drone.yml](#第三步编写-droneyml)
|
||||
- [第四步:编写部署脚本](#第四步编写部署脚本)
|
||||
- [第五步:生产服务器配置](#第五步生产服务器配置)
|
||||
- [第六步:配置 Drone 仓库设置](#第六步配置-drone-仓库设置)
|
||||
- [第七步:验证](#第七步验证)
|
||||
- [回滚方案](#回滚方案)
|
||||
- [多项目管理](#多项目管理)
|
||||
- [踩坑清单](#踩坑清单)
|
||||
- [故障排查速查表](#故障排查速查表)
|
||||
- [附录:完整模板文件](#附录完整模板文件)
|
||||
|
||||
---
|
||||
|
||||
## 前提条件
|
||||
|
||||
| 组件 | 要求 |
|
||||
|------|------|
|
||||
| Gitea 服务器 | 已有,可通过 HTTPS 访问 |
|
||||
| Drone CI 服务器 | Ubuntu x64,已安装 Docker,已部署 Drone Server + Runner |
|
||||
| 生产服务器 | Ubuntu x64,已安装 Docker + Docker Compose |
|
||||
| 项目 | 包含 Dockerfile,可通过 `docker compose` 部署 |
|
||||
|
||||
如果 Drone CI 尚未部署,参见 [附录 A:Drone CI 部署](#附录-a-drone-ci-部署)。
|
||||
|
||||
---
|
||||
|
||||
## 架构概览
|
||||
|
||||
```text
|
||||
触发方式:
|
||||
A) 创建 Git Tag(如 v1.0.0)→ 自动构建部署指定版本
|
||||
B) Drone Cron 定时任务 → 自动构建部署 latest
|
||||
|
||||
流水线:
|
||||
Drone CI 拉取代码
|
||||
|
|
||||
+-- 构建镜像 1 (如 backend) → 推送到 Registry
|
||||
+-- 构建镜像 2 (如 frontend) → 推送到 Registry
|
||||
+-- 构建镜像 N ...
|
||||
|
|
||||
v
|
||||
SSH 到生产服务器 → 执行部署脚本
|
||||
|
|
||||
+-- docker compose pull (拉取新镜像)
|
||||
+-- docker compose up -d (滚动更新)
|
||||
+-- 健康检查
|
||||
+-- 数据库迁移(如有)
|
||||
|
|
||||
v
|
||||
发送通知(企业微信/钉钉/飞书,可选)
|
||||
```
|
||||
|
||||
三台服务器各司其职:
|
||||
|
||||
| 角色 | 职责 |
|
||||
|------|------|
|
||||
| Gitea 服务器 | 代码仓库,通过 Webhook 触发 Drone |
|
||||
| Drone CI 服务器 | 执行构建 + 运行私有 Docker Registry |
|
||||
| 生产服务器 | 运行 Docker Compose 部署的生产服务 |
|
||||
|
||||
---
|
||||
|
||||
## 第一步:基础设施准备(一次性)
|
||||
|
||||
以下操作只需做一次,所有项目共享。
|
||||
|
||||
### 1.1 启动私有 Docker Registry
|
||||
|
||||
在 **Drone CI 服务器**上执行:
|
||||
|
||||
```bash
|
||||
docker run -d --name registry \
|
||||
-p 5000:5000 \
|
||||
-v /opt/registry-data:/var/lib/registry \
|
||||
--restart always \
|
||||
registry:2
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
curl http://localhost:5000/v2/_catalog
|
||||
# 预期输出: {"repositories":[]}
|
||||
```
|
||||
|
||||
### 1.2 配置 insecure-registries
|
||||
|
||||
由于 Registry 未配置 TLS,需要在 **所有需要访问 Registry 的服务器** 上配置 insecure-registries。
|
||||
|
||||
编辑 `/etc/docker/daemon.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"insecure-registries": ["<DRONE_CI_IP_OR_DOMAIN>:5000"]
|
||||
}
|
||||
```
|
||||
|
||||
然后重启 Docker:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
> **重要**:
|
||||
> - `insecure-registries` 的值 **不要** 带 `http://` 前缀,直接写 `host:port`
|
||||
> - 如果已有 `registry-mirrors` 等其他配置,合并到同一个 JSON 文件中,不要覆盖
|
||||
> - Drone CI 服务器和生产服务器 **都需要** 配置
|
||||
|
||||
**示例**(假设 Drone CI 服务器 IP 为 `192.168.1.100`,域名为 `ci.example.com`):
|
||||
|
||||
```json
|
||||
{
|
||||
"registry-mirrors": [
|
||||
"https://docker.1panel.live"
|
||||
],
|
||||
"insecure-registries": [
|
||||
"ci.example.com:5000",
|
||||
"192.168.1.100:5000"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 配置 SSH 免密登录
|
||||
|
||||
在 **Drone CI 服务器**上生成部署专用密钥:
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -C "drone-ci-deploy" -f ~/.ssh/drone_deploy -N ""
|
||||
```
|
||||
|
||||
将公钥添加到 **生产服务器**:
|
||||
|
||||
```bash
|
||||
# 如果 SSH 端口是 22
|
||||
ssh-copy-id -i ~/.ssh/drone_deploy.pub <user>@<production-server-ip>
|
||||
|
||||
# 如果 SSH 端口不是 22(如 3141)
|
||||
ssh-copy-id -i ~/.ssh/drone_deploy.pub -p <port> <user>@<production-server-ip>
|
||||
```
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/drone_deploy -p <port> <user>@<production-server-ip> "echo ok"
|
||||
```
|
||||
|
||||
### 1.4 确保生产服务器用户有 Docker 权限
|
||||
|
||||
```bash
|
||||
# 在生产服务器上
|
||||
sudo usermod -aG docker <deploy-user>
|
||||
# 需要重新登录生效
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第二步:项目接入 Drone CI
|
||||
|
||||
### 2.1 在 Drone 面板激活仓库
|
||||
|
||||
1. 打开 Drone CI 面板(如 `https://drone.example.com`)
|
||||
2. 点击 **SYNC** 同步 Gitea 仓库列表
|
||||
3. 找到目标仓库,点击 **ACTIVATE**
|
||||
|
||||
### 2.2 开启 Trusted 模式
|
||||
|
||||
仓库 Settings → General → Project Settings → 勾选 **Trusted**。
|
||||
|
||||
> 这是必须的,因为构建步骤需要挂载宿主机 Docker socket。如果看不到 Trusted 选项,说明当前用户不是 Drone 管理员。检查 Drone Server 的 `DRONE_USER_CREATE` 环境变量,`username` 必须与 Gitea 登录用户名完全一致(不是邮箱)。
|
||||
|
||||
### 2.3 配置 Secrets
|
||||
|
||||
在仓库 Settings → Secrets 添加以下密钥:
|
||||
|
||||
| Secret 名称 | 说明 | 填写示例 |
|
||||
|-------------|------|---------|
|
||||
| `image_repo_<service>` | 每个需构建的服务的完整镜像地址 | `ci.example.com:5000/myproject-backend` |
|
||||
| `deploy_host` | 生产服务器 IP 或域名 | `192.168.1.200` |
|
||||
| `deploy_user` | SSH 登录用户名 | `ubuntu` |
|
||||
| `deploy_ssh_key` | SSH **私钥**完整内容 | `-----BEGIN OPENSSH PRIVATE KEY-----...` |
|
||||
| `deploy_path` | 生产服务器上的项目部署目录 | `/opt/myproject` |
|
||||
| `wecom_webhook` | 通知 Webhook URL(可选) | `https://qyapi.weixin.qq.com/...` |
|
||||
|
||||
> **关于 `image_repo_<service>`**:每个需要构建的服务单独一个 secret。例如,项目有 backend 和 frontend,就创建 `backend_repo` 和 `frontend_repo`。Registry 地址必须与生产服务器 `.env` 中的镜像地址一致。
|
||||
|
||||
### 2.4 配置 Cron 定时构建(可选)
|
||||
|
||||
仓库 Settings → Cron Jobs 添加:
|
||||
|
||||
| 字段 | 值 | 说明 |
|
||||
|------|------|------|
|
||||
| Name | `nightly-build` | 任务名称 |
|
||||
| Branch | `main` | 构建分支 |
|
||||
| Schedule | `0 16 * * *` | UTC 16:00 = 北京时间 00:00 |
|
||||
|
||||
> Drone 使用 UTC 时区解释 Cron 表达式。北京时间 = UTC + 8。
|
||||
|
||||
---
|
||||
|
||||
## 第三步:编写 .drone.yml
|
||||
|
||||
在项目根目录创建 `.drone.yml`。
|
||||
|
||||
### 核心原则
|
||||
|
||||
1. **使用 `docker:27-cli` + 宿主机 Docker socket**,不用 `plugins/docker` DinD(避免 DinD 启动失败问题)
|
||||
2. **使用 `environment: from_secret`** 注入密钥,不用 `secrets:` 字段
|
||||
3. **使用 `${DRONE_TAG:-latest}`** 作为镜像 tag,不自定义中间变量(避免 Drone 变量替换冲突)
|
||||
4. **触发条件只用 `event`**,不叠加 `cron: [name]`(Drone 触发条件是 AND 运算)
|
||||
|
||||
### 模板
|
||||
|
||||
```yaml
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: build-and-deploy
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- tag # Git Tag 触发
|
||||
- cron # 定时触发
|
||||
|
||||
volumes:
|
||||
- name: dockersock
|
||||
host:
|
||||
path: /var/run/docker.sock
|
||||
|
||||
steps:
|
||||
# ==========================================
|
||||
# 构建步骤 - 每个服务一个 step
|
||||
# ==========================================
|
||||
- name: build-<service-1>
|
||||
image: docker:27-cli
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
environment:
|
||||
IMAGE_REPO:
|
||||
from_secret: <service-1>_repo
|
||||
commands:
|
||||
- '[ -n "$IMAGE_REPO" ] || (echo "<service-1>_repo secret is empty" && exit 1)'
|
||||
- echo "Building <service-1>, tag=${DRONE_TAG:-latest}"
|
||||
- docker build -t "$IMAGE_REPO:${DRONE_TAG:-latest}" -t "$IMAGE_REPO:latest" ./<service-1-context>
|
||||
- docker push "$IMAGE_REPO:${DRONE_TAG:-latest}"
|
||||
- docker push "$IMAGE_REPO:latest"
|
||||
|
||||
- name: build-<service-2>
|
||||
image: docker:27-cli
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
environment:
|
||||
IMAGE_REPO:
|
||||
from_secret: <service-2>_repo
|
||||
commands:
|
||||
- '[ -n "$IMAGE_REPO" ] || (echo "<service-2>_repo secret is empty" && exit 1)'
|
||||
- echo "Building <service-2>, tag=${DRONE_TAG:-latest}"
|
||||
- docker build -t "$IMAGE_REPO:${DRONE_TAG:-latest}" -t "$IMAGE_REPO:latest" ./<service-2-context>
|
||||
- docker push "$IMAGE_REPO:${DRONE_TAG:-latest}"
|
||||
- docker push "$IMAGE_REPO:latest"
|
||||
|
||||
# ==========================================
|
||||
# 部署步骤
|
||||
# ==========================================
|
||||
- name: deploy
|
||||
image: appleboy/drone-ssh
|
||||
environment:
|
||||
DEPLOY_PATH:
|
||||
from_secret: deploy_path
|
||||
settings:
|
||||
host:
|
||||
from_secret: deploy_host
|
||||
username:
|
||||
from_secret: deploy_user
|
||||
key:
|
||||
from_secret: deploy_ssh_key
|
||||
port: 22 # ← 改成实际 SSH 端口
|
||||
command_timeout: 1800s
|
||||
script_stop: true
|
||||
envs:
|
||||
- DRONE_TAG
|
||||
- DEPLOY_PATH
|
||||
script:
|
||||
- IMAGE_TAG="$DRONE_TAG"; [ -n "$IMAGE_TAG" ] || IMAGE_TAG="latest"
|
||||
- cd "$DEPLOY_PATH"
|
||||
- bash scripts/deploy-remote.sh "$IMAGE_TAG"
|
||||
|
||||
# ==========================================
|
||||
# 通知步骤(可选)
|
||||
# ==========================================
|
||||
- name: notify-success
|
||||
image: curlimages/curl
|
||||
environment:
|
||||
WEBHOOK_URL:
|
||||
from_secret: wecom_webhook
|
||||
commands:
|
||||
- |
|
||||
if [ -n "${WEBHOOK_URL:-}" ]; then
|
||||
VERSION="${DRONE_TAG:-nightly-$(date +%Y%m%d)}"
|
||||
curl -sS -X POST "$WEBHOOK_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"msgtype\":\"text\",\"text\":{\"content\":\"✅ 部署成功\nProject: ${DRONE_REPO}\n版本: ${VERSION}\n时间: $(date '+%Y-%m-%d %H:%M:%S')\"}}"
|
||||
fi
|
||||
when:
|
||||
status: [success]
|
||||
|
||||
- name: notify-failure
|
||||
image: curlimages/curl
|
||||
environment:
|
||||
WEBHOOK_URL:
|
||||
from_secret: wecom_webhook
|
||||
commands:
|
||||
- |
|
||||
if [ -n "${WEBHOOK_URL:-}" ]; then
|
||||
VERSION="${DRONE_TAG:-nightly-$(date +%Y%m%d)}"
|
||||
curl -sS -X POST "$WEBHOOK_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"msgtype\":\"text\",\"text\":{\"content\":\"❌ 部署失败\nProject: ${DRONE_REPO}\n版本: ${VERSION}\n构建: ${DRONE_BUILD_LINK}\"}}"
|
||||
fi
|
||||
when:
|
||||
status: [failure]
|
||||
```
|
||||
|
||||
**替换占位符**:
|
||||
|
||||
| 占位符 | 替换为 | 示例 |
|
||||
|--------|--------|------|
|
||||
| `<service-1>`, `<service-2>` | 你的服务名 | `backend`, `frontend`, `api`, `web` |
|
||||
| `<service-1-context>` | Docker build context 路径 | `./backend`, `./frontend`, `.` |
|
||||
| `port: 22` | 生产服务器 SSH 端口 | `22`, `3141` |
|
||||
|
||||
---
|
||||
|
||||
## 第四步:编写部署脚本
|
||||
|
||||
在项目中创建 `scripts/deploy-remote.sh`,这个脚本由 Drone SSH 步骤在生产服务器上调用。
|
||||
|
||||
### 通用模板
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# ========================================
|
||||
# 远程部署脚本 - 被 Drone CI SSH 调用
|
||||
# ========================================
|
||||
# 用法: bash scripts/deploy-remote.sh [image_tag]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------- 日志 ----------
|
||||
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') $1"; }
|
||||
|
||||
# ---------- 配置(可通过环境变量覆盖) ----------
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/opt/myproject}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
|
||||
IMAGE_TAG="${1:-latest}"
|
||||
LOCK_FILE="/tmp/$(basename "$DEPLOY_PATH")-deploy.lock"
|
||||
MAX_ATTEMPTS="${MAX_ATTEMPTS:-30}"
|
||||
HEALTH_CHECK_INTERVAL="${HEALTH_CHECK_INTERVAL:-3}"
|
||||
|
||||
# ---------- 部署锁 ----------
|
||||
cleanup_lock() { rm -f "$LOCK_FILE"; }
|
||||
|
||||
acquire_lock() {
|
||||
if [ -f "$LOCK_FILE" ]; then
|
||||
local old_pid
|
||||
old_pid=$(cat "$LOCK_FILE" 2>/dev/null || true)
|
||||
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
|
||||
log_error "已有部署进程运行中 (PID: $old_pid)"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$LOCK_FILE"
|
||||
fi
|
||||
echo "$$" > "$LOCK_FILE"
|
||||
trap cleanup_lock EXIT
|
||||
}
|
||||
|
||||
# ---------- 工具函数 ----------
|
||||
compose() {
|
||||
docker compose -f "$COMPOSE_FILE" "$@"
|
||||
}
|
||||
|
||||
# 健康检查 - 根据项目实际情况修改
|
||||
# 方式 1:容器内用 python 请求(适用于 Python 后端镜像)
|
||||
wait_healthy_python() {
|
||||
local service="$1"
|
||||
local url="${2:-http://127.0.0.1:8000/health}"
|
||||
local attempt=1
|
||||
|
||||
while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do
|
||||
if compose exec -T "$service" python -c \
|
||||
"import sys,urllib.request;urllib.request.urlopen('${url}', timeout=3);sys.exit(0)" \
|
||||
>/dev/null 2>&1; then
|
||||
log_info "${service} 健康检查通过"
|
||||
return 0
|
||||
fi
|
||||
log_info "等待 ${service} 就绪... (${attempt}/${MAX_ATTEMPTS})"
|
||||
sleep "$HEALTH_CHECK_INTERVAL"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 方式 2:容器内用 curl 请求(适用于内置 curl 的镜像)
|
||||
wait_healthy_curl() {
|
||||
local service="$1"
|
||||
local url="${2:-http://127.0.0.1:8000/health}"
|
||||
local attempt=1
|
||||
|
||||
while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do
|
||||
if compose exec -T "$service" curl -sf "$url" >/dev/null 2>&1; then
|
||||
log_info "${service} 健康检查通过"
|
||||
return 0
|
||||
fi
|
||||
log_info "等待 ${service} 就绪... (${attempt}/${MAX_ATTEMPTS})"
|
||||
sleep "$HEALTH_CHECK_INTERVAL"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 方式 3:从宿主机请求(适用于有端口映射的服务)
|
||||
wait_healthy_host() {
|
||||
local url="${1:-http://127.0.0.1:8000/health}"
|
||||
local attempt=1
|
||||
|
||||
while [ "$attempt" -le "$MAX_ATTEMPTS" ]; do
|
||||
if curl -sf "$url" >/dev/null 2>&1; then
|
||||
log_info "健康检查通过: $url"
|
||||
return 0
|
||||
fi
|
||||
log_info "等待服务就绪... (${attempt}/${MAX_ATTEMPTS})"
|
||||
sleep "$HEALTH_CHECK_INTERVAL"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------- 主流程 ----------
|
||||
main() {
|
||||
acquire_lock
|
||||
|
||||
[ -d "$DEPLOY_PATH" ] || { log_error "部署目录不存在: $DEPLOY_PATH"; exit 1; }
|
||||
cd "$DEPLOY_PATH"
|
||||
[ -f "$COMPOSE_FILE" ] || { log_error "Compose 文件不存在: $COMPOSE_FILE"; exit 1; }
|
||||
|
||||
# 导出镜像 tag 变量 - 根据 compose 文件中使用的变量名按需调整
|
||||
export IMAGE_TAG
|
||||
export VERSION="$IMAGE_TAG"
|
||||
# export TAG="$IMAGE_TAG" # 如果你的 compose 用 ${TAG}
|
||||
|
||||
log_info "========== 开始部署 =========="
|
||||
log_info "镜像版本: $IMAGE_TAG"
|
||||
log_info "部署目录: $DEPLOY_PATH"
|
||||
log_info "Compose: $COMPOSE_FILE"
|
||||
|
||||
# --- 1. 拉取新镜像 ---
|
||||
log_info "[1/5] 拉取新镜像..."
|
||||
compose pull
|
||||
# 或者只拉取特定服务: compose pull backend frontend
|
||||
|
||||
# --- 2. 滚动更新服务 ---
|
||||
log_info "[2/5] 更新服务..."
|
||||
compose up -d --remove-orphans
|
||||
# 如果需要控制更新顺序,拆分为多步:
|
||||
# compose up -d --no-deps backend
|
||||
# compose up -d --no-deps frontend
|
||||
|
||||
# --- 3. 健康检查 ---
|
||||
log_info "[3/5] 健康检查..."
|
||||
# 根据你的项目选择合适的健康检查方式,以下是示例:
|
||||
# wait_healthy_python "backend" "http://127.0.0.1:8000/health"
|
||||
# wait_healthy_curl "api" "http://127.0.0.1:3000/health"
|
||||
# wait_healthy_host "http://127.0.0.1:8080/health"
|
||||
#
|
||||
# 如果健康检查失败,取消注释下面的代码:
|
||||
# if ! wait_healthy_python "backend"; then
|
||||
# log_error "健康检查失败"
|
||||
# compose logs --tail=200 backend || true
|
||||
# exit 1
|
||||
# fi
|
||||
|
||||
# --- 4. 数据库迁移(如有) ---
|
||||
log_info "[4/5] 数据库迁移..."
|
||||
# 根据项目使用的迁移工具选择:
|
||||
# compose exec -T backend alembic upgrade head # Python Alembic
|
||||
# compose exec -T api npx prisma migrate deploy # Node Prisma
|
||||
# compose exec -T api npm run migrate # 自定义脚本
|
||||
# compose exec -T backend python manage.py migrate # Django
|
||||
# 如果不需要迁移,注释掉即可
|
||||
|
||||
# --- 5. 清理 ---
|
||||
log_info "[5/5] 清理旧镜像..."
|
||||
docker image prune -f >/dev/null 2>&1 || log_warn "镜像清理失败,已跳过"
|
||||
|
||||
log_info "========== 部署完成 =========="
|
||||
log_info "版本: $IMAGE_TAG"
|
||||
compose ps
|
||||
}
|
||||
|
||||
main "$@"
|
||||
```
|
||||
|
||||
### 脚本要点
|
||||
|
||||
1. **部署锁**:通过 PID 文件防止并发部署
|
||||
2. **`set -euo pipefail`**:任何命令失败立即退出,防止"假成功"
|
||||
3. **变量导出**:同时 export `IMAGE_TAG` 和 `VERSION`,兼容不同 compose 文件的命名习惯
|
||||
4. **健康检查**:提供三种方式,根据项目镜像内置的工具选择
|
||||
5. **数据库迁移**:在健康检查通过后执行,迁移失败会中断部署
|
||||
|
||||
---
|
||||
|
||||
## 第五步:生产服务器配置
|
||||
|
||||
### 5.1 目录结构
|
||||
|
||||
确保生产服务器上部署目录结构如下:
|
||||
|
||||
```text
|
||||
/opt/myproject/ # DEPLOY_PATH
|
||||
├── docker-compose.prod.yml # 生产 compose 配置
|
||||
├── .env # 环境变量(镜像地址、数据库密码等)
|
||||
└── scripts/
|
||||
└── deploy-remote.sh # 部署脚本(从代码仓库复制)
|
||||
```
|
||||
|
||||
### 5.2 docker-compose.prod.yml 中的镜像引用
|
||||
|
||||
compose 文件中使用变量引用 Registry 中的镜像:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
image: ${DOCKER_REGISTRY}/myproject-backend:${VERSION:-latest}
|
||||
# ...
|
||||
|
||||
frontend:
|
||||
image: ${DOCKER_REGISTRY}/myproject-frontend:${VERSION:-latest}
|
||||
# ...
|
||||
```
|
||||
|
||||
### 5.3 .env 文件
|
||||
|
||||
```bash
|
||||
# 镜像仓库地址 - 必须与 Drone Secret 中的 image_repo 前缀一致
|
||||
DOCKER_REGISTRY=ci.example.com:5000
|
||||
|
||||
# 其他生产环境配置
|
||||
POSTGRES_PASSWORD=xxx
|
||||
REDIS_PASSWORD=xxx
|
||||
# ...
|
||||
```
|
||||
|
||||
> **变量一致性**:假设 Drone Secret `backend_repo` = `ci.example.com:5000/myproject-backend`,那么 `.env` 中的 `DOCKER_REGISTRY` 必须是 `ci.example.com:5000`,compose 中的镜像名必须是 `${DOCKER_REGISTRY}/myproject-backend:${VERSION:-latest}`。三者拼起来的镜像全名必须完全一致。
|
||||
|
||||
### 5.4 首次部署
|
||||
|
||||
首次部署需要手动初始化:
|
||||
|
||||
```bash
|
||||
cd /opt/myproject
|
||||
|
||||
# 确认 .env 和 compose 文件就位
|
||||
ls -la .env docker-compose.prod.yml
|
||||
|
||||
# 手动拉取并启动
|
||||
export VERSION=latest
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# 运行数据库迁移(如有)
|
||||
docker compose -f docker-compose.prod.yml exec -T backend alembic upgrade head
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第六步:配置 Drone 仓库设置
|
||||
|
||||
### Secrets 快速清单
|
||||
|
||||
针对你的新项目,在 Drone 面板创建以下 Secrets:
|
||||
|
||||
```text
|
||||
backend_repo = <registry-host>:5000/<project>-backend
|
||||
frontend_repo = <registry-host>:5000/<project>-frontend # 如果有
|
||||
deploy_host = <production-server-ip>
|
||||
deploy_user = <ssh-username>
|
||||
deploy_ssh_key = <SSH 私钥完整内容,cat ~/.ssh/drone_deploy>
|
||||
deploy_path = /opt/<project>
|
||||
wecom_webhook = <通知 webhook url> # 可选
|
||||
```
|
||||
|
||||
> `deploy_ssh_key` 是第一步中生成的 SSH 私钥内容,所有项目可以共用同一个密钥(只要目标生产服务器相同)。
|
||||
|
||||
### Cron 配置
|
||||
|
||||
如果需要定时构建:
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|------|
|
||||
| Name | `nightly-build` |
|
||||
| Branch | `main` |
|
||||
| Schedule | `0 16 * * *`(北京时间 00:00) |
|
||||
|
||||
---
|
||||
|
||||
## 第七步:验证
|
||||
|
||||
### 7.1 Registry 验证
|
||||
|
||||
```bash
|
||||
# 在 Drone CI 服务器上
|
||||
curl http://localhost:5000/v2/_catalog
|
||||
```
|
||||
|
||||
### 7.2 Tag 触发构建
|
||||
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
在 Drone 面板观察 pipeline 执行状态。
|
||||
|
||||
### 7.3 生产服务验证
|
||||
|
||||
```bash
|
||||
# 检查容器状态
|
||||
ssh -p <port> <user>@<prod-ip> "cd /opt/myproject && docker compose -f docker-compose.prod.yml ps"
|
||||
|
||||
# 检查镜像版本
|
||||
ssh -p <port> <user>@<prod-ip> "docker images | grep myproject"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 回滚方案
|
||||
|
||||
### 方式 1:手动指定版本回滚
|
||||
|
||||
```bash
|
||||
ssh -p <port> <user>@<prod-ip>
|
||||
cd /opt/myproject
|
||||
bash scripts/deploy-remote.sh v1.0.0 # 指定要回滚到的版本
|
||||
```
|
||||
|
||||
### 方式 2:直接 compose 回滚
|
||||
|
||||
```bash
|
||||
ssh -p <port> <user>@<prod-ip>
|
||||
cd /opt/myproject
|
||||
export VERSION=v1.0.0
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 方式 3:通过 Git Tag 触发
|
||||
|
||||
```bash
|
||||
git tag v1.0.0-rollback v1.0.0
|
||||
git push origin v1.0.0-rollback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 多项目管理
|
||||
|
||||
当多个项目共用同一套 Drone CI + Registry 基础设施时:
|
||||
|
||||
### 共享部分(不需要重复)
|
||||
|
||||
- Drone CI Server + Runner(已部署)
|
||||
- Docker Registry(已运行在 :5000)
|
||||
- SSH 密钥(可共用同一个 `drone_deploy` 密钥)
|
||||
- `insecure-registries` 配置(已在两台服务器上配置)
|
||||
|
||||
### 每个新项目需要做的
|
||||
|
||||
1. 在 Drone 面板激活仓库 + 开启 Trusted
|
||||
2. 在 Drone 面板添加该仓库的 Secrets(镜像地址、部署路径)
|
||||
3. 项目代码中添加 `.drone.yml` 和 `scripts/deploy-remote.sh`
|
||||
4. 生产服务器创建部署目录 + 放置 compose 文件和 `.env`
|
||||
5. (可选)配置 Cron 定时构建
|
||||
|
||||
### Registry 镜像命名规范
|
||||
|
||||
建议统一命名格式:
|
||||
|
||||
```text
|
||||
<registry-host>:5000/<project-name>-<service>:<tag>
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
ci.example.com:5000/douyin-backend:v1.0.0
|
||||
ci.example.com:5000/douyin-frontend:v1.0.0
|
||||
ci.example.com:5000/crm-api:v2.1.0
|
||||
ci.example.com:5000/crm-web:v2.1.0
|
||||
ci.example.com:5000/blog-app:latest
|
||||
```
|
||||
|
||||
查看所有仓库:
|
||||
|
||||
```bash
|
||||
curl http://localhost:5000/v2/_catalog
|
||||
```
|
||||
|
||||
查看某个镜像的所有 tag:
|
||||
|
||||
```bash
|
||||
curl http://localhost:5000/v2/<project>-<service>/tags/list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 踩坑清单
|
||||
|
||||
以下是实际部署中遇到的问题,按出现频率排序:
|
||||
|
||||
### 1. `insecure-registries` 格式错误
|
||||
|
||||
**错误**:Docker push 报 `server gave HTTP response to HTTPS client`
|
||||
|
||||
**原因**:`/etc/docker/daemon.json` 中写了 `http://host:5000`
|
||||
|
||||
**正确写法**:直接写 `host:5000`,不带协议前缀
|
||||
|
||||
### 2. Drone 变量替换冲突
|
||||
|
||||
**错误**:构建命令中的 shell 变量值为空
|
||||
|
||||
**原因**:Drone 在执行命令前会对 `${VAR}` 做自身的变量替换,自定义 shell 变量 `${TAG}` 被 Drone 替换为空
|
||||
|
||||
**解决**:直接使用 Drone 内置变量 `${DRONE_TAG:-latest}`,不要赋值给中间变量
|
||||
|
||||
### 3. Secret 注入不生效
|
||||
|
||||
**错误**:环境变量为空,步骤因 secret 缺失而失败
|
||||
|
||||
**原因**:使用了步骤级 `secrets:` 字段
|
||||
|
||||
**解决**:改用 `environment: { VAR: { from_secret: name } }`
|
||||
|
||||
### 4. plugins/docker DinD 启动失败
|
||||
|
||||
**错误**:`Unable to reach Docker Daemon after 15 attempts`
|
||||
|
||||
**原因**:`plugins/docker` 内部启动 Docker 守护进程失败(cgroup / 存储驱动兼容性)
|
||||
|
||||
**解决**:使用 `docker:27-cli` + 挂载宿主机 `/var/run/docker.sock`
|
||||
|
||||
### 5. Trusted 选项不可见
|
||||
|
||||
**错误**:仓库 Settings 中找不到 Trusted 选项
|
||||
|
||||
**原因**:`DRONE_USER_CREATE` 的 `username` 填了邮箱,不是 Gitea 用户名
|
||||
|
||||
**解决**:`username` 必须与 Gitea 登录用户名完全一致
|
||||
|
||||
### 6. Tag 触发与 Cron 触发互相排斥
|
||||
|
||||
**错误**:推送 Tag 后 Drone 不触发构建
|
||||
|
||||
**原因**:`.drone.yml` 同时配了 `event: [tag, cron]` 和 `cron: [nightly-build]`,Drone 触发条件是 **AND** 运算
|
||||
|
||||
**解决**:只保留 `event: [tag, cron]`,不加 `cron:` 过滤
|
||||
|
||||
### 7. 生产服务器镜像名不匹配
|
||||
|
||||
**错误**:`docker compose pull` 报 invalid reference 或拉取到错误镜像
|
||||
|
||||
**原因**:Drone Secret 中的 Registry 地址(如 IP)与生产服务器 `.env` 中的(如域名)不一致
|
||||
|
||||
**解决**:统一使用同一个地址格式(建议统一用域名或统一用 IP)
|
||||
|
||||
### 8. SSH 端口不对
|
||||
|
||||
**错误**:deploy 步骤超时或连接拒绝
|
||||
|
||||
**原因**:`appleboy/drone-ssh` 默认使用 22 端口
|
||||
|
||||
**解决**:在 `.drone.yml` 的 deploy settings 中显式指定 `port: <actual-port>`
|
||||
|
||||
### 9. Docker 权限不足
|
||||
|
||||
**错误**:生产服务器 `permission denied while trying to connect to the Docker daemon socket`
|
||||
|
||||
**解决**:`sudo usermod -aG docker <user>` 后重新登录
|
||||
|
||||
### 10. daemon.json 被覆盖
|
||||
|
||||
**错误**:修改 `insecure-registries` 时丢失了已有的 `registry-mirrors` 配置
|
||||
|
||||
**预防**:修改前先 `cat /etc/docker/daemon.json` 查看现有内容,合并修改
|
||||
|
||||
---
|
||||
|
||||
## 故障排查速查表
|
||||
|
||||
| 现象 | 检查方向 |
|
||||
|------|---------|
|
||||
| Pipeline 不触发 | Gitea Webhook 是否勾选"创建"事件;`.drone.yml` trigger 配置 |
|
||||
| Step 一直 pending | Runner 是否连通 Server;仓库是否 Trusted |
|
||||
| 构建报 secret 为空 | 使用 `environment: from_secret` 而非 `secrets:` |
|
||||
| Docker build 失败 | Dockerfile 是否正确;build context 路径是否对 |
|
||||
| Docker push 失败 (HTTPS) | 两台服务器 `insecure-registries` 配置 |
|
||||
| Docker push 失败 (连接拒绝) | Registry 容器是否运行:`docker ps \| grep registry` |
|
||||
| SSH 部署失败 | 密钥是否正确;端口是否匹配;用户是否有 Docker 权限 |
|
||||
| 找不到部署脚本 | `deploy-remote.sh` 是否已复制到生产服务器的对应目录 |
|
||||
| 镜像名 invalid reference | 生产服务器 `.env` 的 `DOCKER_REGISTRY` 变量是否正确 |
|
||||
| compose pull 拉到旧镜像 | 检查 Registry 地址和镜像 tag 是否一致 |
|
||||
| 数据库迁移失败 | `docker compose logs -f <service>` 查看报错 |
|
||||
| 健康检查超时 | 增大 `MAX_ATTEMPTS`;检查服务是否正常启动 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:Drone CI 部署
|
||||
|
||||
如果 Drone CI 尚未部署,在 Drone CI 服务器上创建 `~/drone/docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
drone-server:
|
||||
image: drone/drone:2
|
||||
container_name: drone-server
|
||||
restart: always
|
||||
ports:
|
||||
- "3080:80" # Drone Web UI 端口,通过反向代理暴露 HTTPS
|
||||
environment:
|
||||
- DRONE_GITEA_SERVER=https://<your-gitea-domain>
|
||||
- DRONE_GITEA_CLIENT_ID=<gitea-oauth-app-client-id>
|
||||
- DRONE_GITEA_CLIENT_SECRET=<gitea-oauth-app-client-secret>
|
||||
- DRONE_SERVER_HOST=<your-drone-domain>
|
||||
- DRONE_SERVER_PROTO=https
|
||||
- DRONE_RPC_SECRET=<随机生成的长字符串>
|
||||
- DRONE_USER_CREATE=username:<your-gitea-username>,admin:true
|
||||
volumes:
|
||||
- ./data:/data
|
||||
|
||||
drone-runner:
|
||||
image: drone/drone-runner-docker:1
|
||||
container_name: drone-runner
|
||||
restart: always
|
||||
depends_on:
|
||||
- drone-server
|
||||
environment:
|
||||
# Runner 通过 Docker 内网直连 server 的 80 端口
|
||||
- DRONE_RPC_PROTO=http
|
||||
- DRONE_RPC_HOST=drone-server
|
||||
- DRONE_RPC_SECRET=<与 server 相同的 secret>
|
||||
- DRONE_RUNNER_CAPACITY=2
|
||||
- DRONE_RUNNER_NAME=drone-runner-1
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
```
|
||||
|
||||
启动:
|
||||
|
||||
```bash
|
||||
cd ~/drone
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Gitea OAuth App 配置
|
||||
|
||||
1. Gitea → 站点管理 → 应用 → 创建 OAuth2 应用
|
||||
2. 重定向 URI:`https://<your-drone-domain>/login`
|
||||
3. 将 Client ID 和 Client Secret 填入上面的 compose 配置
|
||||
|
||||
### 生成 RPC Secret
|
||||
|
||||
```bash
|
||||
openssl rand -hex 16
|
||||
```
|
||||
|
||||
### 反向代理配置(Nginx 示例)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name drone.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3080;
|
||||
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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录 B:新项目接入 Checklist
|
||||
|
||||
为新项目接入 CI/CD 时,按此清单逐项完成:
|
||||
|
||||
```text
|
||||
□ 基础设施(一次性,已完成则跳过)
|
||||
□ Registry 运行中
|
||||
□ insecure-registries 已配置(Drone CI 服务器 + 生产服务器)
|
||||
□ SSH 密钥已配置
|
||||
|
||||
□ Drone 面板
|
||||
□ 仓库已激活(ACTIVATE)
|
||||
□ Trusted 已勾选
|
||||
□ Secrets 已添加(image_repo, deploy_host, deploy_user, deploy_ssh_key, deploy_path)
|
||||
□ Cron Job 已配置(如需定时构建)
|
||||
|
||||
□ 项目代码
|
||||
□ .drone.yml 已创建
|
||||
□ scripts/deploy-remote.sh 已创建
|
||||
|
||||
□ 生产服务器
|
||||
□ 部署目录已创建
|
||||
□ docker-compose.prod.yml 已放置
|
||||
□ .env 已配置(DOCKER_REGISTRY 等)
|
||||
□ scripts/deploy-remote.sh 已复制到部署目录
|
||||
□ 首次手动部署成功
|
||||
|
||||
□ 验证
|
||||
□ 推送 Tag 后 Drone 自动构建并部署成功
|
||||
□ 生产服务正常运行
|
||||
□ 回滚流程测试通过
|
||||
```
|
||||
@@ -0,0 +1,901 @@
|
||||
# TikHub API 参考文档
|
||||
|
||||
> 版本:V5.2.9(2025-03-08)
|
||||
> Swagger UI:https://api.tikhub.io
|
||||
> ReDoc:https://api.tikhub.io/docs
|
||||
> Apifox 文档:https://docs.tikhub.io
|
||||
|
||||
---
|
||||
|
||||
## 基础信息
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| Base URL(国际)| `https://api.tikhub.io` |
|
||||
| Base URL(中国大陆)| `https://api.tikhub.dev`(路径和参数完全相同,仅域名不同)|
|
||||
| 认证方式 | `Authorization: Bearer {API_TOKEN}` |
|
||||
| 计费 | $0.001 / 请求(按量付费,支持批量折扣)|
|
||||
| 速率限制 | 令牌桶 10 req/s(本项目在 `src/lib/tikhub.ts` 中已实现)|
|
||||
| 超时 | 15s(本项目已配置)|
|
||||
|
||||
所有端点均为 `GET` 请求(除特别标注),返回 JSON。
|
||||
|
||||
---
|
||||
|
||||
## 错误码
|
||||
|
||||
| HTTP 状态码 | 含义 |
|
||||
|------------|------|
|
||||
| 400 | 参数错误 |
|
||||
| 401 | API Key 无效或未提供 |
|
||||
| 402 | 余额不足 |
|
||||
| 403 | 无权限访问该端点 |
|
||||
| 404 | 内容不存在 |
|
||||
| 429 | 请求过于频繁 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
---
|
||||
|
||||
## 抖音(Douyin)
|
||||
|
||||
### 抖音网页版 API — `/api/v1/douyin/web/`
|
||||
|
||||
#### ★ `GET /api/v1/douyin/web/fetch_hot_search_result`
|
||||
|
||||
> **本项目使用** · 抖音热搜榜
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 无 | — | — | 无需参数 |
|
||||
|
||||
响应结构:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"data": {
|
||||
"trending_list": [
|
||||
{
|
||||
"sentence_id": "string", // 热搜词 ID(用作 ContentItem.id)
|
||||
"word": "string", // 热搜词文本
|
||||
"word_cover": {
|
||||
"url_list": ["string"] // 封面图列表
|
||||
},
|
||||
"hot_value": 10000, // 热度值(→ like_count)
|
||||
"view_count": 500000, // 阅读量(→ play_count)
|
||||
"discuss_video_count": 200, // 讨论视频数(→ comment_count)
|
||||
"event_time": 1709000000 // Unix 时间戳(秒)
|
||||
}
|
||||
],
|
||||
"word_list": [/* 同上格式,普通热词 */]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`trending_list` 为置顶热搜,`word_list` 为普通热词。本项目合并两者并按 `sentence_id` 去重。
|
||||
|
||||
---
|
||||
|
||||
#### ★ `GET /api/v1/douyin/web/fetch_one_video`
|
||||
|
||||
> **本项目使用** · 抖音视频详情
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `aweme_id` | string | 是 | 视频 ID |
|
||||
|
||||
响应结构:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"aweme_detail": {
|
||||
"aweme_id": "string",
|
||||
"desc": "string", // 视频标题/描述
|
||||
"video": {
|
||||
"cover": { "url_list": ["string"] },
|
||||
"play_addr": { "url_list": ["string"] }
|
||||
},
|
||||
"author": {
|
||||
"nickname": "string",
|
||||
"avatar_thumb": { "url_list": ["string"] }
|
||||
},
|
||||
"statistics": {
|
||||
"play_count": 100000,
|
||||
"digg_count": 5000, // 点赞数(→ like_count)
|
||||
"comment_count": 200,
|
||||
"share_count": 300
|
||||
},
|
||||
"create_time": 1709000000,
|
||||
"text_extra": [{ "hashtag_name": "string" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 其他常用端点
|
||||
|
||||
| 端点 | 说明 | 关键参数 |
|
||||
|------|------|----------|
|
||||
| `GET /api/v1/douyin/web/fetch_user_profile` | 获取用户信息 | `unique_id`(用户名)或 `sec_user_id` |
|
||||
| `GET /api/v1/douyin/web/fetch_user_post` | 获取用户作品 | `sec_user_id`, `cursor`(分页), `count` |
|
||||
| `GET /api/v1/douyin/web/fetch_general_search_result` | 综合搜索 | `keyword`, `count`, `offset` |
|
||||
| `GET /api/v1/douyin/web/fetch_video_search_result` | 视频搜索 | `keyword`, `count`, `offset` |
|
||||
| `GET /api/v1/douyin/web/fetch_user_search_result_v2` | 用户搜索 | `keyword` |
|
||||
| `GET /api/v1/douyin/web/fetch_post_comment` | 获取评论 | `aweme_id`, `cursor`, `count` |
|
||||
| `GET /api/v1/douyin/app/v3/fetch_hot_search_list` | App 热搜榜(App V3 推荐)| 无 |
|
||||
| `GET /api/v1/douyin/billboard/*` | 各类榜单(热门/音乐/挑战等)| 参见 Swagger |
|
||||
|
||||
---
|
||||
|
||||
## TikTok
|
||||
|
||||
### TikTok 网页版 API — `/api/v1/tiktok/web/`
|
||||
|
||||
#### ★ `GET /api/v1/tiktok/web/fetch_explore_post`
|
||||
|
||||
> **本项目使用** · TikTok 探索/热门视频列表
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `count` | integer | 否 | 返回数量,默认 20 |
|
||||
|
||||
响应结构:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"itemList": [
|
||||
{
|
||||
"id": "string", // 视频 ID
|
||||
"desc": "string", // 视频描述/标题
|
||||
"video": {
|
||||
"cover": "string", // 封面图 URL(直接字符串)
|
||||
"playAddr": "string" // 播放地址
|
||||
},
|
||||
"author": {
|
||||
"nickname": "string",
|
||||
"avatarThumb": "string", // 头像 URL
|
||||
"uniqueId": "string" // 用户名(用于构建主页链接)
|
||||
},
|
||||
"stats": {
|
||||
"playCount": 500000,
|
||||
"diggCount": 25000, // 点赞数(→ like_count)
|
||||
"commentCount": 1000,
|
||||
"shareCount": 500
|
||||
},
|
||||
"createTime": 1709000000, // Unix 时间戳(秒)
|
||||
"challenges": [{ "title": "string" }] // 话题标签(→ tags)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`video.cover` 是直接字符串,不是数组(与抖音不同)。
|
||||
|
||||
---
|
||||
|
||||
#### ★ `GET /api/v1/tiktok/web/fetch_post_detail`
|
||||
|
||||
> **本项目使用** · TikTok 视频详情
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `itemId` | string | 是 | 视频 ID |
|
||||
|
||||
响应结构:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"itemInfo": {
|
||||
"itemStruct": { /* 同 itemList 中的单条格式 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 其他常用端点
|
||||
|
||||
| 端点 | 说明 | 关键参数 |
|
||||
|------|------|----------|
|
||||
| `GET /api/v1/tiktok/web/fetch_user_profile` | 用户信息 | `uniqueId` 或 `secUid` |
|
||||
| `GET /api/v1/tiktok/web/fetch_user_post` | 用户作品 | `secUid`, `cursor`, `count`, `post_item_list_request_type`(0=默认/1=热门/2=最旧) |
|
||||
| `GET /api/v1/tiktok/web/fetch_user_like` | 用户喜欢(需公开)| `secUid`, `cursor`, `count` |
|
||||
| `GET /api/v1/tiktok/web/fetch_post_comment` | 视频评论 | `aweme_id`, `cursor`, `count` |
|
||||
| `GET /api/v1/tiktok/web/fetch_search_video` | 视频搜索 | `keyword`, `count`, `offset`, `search_id` |
|
||||
| `GET /api/v1/tiktok/web/fetch_search_user` | 用户搜索 | `keyword`, `cursor`, `search_id` |
|
||||
| `GET /api/v1/tiktok/web/fetch_tag_detail` | 话题详情 | `tag_name` |
|
||||
| `GET /api/v1/tiktok/web/fetch_user_fans` | 粉丝列表 | `secUid`, `count`(默认30) |
|
||||
| `GET /api/v1/tiktok/web/fetch_user_follow` | 关注列表 | `secUid`, `count`(默认30) |
|
||||
| `POST /api/v1/tiktok/web/fetch_home_feed` | 首页推荐 | `count`, `cookie` |
|
||||
|
||||
---
|
||||
|
||||
## 小红书(Xiaohongshu)
|
||||
|
||||
### 小红书 Web V2 API — `/api/v1/xiaohongshu/web_v2/`
|
||||
|
||||
#### ★ `GET /api/v1/xiaohongshu/web_v2/fetch_hot_list`
|
||||
|
||||
> **本项目使用** · 获取热榜关键词列表(第一步)
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 无 | — | — | 无需参数 |
|
||||
|
||||
响应结构:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"data": {
|
||||
"items": [
|
||||
{ "title": "string" } // 热词标题,取第一个作为搜索关键词
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 小红书 App V2 API — `/api/v1/xiaohongshu/app_v2/`(推荐)
|
||||
|
||||
#### ★ `GET /api/v1/xiaohongshu/app_v2/search_notes`
|
||||
|
||||
> **本项目使用** · 按关键词搜索笔记(第二步)
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `keyword` | string | 是 | 搜索关键词 |
|
||||
| `page` | integer | 否 | 页码,默认 1 |
|
||||
|
||||
响应结构:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"model_type": "note", // 只处理 model_type==='note' 的条目
|
||||
"note": {
|
||||
"id": "string",
|
||||
"title": "string",
|
||||
"desc": "string",
|
||||
"type": "normal | video",
|
||||
"timestamp": 1709000000,
|
||||
"images_list": [
|
||||
{
|
||||
"url": "string",
|
||||
"url_size_large": "string" // 优先使用大图
|
||||
}
|
||||
],
|
||||
"user": {
|
||||
"nickname": "string",
|
||||
"images": "string", // 头像 URL
|
||||
"user_id": "string"
|
||||
},
|
||||
"liked_count": 5000, // 点赞数(数字类型)
|
||||
"comments_count": 200, // 评论数(注意:有 's')
|
||||
"shared_count": 100,
|
||||
"collected_count": 300,
|
||||
"tag_info": [{ "name": "string" }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:
|
||||
> - `model_type` 不为 `"note"` 的条目(如广告)需过滤掉
|
||||
> - 搜索结果中视频笔记的 `video_url` 不可用,仅详情接口可获取视频链接
|
||||
|
||||
---
|
||||
|
||||
#### ★ `GET /api/v1/xiaohongshu/app_v2/get_mixed_note_detail`
|
||||
|
||||
> **本项目使用** · 获取笔记详情
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `note_id` | string | 是 | 笔记 ID |
|
||||
|
||||
响应结构:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "string",
|
||||
"title": "string",
|
||||
"name": "string", // 备用标题字段
|
||||
"desc": "string",
|
||||
"type": "normal | video",
|
||||
"timestamp": 1709000000,
|
||||
"likes": 5000, // 点赞数(注意:详情与搜索字段名不同)
|
||||
"comments_count": 200,
|
||||
"shared_count": 100,
|
||||
"collected_count": 300,
|
||||
"images_list": [
|
||||
{ "url": "string", "url_size_large": "string" }
|
||||
],
|
||||
"user": {
|
||||
"nickname": "string",
|
||||
"images": "string"
|
||||
},
|
||||
"tag_info": [{ "name": "string" }],
|
||||
"video_info_v2": {
|
||||
"url": "string" // 仅视频笔记有此字段
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 其他常用端点
|
||||
|
||||
| 端点 | 说明 | 关键参数 |
|
||||
|------|------|----------|
|
||||
| `GET /api/v1/xiaohongshu/app_v2/fetch_note_detail` | 笔记详情(旧版)| `note_id` |
|
||||
| `GET /api/v1/xiaohongshu/web_v2/fetch_user_info` | 用户信息 | `user_id` 或 `username` |
|
||||
| `GET /api/v1/xiaohongshu/web_v2/fetch_user_notes` | 用户笔记列表 | `user_id`, `cursor` |
|
||||
| `GET /api/v1/xiaohongshu/app_v2/fetch_feed` | 推荐信息流 | `count` |
|
||||
|
||||
---
|
||||
|
||||
## 哔哩哔哩(Bilibili)
|
||||
|
||||
### Bilibili 网页版 API — `/api/v1/bilibili/web/`
|
||||
|
||||
#### ★ `GET /api/v1/bilibili/web/fetch_popular_video_list`
|
||||
|
||||
> **本项目使用** · 综合热门视频列表
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `pn` | integer | 否 | 页码,默认 1 |
|
||||
| `ps` | integer | 否 | 每页数量,默认 20 |
|
||||
|
||||
响应结构:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"bvid": "string", // 视频 BV 号(主要 ID)
|
||||
"aid": 123456, // av 号(备用)
|
||||
"title": "string",
|
||||
"pic": "string", // 封面图 URL(直接字符串)
|
||||
"desc": "string",
|
||||
"owner": {
|
||||
"mid": 123456,
|
||||
"name": "string",
|
||||
"face": "string" // UP 主头像
|
||||
},
|
||||
"stat": {
|
||||
"view": 100000, // 播放量(→ play_count)
|
||||
"like": 5000, // 点赞数
|
||||
"reply": 200, // 评论数(→ comment_count)
|
||||
"share": 300,
|
||||
"danmaku": 500, // 弹幕数(未映射)
|
||||
"favorite": 1000, // 收藏数(未映射)
|
||||
"coin": 800 // 投币数(未映射)
|
||||
},
|
||||
"pubdate": 1709000000, // Unix 时间戳(秒)
|
||||
"duration": 300, // 时长(秒)
|
||||
"tname": "string", // 分区名(→ tags[0])
|
||||
"tags": [{ "tag_id": 1, "tag_name": "string" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### ★ `GET /api/v1/bilibili/web/fetch_video_detail`
|
||||
|
||||
> **本项目使用** · 视频详情
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `bvid` | string | 是 | 视频 BV 号(如 `BV1xx411c7mD`)|
|
||||
|
||||
响应结构:
|
||||
```json
|
||||
{
|
||||
"data": { /* 同 list 中单条格式 */ }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 其他常用端点
|
||||
|
||||
| 端点 | 说明 | 关键参数 |
|
||||
|------|------|----------|
|
||||
| `GET /api/v1/bilibili/web/fetch_hot_search` | 热搜词 | 无 |
|
||||
| `GET /api/v1/bilibili/web/fetch_user_info` | UP 主信息 | `mid`(用户 ID)|
|
||||
| `GET /api/v1/bilibili/web/fetch_user_videos` | UP 主视频列表 | `mid`, `pn`, `ps` |
|
||||
| `GET /api/v1/bilibili/web/fetch_video_comment` | 视频评论 | `oid`(aid), `pn`, `ps` |
|
||||
| `GET /api/v1/bilibili/web/fetch_ranking` | 全站排行榜(每日更新)| `tid`(分区ID), `day`(1/3/7) |
|
||||
|
||||
---
|
||||
|
||||
## 微博(Weibo)
|
||||
|
||||
### 微博 App API — `/api/v1/weibo/app/`
|
||||
|
||||
#### ★ `GET /api/v1/weibo/app/fetch_hot_search`
|
||||
|
||||
> **本项目使用** · 微博热搜榜
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 无 | — | — | 无需参数 |
|
||||
|
||||
响应结构(两种格式,取其一):
|
||||
|
||||
**格式 1:实际微博状态**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"statuses": [
|
||||
{
|
||||
"id": "string | number",
|
||||
"mid": "string",
|
||||
"text": "string", // HTML 格式,需 stripHtml 处理
|
||||
"raw_text": "string",
|
||||
"user": {
|
||||
"screen_name": "string",
|
||||
"profile_image_url": "string",
|
||||
"avatar_hd": "string",
|
||||
"avatar_large": "string"
|
||||
},
|
||||
"pic_ids": ["string"], // 图片 ID 列表
|
||||
"pic_infos": {
|
||||
"PIC_ID": {
|
||||
"url": "string",
|
||||
"large": { "url": "string" }
|
||||
}
|
||||
},
|
||||
"attitudes_count": 5000, // 点赞数(→ like_count)
|
||||
"comments_count": 200,
|
||||
"reposts_count": 300, // 转发数(→ share_count)
|
||||
"reads_count": 100000, // 阅读数(→ play_count)
|
||||
"created_at": "Mon Jan 01 00:00:00 +0800 2024"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**格式 2:热搜话题**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"band_list": [
|
||||
{
|
||||
"word": "string", // 热搜词
|
||||
"num": 10000, // 热度(→ play_count)
|
||||
"label_name": "string", // 标签(热、沸)
|
||||
"mid": "string",
|
||||
"rank": 1
|
||||
}
|
||||
],
|
||||
"realtime": [/* 同上 */]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:
|
||||
> - `text` 字段含 HTML 标签(`<a>`, `<span>` 等),需 strip 处理
|
||||
> - 图片 URL 构建:`https://ww1.sinaimg.cn/large/{pic_id}.jpg`
|
||||
> - 时间格式:`"Mon Jan 01 00:00:00 +0800 2024"`,可直接用 `new Date()` 解析
|
||||
|
||||
---
|
||||
|
||||
#### ★ `GET /api/v1/weibo/app/fetch_post_detail`
|
||||
|
||||
> **本项目使用** · 微博详情
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `mid` | string | 是 | 微博 ID |
|
||||
|
||||
响应结构:
|
||||
```json
|
||||
{
|
||||
"data": { /* 同 statuses 中单条格式 */ }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 其他常用端点
|
||||
|
||||
| 端点 | 说明 | 关键参数 |
|
||||
|------|------|----------|
|
||||
| `GET /api/v1/weibo/web/fetch_hot_search` | 网页版热搜榜 | 无 |
|
||||
| `GET /api/v1/weibo/web_v2/fetch_hot_search` | 网页版 V2 热搜榜 | 无 |
|
||||
| `GET /api/v1/weibo/web/fetch_user_info` | 用户信息 | `uid` 或 `screen_name` |
|
||||
| `GET /api/v1/weibo/web/fetch_user_statuses` | 用户微博列表 | `uid`, `page`, `count` |
|
||||
|
||||
---
|
||||
|
||||
## YouTube
|
||||
|
||||
### YouTube 网页版 API — `/api/v1/youtube/web/`
|
||||
|
||||
#### ★ `GET /api/v1/youtube/web/fetch_trending_video`
|
||||
|
||||
> **本项目使用** · YouTube 热门视频列表
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `count` | integer | 否 | 返回数量,默认 20 |
|
||||
|
||||
响应结构:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": "string | { videoId: string }", // 视频 ID(可能是字符串或对象)
|
||||
"snippet": {
|
||||
"title": "string",
|
||||
"description": "string",
|
||||
"channelTitle": "string",
|
||||
"publishedAt": "2024-01-15T10:00:00Z", // ISO 8601
|
||||
"thumbnails": {
|
||||
"maxres": { "url": "string" },
|
||||
"high": { "url": "string" },
|
||||
"medium": { "url": "string" },
|
||||
"default": { "url": "string" }
|
||||
},
|
||||
"tags": ["string"]
|
||||
},
|
||||
"statistics": {
|
||||
"viewCount": "100000", // 注意:YouTube 统计字段是字符串类型
|
||||
"likeCount": "5000", // 需 parseInt() 转换
|
||||
"commentCount": "200"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:
|
||||
> - `statistics.viewCount` 等字段为**字符串类型**,需用 `parseInt()` 转换
|
||||
> - `id` 字段可能是字符串或 `{ videoId: string }` 对象,需兼容处理
|
||||
> - 缩略图优先级:`maxres > high > medium > default`
|
||||
|
||||
---
|
||||
|
||||
#### ★ `GET /api/v1/youtube/web/fetch_video_detail`
|
||||
|
||||
> **本项目使用** · YouTube 视频详情
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `video_id` | string | 是 | 视频 ID(如 `dQw4w9WgXcQ`)|
|
||||
|
||||
响应结构:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"items": [{ /* 同 trending 中单条格式 */ }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 其他常用端点
|
||||
|
||||
| 端点 | 说明 | 关键参数 |
|
||||
|------|------|----------|
|
||||
| `GET /api/v1/youtube/web/fetch_channel_info` | 频道信息 | `channel_id` 或 `username` |
|
||||
| `GET /api/v1/youtube/web/fetch_channel_videos` | 频道视频列表 | `channel_id`, `count`, `page_token` |
|
||||
| `GET /api/v1/youtube/web/fetch_search_results` | 视频搜索 | `keyword`, `count`, `page_token` |
|
||||
| `GET /api/v1/youtube/web/fetch_playlist_videos` | 播放列表视频 | `playlist_id`, `count`, `page_token` |
|
||||
|
||||
---
|
||||
|
||||
## Instagram
|
||||
|
||||
### Instagram 网页版 API — `/api/v1/instagram/web/`
|
||||
|
||||
#### ★ `GET /api/v1/instagram/web/fetch_explore_feed`
|
||||
|
||||
> **本项目使用** · Instagram 探索页内容
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `count` | integer | 否 | 返回数量,默认 20 |
|
||||
|
||||
响应结构(两种格式,需同时处理):
|
||||
|
||||
**格式 1:扁平 items 数组**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"media": { /* InstagramMediaItem */ }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**格式 2:分区 sectional_items**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"sectional_items": [
|
||||
{
|
||||
"layout_content": {
|
||||
"medias": [
|
||||
{ "media": { /* InstagramMediaItem */ } }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**InstagramMediaItem 结构**:
|
||||
```json
|
||||
{
|
||||
"pk": "string",
|
||||
"id": "string",
|
||||
"code": "string", // shortcode(用于构建 URL 和作为 ID)
|
||||
"media_type": 1, // 1=图片, 2=视频, 8=轮播
|
||||
"caption": { "text": "string" } | "string" | null,
|
||||
"image_versions2": {
|
||||
"candidates": [
|
||||
{ "url": "string", "width": 1080, "height": 1080 }
|
||||
]
|
||||
},
|
||||
"thumbnail_url": "string", // 视频封面(备用)
|
||||
"video_url": "string", // 仅 media_type=2 时有值
|
||||
"user": {
|
||||
"username": "string",
|
||||
"full_name": "string",
|
||||
"profile_pic_url": "string"
|
||||
},
|
||||
"like_count": 5000,
|
||||
"comment_count": 200,
|
||||
"taken_at": 1709000000 // Unix 时间戳(秒)
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:
|
||||
> - 以 `code`(shortcode)作为内容 ID,URL 格式:`https://www.instagram.com/p/{code}/`
|
||||
> - `caption` 字段格式不固定,可能是对象、字符串或 null,需兼容处理
|
||||
|
||||
---
|
||||
|
||||
#### ★ `GET /api/v1/instagram/web/fetch_post_detail`
|
||||
|
||||
> **本项目使用** · Instagram 帖子详情
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `shortcode` | string | 是 | 帖子 shortcode(即内容 ID)|
|
||||
|
||||
响应结构:
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"items": [{ /* InstagramMediaItem */ }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 其他常用端点
|
||||
|
||||
| 端点 | 说明 | 关键参数 |
|
||||
|------|------|----------|
|
||||
| `GET /api/v1/instagram/web/fetch_user_info` | 用户信息 | `username` |
|
||||
| `GET /api/v1/instagram/web/fetch_user_posts` | 用户帖子 | `username`, `cursor`, `count` |
|
||||
| `GET /api/v1/instagram/web/fetch_search_result` | 搜索用户/标签 | `keyword` |
|
||||
| `GET /api/v1/instagram/web/fetch_hashtag_posts` | 标签内容 | `hashtag`, `cursor` |
|
||||
|
||||
---
|
||||
|
||||
## Twitter / X
|
||||
|
||||
### Twitter 网页版 API — `/api/v1/twitter/web/`
|
||||
|
||||
#### ★ `GET /api/v1/twitter/web/fetch_trending_topics`
|
||||
|
||||
> **本项目使用** · Twitter 热门话题和趋势推文
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `count` | integer | 否 | 返回数量,默认 20 |
|
||||
|
||||
响应结构(三种格式,需全部处理):
|
||||
|
||||
**格式 1:推文数组**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"tweets": [
|
||||
{
|
||||
"id_str": "string",
|
||||
"full_text": "string", // 优先使用
|
||||
"text": "string", // 备用
|
||||
"user": {
|
||||
"name": "string",
|
||||
"screen_name": "string", // 用户名(@后面的部分)
|
||||
"profile_image_url_https": "string"
|
||||
},
|
||||
"favorite_count": 5000, // 点赞数(→ like_count)
|
||||
"reply_count": 200, // 回复数(→ comment_count)
|
||||
"retweet_count": 300, // 转推数(→ share_count)
|
||||
"created_at": "Mon Jan 01 00:00:00 +0000 2024",
|
||||
"entities": {
|
||||
"media": [
|
||||
{ "media_url_https": "string", "type": "photo | video" }
|
||||
],
|
||||
"hashtags": [{ "text": "string" }]
|
||||
},
|
||||
"extended_entities": {
|
||||
"media": [/* 同上,优先使用 */]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**格式 2:GraphQL 时间线(Timeline Instructions)**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"timeline": {
|
||||
"instructions": [
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"content": {
|
||||
"itemContent": {
|
||||
"tweet_results": {
|
||||
"result": {
|
||||
"legacy": { /* 同 TwitterTweet 格式 */ },
|
||||
"core": {
|
||||
"user_results": {
|
||||
"result": {
|
||||
"legacy": { /* TwitterUser 格式 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**格式 3:热门话题列表**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"trends": [
|
||||
{
|
||||
"name": "string", // 话题名称
|
||||
"tweet_volume": 10000, // 推文量(→ play_count)
|
||||
"query": "string" // URL 编码的搜索查询
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:
|
||||
> - 时间格式:`"Mon Jan 01 00:00:00 +0000 2024"`,可直接用 `new Date()` 解析
|
||||
> - 封面图来自 `extended_entities.media`(优先)或 `entities.media`,找 `type === 'photo'` 的条目
|
||||
> - 原帖 URL 格式:`https://x.com/{screen_name}/status/{id_str}`
|
||||
|
||||
---
|
||||
|
||||
#### ★ `GET /api/v1/twitter/web/fetch_tweet_detail`
|
||||
|
||||
> **本项目使用** · 推文详情
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `tweet_id` | string | 是 | 推文 ID |
|
||||
|
||||
响应结构(两种格式):
|
||||
|
||||
```json
|
||||
// 格式 1(GraphQL)
|
||||
{
|
||||
"data": {
|
||||
"tweetResult": {
|
||||
"result": {
|
||||
"legacy": { /* TwitterTweet */ },
|
||||
"core": { "user_results": { "result": { "legacy": { /* TwitterUser */ } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 格式 2(直接对象)
|
||||
{
|
||||
"data": {
|
||||
"tweet": { /* TwitterTweet */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 其他常用端点
|
||||
|
||||
| 端点 | 说明 | 关键参数 |
|
||||
|------|------|----------|
|
||||
| `GET /api/v1/twitter/web/fetch_user_info` | 用户信息 | `screen_name` |
|
||||
| `GET /api/v1/twitter/web/fetch_user_tweets` | 用户推文 | `screen_name`, `cursor`, `count` |
|
||||
| `GET /api/v1/twitter/web/fetch_search_timeline` | 搜索推文 | `keyword`, `cursor`, `count` |
|
||||
| `GET /api/v1/twitter/web/fetch_tweet_replies` | 推文回复 | `tweet_id`, `cursor` |
|
||||
|
||||
---
|
||||
|
||||
## 通用说明
|
||||
|
||||
### 本项目中使用的端点汇总
|
||||
|
||||
| 平台 | fetchHotList 端点 | fetchDetail 端点 |
|
||||
|------|------------------|-----------------|
|
||||
| 抖音 | `douyin/web/fetch_hot_search_result` | `douyin/web/fetch_one_video` → 回退 `fetch_hot_search_result` |
|
||||
| TikTok | `tiktok/web/fetch_explore_post` | `tiktok/web/fetch_post_detail` |
|
||||
| 小红书 | `xiaohongshu/web_v2/fetch_hot_list` + `xiaohongshu/app_v2/search_notes` | `xiaohongshu/app_v2/get_mixed_note_detail` |
|
||||
| YouTube | `youtube/web/fetch_trending_video` | `youtube/web/fetch_video_detail` |
|
||||
| Instagram | `instagram/web/fetch_explore_feed` | `instagram/web/fetch_post_detail` |
|
||||
| Twitter | `twitter/web/fetch_trending_topics` | `twitter/web/fetch_tweet_detail` |
|
||||
| 哔哩哔哩 | `bilibili/web/fetch_popular_video_list` | `bilibili/web/fetch_video_detail` |
|
||||
| 微博 | `weibo/app/fetch_hot_search` | `weibo/app/fetch_post_detail` |
|
||||
|
||||
### 时间戳处理
|
||||
|
||||
| 平台 | 时间格式 | 处理方式 |
|
||||
|------|---------|----------|
|
||||
| 抖音、TikTok、小红书、B站、微博、Instagram | Unix 时间戳(秒)| `new Date(timestamp * 1000).toISOString()` |
|
||||
| YouTube | ISO 8601 字符串 | 直接使用 |
|
||||
| Twitter | `"Mon Jan 01 00:00:00 +0000 2024"` | `new Date(dateStr).toISOString()` |
|
||||
| 微博 | `"Mon Jan 01 00:00:00 +0800 2024"` | `new Date(dateStr).toISOString()` |
|
||||
|
||||
### 图片 URL 类型
|
||||
|
||||
| 平台 | 图片字段类型 |
|
||||
|------|------------|
|
||||
| 抖音 | `url_list: string[]`(取 `[0]`)|
|
||||
| TikTok | `cover: string`(直接字符串)|
|
||||
| 小红书 | `images_list[0].url_size_large \|\| url` |
|
||||
| B站 | `pic: string`(直接字符串)|
|
||||
| YouTube | `thumbnails.maxres.url`(对象嵌套)|
|
||||
| Instagram | `image_versions2.candidates[0].url` |
|
||||
| Twitter | `entities/extended_entities.media[0].media_url_https` |
|
||||
| 微博 | `pic_infos[id].large.url` 或 `https://ww1.sinaimg.cn/large/{id}.jpg` |
|
||||
|
||||
---
|
||||
|
||||
*文档基于项目实际使用端点 + TikHub OpenAPI 规范整理。完整端点列表(700+)请访问 https://api.tikhub.io*
|
||||
Reference in New Issue
Block a user