diff --git a/docker-compose.yml b/docker-compose.yml index 445d0fa..161343e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,10 +43,9 @@ services: retries: 10 volumes: - mysql_data:/var/lib/mysql - ports: - # Expose for host-side inspection (mysql CLI / DBeaver); omit if you - # don't need cross-container access from the host. - - "3306:3306" + # 3306 不映射宿主机:容器内通过服务名 mysql:3306 互联;宿主机检查用 + # docker compose exec mysql mysql -uyunke -p(2026-08-21 本机 3306 已被 + # 本地 MySQL 占用,发布环境也无需对外暴露数据库) redis: image: redis:7-alpine diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..ee64b5f --- /dev/null +++ b/docs/DEPLOY.md @@ -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**(抓取 + 媒体下载) diff --git a/yunke-server/app/db/models.py b/yunke-server/app/db/models.py index 40ddc79..e28001d 100644 --- a/yunke-server/app/db/models.py +++ b/yunke-server/app/db/models.py @@ -160,7 +160,7 @@ class WechatAccount(Base): send_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) - 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) last_update: Mapped[int | None] = mapped_column(BigInteger, nullable=True) diff --git a/yunke-server/app/storage/repos.py b/yunke-server/app/storage/repos.py index c68b145..41eaaa5 100644 --- a/yunke-server/app/storage/repos.py +++ b/yunke-server/app/storage/repos.py @@ -116,6 +116,12 @@ _INT_FIELDS: frozenset[str] = frozenset( "start_time", "end_time", "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: return int(s) except ValueError: - return None + 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 + + +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 @@ -263,7 +288,11 @@ def _build_upsert_stmt( """ pk_cols = _pk_col_names(model) # 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 # ``.values(...)``) and reuse that accessor when building the SET dict.