feat(deploy): MySQL容器实测打通(3个方言bug修复)+部署文档
- repos: on_duplicate只更新实际插入列(fetched_at等Python-default列, SQLite excluded容忍/MySQL new.*直接报Unknown column) - repos: _coerce_int增加日期字符串→ms epoch分支 + _INT_FIELDS补 last_update/last_chat_time/timestamp/update_time(字符串直通BigInteger) - models: wechat_account.db_size Integer→BigInteger(实测2.17GB超32位上限) - compose: mysql不映射宿主机3306(本机已占;容器内服务名互联) - docs/DEPLOY.md: 服务器部署全指南(架构/步骤/数据迁移/运维命令/注意事项) - 容器实测:Playwright抓取55账号→MySQL upsert全通 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+3
-4
@@ -43,10 +43,9 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
volumes:
|
volumes:
|
||||||
- mysql_data:/var/lib/mysql
|
- mysql_data:/var/lib/mysql
|
||||||
ports:
|
# 3306 不映射宿主机:容器内通过服务名 mysql:3306 互联;宿主机检查用
|
||||||
# Expose for host-side inspection (mysql CLI / DBeaver); omit if you
|
# docker compose exec mysql mysql -uyunke -p(2026-08-21 本机 3306 已被
|
||||||
# don't need cross-container access from the host.
|
# 本地 MySQL 占用,发布环境也无需对外暴露数据库)
|
||||||
- "3306:3306"
|
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
|
|||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
# 云客数据镜像 — 服务器部署指南
|
||||||
|
|
||||||
|
> 2026-08-21|适用于 Linux 服务器 + Docker Compose 五服务部署
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Docker Compose │
|
||||||
|
│ ┌────────┐ ┌────────┐ ┌────────────────────┐ │
|
||||||
|
│ │ MySQL 8│ │ Redis 7│ │ backend (FastAPI) │ │
|
||||||
|
│ │ :3306 │ │ :6379 │ │ :8000 │ │
|
||||||
|
│ └────┬───┘ └────┬───┘ └─────────▲──────────┘ │
|
||||||
|
│ │ │ │ /api │
|
||||||
|
│ ┌────▼───────────▼───┐ ┌────────┴──────────┐ │
|
||||||
|
│ │ scheduler │ │ frontend (nginx) │ │
|
||||||
|
│ │ APScheduler │ │ :5173 → :80 │ │
|
||||||
|
│ │ + Playwright │ └───────────────────┘ │
|
||||||
|
│ │ + Chromium │ │
|
||||||
|
│ └────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **scheduler**:每晚 02:00 全量 + 每 4h 消息增量 + 每周日 05:00 全量对齐(全部浏览器 fetch 零风控)→ 需要 Playwright + Chromium(镜像已内置)
|
||||||
|
- **restart: unless-stopped**:容器崩溃自动重启(服务器上的守护方案,无需本地计划任务)
|
||||||
|
|
||||||
|
## 前置要求
|
||||||
|
|
||||||
|
- Linux 服务器(2C4G 起步,磁盘 ≥ 20GB:MySQL 数据 + 媒体文件 2.5GB+)
|
||||||
|
- Docker 24+ 与 Docker Compose v2
|
||||||
|
- 一个有效的云客会话 cookie(从浏览器登录云客后复制)
|
||||||
|
|
||||||
|
## 部署步骤
|
||||||
|
|
||||||
|
### 1. 上传代码
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <你的仓库地址> yunke
|
||||||
|
cd yunke
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 配置 .env
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp yunke-server/.env.example .env # 或手动创建
|
||||||
|
```
|
||||||
|
|
||||||
|
必须设置的项:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# 云客会话(从浏览器 F12 → Network → 任意请求的 Cookie 头复制)
|
||||||
|
YK_USERID=<32位userId>
|
||||||
|
YK_COMPANY=<公司码>
|
||||||
|
YK_COOKIE=user=xxx; userToken=yyy
|
||||||
|
|
||||||
|
# MySQL(docker-compose 自动创建库和用户)
|
||||||
|
MYSQL_ROOT_PASSWORD=<强密码>
|
||||||
|
MYSQL_PASSWORD=<强密码>
|
||||||
|
|
||||||
|
# 查看后台的访问令牌(留空=无鉴权,仅限内网)
|
||||||
|
API_TOKEN=<随机长字符串>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
首次构建约 5-10 分钟(含 Chromium 下载 ~150MB)。启动后:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps # 五服务全部 Up(healthy)
|
||||||
|
docker compose logs -f scheduler # 看调度器日志
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/health # {"status":"ok"}
|
||||||
|
curl http://localhost:5173/ # 前端页面
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 数据迁移(把本地 SQLite 数据搬到服务器 MySQL)
|
||||||
|
|
||||||
|
**方式 A:重抓(推荐,数据最新)**
|
||||||
|
|
||||||
|
服务器上手动跑一轮全量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec scheduler bash
|
||||||
|
DB_URL="mysql+asyncmy://yunke:<密码>@mysql:3306/yunke" \
|
||||||
|
bash scripts/full_sync_all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
耗时 6-12 小时(消息全量翻页),跑完后调度器自动接管日常增量。
|
||||||
|
|
||||||
|
**方式 B:SQLite → MySQL 数据搬迁**
|
||||||
|
|
||||||
|
把本地 smoke.db 和 media/ 目录传到服务器,用 Python 脚本逐表导:
|
||||||
|
(如有需要我可以写迁移脚本,~100 行)
|
||||||
|
|
||||||
|
### 6. 媒体文件
|
||||||
|
|
||||||
|
媒体存在 Docker volume `media_data` 里。本地已有 2.5GB 媒体的话:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 本地打包
|
||||||
|
tar czf media.tar.gz -C yunke-server media/
|
||||||
|
|
||||||
|
# 传到服务器后灌入 volume
|
||||||
|
docker run --rm -v yunke_media_data:/target -v $(pwd):/src alpine \
|
||||||
|
sh -c "cd /target && tar xzf /src/media.tar.gz --strip-components=1"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 日常运维
|
||||||
|
|
||||||
|
| 操作 | 命令 |
|
||||||
|
|---|---|
|
||||||
|
| 看调度日志 | `docker compose logs -f scheduler` |
|
||||||
|
| 手动触发全量 | `docker compose exec scheduler bash scripts/full_sync_all.sh` |
|
||||||
|
| 换 cookie | 改 `.env` 的 `YK_COOKIE` → `docker compose restart scheduler backend` |
|
||||||
|
| 备份 MySQL | `docker compose exec mysql mysqldump -uroot -p<密码> yunke > backup.sql` |
|
||||||
|
| 备份媒体 | `docker run --rm -v yunke_media_data:/data -v $(pwd):/out alpine tar czf /out/media.tgz -C /data .` |
|
||||||
|
| 升级版本 | `git pull && docker compose up -d --build` |
|
||||||
|
|
||||||
|
## 已知注意事项
|
||||||
|
|
||||||
|
1. **cookie 过期**:云客会话定期失效,过期后日志出现 `⚠️ cookie 可能已过期` 提示 → 从浏览器重新复制 `YK_COOKIE` 并 restart
|
||||||
|
2. **首次消息全量很慢**(6-12h),之后增量每轮 15-30 分钟
|
||||||
|
3. **FTS 搜索**:MySQL 上自动回退 LIKE(暂无全文索引);如搜索量大可在 MySQL 建 FULLTEXT 索引
|
||||||
|
4. **时区**:容器默认 UTC;调度按容器时区触发(FULL_SYNC_HOUR=2 即 UTC 02:00)。要北京时间可在 compose 加 `TZ: Asia/Shanghai`
|
||||||
|
5. **服务器需能访问 crm2.yunkecn.com 和 *.oss.yunkecn.com**(抓取 + 媒体下载)
|
||||||
@@ -160,7 +160,7 @@ class WechatAccount(Base):
|
|||||||
send_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
send_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
receive_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
receive_num: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
is_on_job: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
is_on_job: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||||
db_size: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
db_size: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 字节数可超 32 位 INT(实测 2.17GB,2026-08-21)
|
||||||
online_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
online_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
last_update: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
last_update: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,12 @@ _INT_FIELDS: frozenset[str] = frozenset(
|
|||||||
"start_time",
|
"start_time",
|
||||||
"end_time",
|
"end_time",
|
||||||
"mp3_duration",
|
"mp3_duration",
|
||||||
|
# 时间戳(ms-epoch)列:上游可能给日期字符串而非数字,走 _coerce_int
|
||||||
|
# 的日期解析分支(MySQL 严格类型实测修复,2026-08-21)
|
||||||
|
"last_update",
|
||||||
|
"last_chat_time",
|
||||||
|
"timestamp",
|
||||||
|
"update_time",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -171,7 +177,26 @@ def _coerce_int(value: Any) -> int | None:
|
|||||||
try:
|
try:
|
||||||
return int(s)
|
return int(s)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
pass
|
||||||
|
# 日期时间字符串 → ms epoch(如 "2026-08-21 23:29:46");MySQL 的
|
||||||
|
# BigInteger 列拒绝字符串,SQLite 容忍(2026-08-21 部署实测修复)
|
||||||
|
ts = _parse_datetime_str_to_ms(s)
|
||||||
|
if ts is not None:
|
||||||
|
return ts
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_datetime_str_to_ms(s: str) -> int | None:
|
||||||
|
"""Best-effort datetime-string → ms epoch for timestamp int columns."""
|
||||||
|
import datetime as _dt
|
||||||
|
|
||||||
|
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
|
||||||
|
try:
|
||||||
|
d = _dt.datetime.strptime(s, fmt)
|
||||||
|
# 与 scraper 层一致的 naive-UTC 约定
|
||||||
|
return int(d.replace(tzinfo=_dt.UTC).timestamp() * 1000)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -263,7 +288,11 @@ def _build_upsert_stmt(
|
|||||||
"""
|
"""
|
||||||
pk_cols = _pk_col_names(model)
|
pk_cols = _pk_col_names(model)
|
||||||
# Non-PK columns to refresh on conflict (volatile fields).
|
# Non-PK columns to refresh on conflict (volatile fields).
|
||||||
update_col_names = [c.name for c in model.__table__.columns if c.name not in pk_cols]
|
# 只刷新实际插入的列(2026-08-21 MySQL 实测修复):全列取会把
|
||||||
|
# fetched_at/updated_at 这类 Python-default 列拉进 UPDATE 子句 ——
|
||||||
|
# SQLite 的 excluded.* 容忍(取 DEFAULT),MySQL 的 new.* 直接报
|
||||||
|
# "Unknown column"。
|
||||||
|
update_col_names = [name for name in values if name not in pk_cols]
|
||||||
|
|
||||||
# NB: access ``excluded`` / ``inserted`` on the BARE insert (before
|
# NB: access ``excluded`` / ``inserted`` on the BARE insert (before
|
||||||
# ``.values(...)``) and reuse that accessor when building the SET dict.
|
# ``.values(...)``) and reuse that accessor when building the SET dict.
|
||||||
|
|||||||
Reference in New Issue
Block a user