From ef56008ccb328af3d1fa65d2ed77e918b43db8e5 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Thu, 14 May 2026 10:29:18 +0800 Subject: [PATCH] 1 --- .env.example | 5 + README_DOCKER.md | 44 +++++ app/db/auth_db.py | 32 ++++ app/web/web_server.py | 3 + docker-compose.yml | 4 + rules.yaml | 8 +- scripts/migrate_container_db_to_volume.sh | 220 ++++++++++++++++++++++ tests/test_user_subscription_auth.py | 34 ++++ 8 files changed, 346 insertions(+), 4 deletions(-) create mode 100755 scripts/migrate_container_db_to_volume.sh diff --git a/.env.example b/.env.example index e58b631..d6fc577 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,11 @@ ALPHAX_SCHEDULER_DRY_RUN=1 # SQLite DB 路径。容器内默认 /app/data/altcoin_monitor.db。 ALPHAX_DB_PATH=/app/data/altcoin_monitor.db +# 全新空库启动时创建默认管理员。已有用户/迁移旧库时不会覆盖。 +ALPHAX_BOOTSTRAP_ADMIN=1 +ALPHAX_DEFAULT_ADMIN_EMAIL=admin@alphax.local +ALPHAX_DEFAULT_ADMIN_PASSWORD=AlphaXAdmin123 + # 飞书机器人 Webhook。不要把真实值提交到仓库。 # ALTCOIN_FEISHU_WEBHOOK=https://open.feishu.cn/open-apis/bot/v2/hook/REDACTED ALTCOIN_FEISHU_WEBHOOK= diff --git a/README_DOCKER.md b/README_DOCKER.md index 6ba5e51..9ac82da 100644 --- a/README_DOCKER.md +++ b/README_DOCKER.md @@ -26,6 +26,23 @@ docker compose up -d alphax-web curl -s http://127.0.0.1:8191/api/stats ``` +首次使用空库启动时,Web 会自动创建一个默认管理员账号: + +```text +邮箱:admin@alphax.local +密码:AlphaXAdmin123 +``` + +建议首次登录后立刻在账号设置中修改密码。也可以在 `.env` 中覆盖默认值: + +```text +ALPHAX_BOOTSTRAP_ADMIN=1 +ALPHAX_DEFAULT_ADMIN_EMAIL=your-admin@example.com +ALPHAX_DEFAULT_ADMIN_PASSWORD=change-me-to-a-strong-password +``` + +该初始化只会在 `app_user` 表完全为空时执行;如果你迁移了旧数据库或已经有用户,不会覆盖任何账号。 + 确认 Web 正常后,如果要启动调度器: ```bash @@ -103,6 +120,33 @@ cp /home/ubuntu/quant_monitor/altcoin/altcoin_monitor.db ./data/altcoin_monitor. 不要反向覆盖线上 DB。 +如果旧部署的数据库还在容器内部、尚未挂载到 `./data`,可以使用迁移脚本把容器内 DB 迁移到 compose volume: + +```bash +cd /home/ubuntu/quant_monitor/alphax-docker +bash scripts/migrate_container_db_to_volume.sh +``` + +脚本会: + +- 用容器内 SQLite backup API 导出一致性快照,包含 WAL 中的数据。 +- 备份已有 `./data/altcoin_monitor.db` 到 `./data/backups/`。 +- 把容器内 DB 安装到 `./data/altcoin_monitor.db`。 +- 重建 `alphax-web` / `alphax-scheduler`,让容器使用 volume 中的数据库。 + +常用参数: + +```bash +# 指定旧容器名 +SOURCE_CONTAINER=old-alphax-web bash scripts/migrate_container_db_to_volume.sh + +# 只复制,不自动重启 compose 服务 +RECREATE=0 bash scripts/migrate_container_db_to_volume.sh + +# 如果脚本检测到 /app/data 已经是挂载卷但仍想强制复制 +FORCE=1 bash scripts/migrate_container_db_to_volume.sh +``` + ## 打包迁移到新服务器 建议只打包代码和配置骨架,不把 DB 直接打进镜像。可以用 tar 打包整个副本目录,排除本地缓存和归档备份: diff --git a/app/db/auth_db.py b/app/db/auth_db.py index ab3773b..f627898 100644 --- a/app/db/auth_db.py +++ b/app/db/auth_db.py @@ -385,9 +385,41 @@ def _public_user(row) -> dict: "free_trial_claimed": int(d.get("free_trial_claimed") or 0), "created_at": d.get("created_at"), "last_login_at": d.get("last_login_at"), + "is_admin": bool(d.get("is_admin")), } +def ensure_default_admin() -> dict: + """在全新空库中创建默认管理员;已有任何用户时不做任何修改。""" + init_auth_db() + email = (os.getenv("ALPHAX_DEFAULT_ADMIN_EMAIL") or "").strip().lower() + password = os.getenv("ALPHAX_DEFAULT_ADMIN_PASSWORD") or "" + enabled = (os.getenv("ALPHAX_BOOTSTRAP_ADMIN", "1") or "1").strip().lower() + if enabled in ("0", "false", "no", "off"): + return {"created": False, "reason": "disabled"} + if not email or not password: + return {"created": False, "reason": "missing_env"} + email = _normalize_email(email) + password_hash, salt = _hash_password(password) + conn = get_conn() + try: + existing_count = conn.execute("SELECT COUNT(*) FROM app_user").fetchone()[0] + if existing_count: + return {"created": False, "reason": "users_exist", "user_count": int(existing_count)} + now = _iso(_now()) + invite_code = _new_invite_code(conn) + cur = conn.execute(""" + INSERT INTO app_user ( + email, password_hash, password_salt, email_verified, status, + invite_code, invited_by_user_id, free_trial_claimed, created_at, updated_at, is_admin + ) VALUES (?, ?, ?, 1, 'active', ?, NULL, 0, ?, ?, 1) + """, (email, password_hash, salt, invite_code, now, now)) + conn.commit() + return {"created": True, "email": email, "user_id": cur.lastrowid} + finally: + conn.close() + + def get_user_by_email(email: str): init_auth_db() email = _normalize_email(email) diff --git a/app/web/web_server.py b/app/web/web_server.py index 710944a..ef5b302 100644 --- a/app/web/web_server.py +++ b/app/web/web_server.py @@ -29,6 +29,9 @@ HTML_PAGE = (REPO_ROOT / "static" / "app.html").read_text(encoding="utf-8") @asynccontextmanager async def lifespan(app: FastAPI): init_db() + bootstrap_admin = auth_db.ensure_default_admin() + if bootstrap_admin.get("created"): + print(f"默认管理员已创建: {bootstrap_admin.get('email')}") yield diff --git a/docker-compose.yml b/docker-compose.yml index b91505a..19c81ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,10 @@ services: environment: PORT: "8190" ALPHAX_DB_PATH: "/app/data/altcoin_monitor.db" + # 仅当 app_user 表为空时创建默认管理员;已有用户或迁移旧库时不会覆盖。 + ALPHAX_BOOTSTRAP_ADMIN: "${ALPHAX_BOOTSTRAP_ADMIN:-1}" + ALPHAX_DEFAULT_ADMIN_EMAIL: "${ALPHAX_DEFAULT_ADMIN_EMAIL:-admin@alphax.local}" + ALPHAX_DEFAULT_ADMIN_PASSWORD: "${ALPHAX_DEFAULT_ADMIN_PASSWORD:-AlphaXAdmin123}" command: ["web"] ports: - "8191:8190" diff --git a/rules.yaml b/rules.yaml index b4629fb..3df208d 100644 --- a/rules.yaml +++ b/rules.yaml @@ -405,11 +405,11 @@ event_driven: note: Solana meme主题扩散 meta: version: 1 - last_review: '2026-05-14T09:19:05.923167' - last_reverse_analysis: '2026-05-14T09:19:39.019005' - total_reviews: 26 + last_review: '2026-05-14T10:26:01.120951' + last_reverse_analysis: '2026-05-14T10:26:36.940507' + total_reviews: 28 total_rules_learned: 37 - iteration_count: 31 + iteration_count: 33 strategy_version: v1.7.11 strategy_revision_started_at: '2026-05-09T01:20:00' strategy_revision_note: 'v1.7.11: 触发时效治理,旧形态只作背景,消息触发显式标记' diff --git a/scripts/migrate_container_db_to_volume.sh b/scripts/migrate_container_db_to_volume.sh new file mode 100755 index 0000000..9409082 --- /dev/null +++ b/scripts/migrate_container_db_to_volume.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Migrate an AlphaX SQLite DB that lives inside a Docker container into the +# docker-compose host volume path: ./data/altcoin_monitor.db. +# +# Safe defaults: +# - uses sqlite3 backup API inside the container, so WAL data is included +# - backs up any existing host ./data/altcoin_monitor.db first +# - refuses to overwrite when the container is already reading from /app/data +# - recreates compose services after migration so the new volume is mounted +# +# Common usage on the server: +# bash scripts/migrate_container_db_to_volume.sh +# +# Useful overrides: +# SOURCE_CONTAINER=old-alphax-web bash scripts/migrate_container_db_to_volume.sh +# SERVICE=alphax-web TARGET_DB=data/altcoin_monitor.db bash scripts/migrate_container_db_to_volume.sh +# RECREATE=0 bash scripts/migrate_container_db_to_volume.sh + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +COMPOSE_CMD="${COMPOSE_CMD:-docker compose}" +SERVICE="${SERVICE:-alphax-web}" +SCHEDULER_SERVICE="${SCHEDULER_SERVICE:-alphax-scheduler}" +SOURCE_CONTAINER="${SOURCE_CONTAINER:-}" +SOURCE_DB="${SOURCE_DB:-/app/data/altcoin_monitor.db}" +TARGET_DB="${TARGET_DB:-data/altcoin_monitor.db}" +BACKUP_DIR="${BACKUP_DIR:-data/backups}" +RECREATE="${RECREATE:-1}" +STOP_SCHEDULER="${STOP_SCHEDULER:-1}" +FORCE="${FORCE:-0}" + +compose() { + ${COMPOSE_CMD} "$@" +} + +die() { + echo "ERROR: $*" >&2 + exit 1 +} + +info() { + echo "[migrate-db] $*" +} + +service_exists() { + compose config --services 2>/dev/null | grep -qx "$1" +} + +container_id() { + if [ -n "$SOURCE_CONTAINER" ]; then + docker inspect -f '{{.Id}}' "$SOURCE_CONTAINER" >/dev/null 2>&1 || die "SOURCE_CONTAINER not found: $SOURCE_CONTAINER" + docker inspect -f '{{.Id}}' "$SOURCE_CONTAINER" + return + fi + local cid + cid="$(compose ps -q "$SERVICE" 2>/dev/null || true)" + [ -n "$cid" ] || die "service '$SERVICE' is not running. Start it first, or set SOURCE_CONTAINER=." + echo "$cid" +} + +is_source_db_under_mount() { + local cid="$1" + local mounted=1 + while IFS= read -r dest; do + [ -n "$dest" ] || continue + case "$SOURCE_DB" in + "$dest"|"$dest"/*) + mounted=0 + break + ;; + esac + done < <(docker inspect -f '{{range .Mounts}}{{println .Destination}}{{end}}' "$cid") + return "$mounted" +} + +verify_db_in_container() { + local cid="$1" + local db_path="$2" + docker exec -e CHECK_DB="$db_path" "$cid" python - <<'PY' +import os +import sqlite3 +import sys + +db = os.environ["CHECK_DB"] +conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=30) +result = conn.execute("PRAGMA integrity_check").fetchone()[0] +tables = conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table'").fetchone()[0] +conn.close() +if result != "ok": + print(f"integrity_check failed: {result}", file=sys.stderr) + raise SystemExit(2) +print(f"integrity_check=ok tables={tables}") +PY +} + +verify_db_on_host_if_possible() { + local db_path="$1" + if command -v python3 >/dev/null 2>&1; then + HOST_DB="$db_path" python3 - <<'PY' +import os +import sqlite3 +import sys + +db = os.environ["HOST_DB"] +conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=30) +result = conn.execute("PRAGMA integrity_check").fetchone()[0] +tables = conn.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table'").fetchone()[0] +conn.close() +if result != "ok": + print(f"host integrity_check failed: {result}", file=sys.stderr) + raise SystemExit(2) +print(f"host integrity_check=ok tables={tables}") +PY + else + info "python3 not found on host; skipped host integrity check" + fi +} + +CID="$(container_id)" +STAMP="$(date +%Y%m%d_%H%M%S)" +TMP_IN_CONTAINER="/tmp/alphax_container_db_${STAMP}.db" +TMP_ON_HOST="${BACKUP_DIR}/altcoin_monitor.from_container.${STAMP}.tmp.db" + +info "source container: ${SOURCE_CONTAINER:-$SERVICE} ($CID)" +info "source db: $SOURCE_DB" +info "target db: $TARGET_DB" + +docker exec -e SOURCE_DB="$SOURCE_DB" "$CID" sh -lc 'test -s "$SOURCE_DB"' \ + || die "source database does not exist or is empty inside container: $SOURCE_DB" + +if is_source_db_under_mount "$CID" && [ "$FORCE" != "1" ]; then + info "container path '$SOURCE_DB' is already under a mounted volume/bind mount." + info "No migration needed. Set FORCE=1 only if you intentionally want to copy it anyway." + exit 0 +fi + +mkdir -p "$(dirname "$TARGET_DB")" "$BACKUP_DIR" + +if [ "$STOP_SCHEDULER" = "1" ] && service_exists "$SCHEDULER_SERVICE"; then + info "stopping scheduler service to avoid concurrent writes" + compose stop "$SCHEDULER_SERVICE" >/dev/null 2>&1 || true +fi + +info "creating SQLite backup inside container" +docker exec \ + -e SOURCE_DB="$SOURCE_DB" \ + -e TMP_DB="$TMP_IN_CONTAINER" \ + "$CID" python - <<'PY' +import os +import sqlite3 + +src = os.environ["SOURCE_DB"] +dst = os.environ["TMP_DB"] +try: + os.remove(dst) +except FileNotFoundError: + pass + +src_conn = sqlite3.connect(f"file:{src}?mode=ro", uri=True, timeout=30) +dst_conn = sqlite3.connect(dst, timeout=30) +src_conn.backup(dst_conn) +dst_conn.close() +src_conn.close() + +check = sqlite3.connect(f"file:{dst}?mode=ro", uri=True, timeout=30) +result = check.execute("PRAGMA integrity_check").fetchone()[0] +check.close() +if result != "ok": + raise SystemExit(f"container backup integrity_check failed: {result}") +print(f"backup_created={dst}") +PY + +info "copying backup from container to host" +docker cp "${CID}:${TMP_IN_CONTAINER}" "$TMP_ON_HOST" +docker exec -e TMP_DB="$TMP_IN_CONTAINER" "$CID" sh -lc 'rm -f "$TMP_DB"' >/dev/null 2>&1 || true + +verify_db_on_host_if_possible "$TMP_ON_HOST" + +if [ -e "$TARGET_DB" ]; then + EXISTING_BACKUP="${BACKUP_DIR}/altcoin_monitor.before_container_migration.${STAMP}.db" + info "backing up existing target db to $EXISTING_BACKUP" + cp -p "$TARGET_DB" "$EXISTING_BACKUP" +fi + +for sidecar in "${TARGET_DB}-wal" "${TARGET_DB}-shm"; do + if [ -e "$sidecar" ]; then + sidecar_backup="${BACKUP_DIR}/$(basename "$sidecar").before_container_migration.${STAMP}" + info "moving stale sidecar $sidecar to $sidecar_backup" + mv "$sidecar" "$sidecar_backup" + fi +done + +info "installing migrated db into volume path" +mv "$TMP_ON_HOST" "$TARGET_DB" +chmod 664 "$TARGET_DB" || true + +verify_db_on_host_if_possible "$TARGET_DB" + +if [ "$RECREATE" = "1" ]; then + info "recreating compose services so the host volume is mounted" + compose up -d --force-recreate "$SERVICE" + if service_exists "$SCHEDULER_SERVICE"; then + compose up -d --force-recreate "$SCHEDULER_SERVICE" + fi + + NEW_CID="$(compose ps -q "$SERVICE" 2>/dev/null || true)" + if [ -n "$NEW_CID" ]; then + info "verifying migrated db from recreated container" + verify_db_in_container "$NEW_CID" "$SOURCE_DB" + fi +else + info "RECREATE=0, skipped service recreation" +fi + +info "done" +info "volume db is now: $ROOT_DIR/$TARGET_DB" +info "backups are in: $ROOT_DIR/$BACKUP_DIR" diff --git a/tests/test_user_subscription_auth.py b/tests/test_user_subscription_auth.py index 657e48e..78b4e75 100644 --- a/tests/test_user_subscription_auth.py +++ b/tests/test_user_subscription_auth.py @@ -42,6 +42,40 @@ def test_register_creates_unverified_user_with_invite_code_and_email_verificatio assert user["invited_by_user_id"] is None +def test_default_admin_bootstrap_only_when_user_table_empty(temp_auth_db, monkeypatch): + monkeypatch.setenv("ALPHAX_BOOTSTRAP_ADMIN", "1") + monkeypatch.setenv("ALPHAX_DEFAULT_ADMIN_EMAIL", "admin@alphax.local") + monkeypatch.setenv("ALPHAX_DEFAULT_ADMIN_PASSWORD", "AlphaXAdmin123") + + created = auth_db.ensure_default_admin() + assert created["created"] is True + + user = auth_db.get_user_by_email("admin@alphax.local") + assert user["email_verified"] == 1 + assert user["status"] == "active" + assert user["is_admin"] == 1 + + session = auth_db.login_user("admin@alphax.local", "AlphaXAdmin123") + assert session["token"] + assert session["user"]["is_admin"] is True + + skipped = auth_db.ensure_default_admin() + assert skipped["created"] is False + assert skipped["reason"] == "users_exist" + + +def test_default_admin_bootstrap_does_not_touch_existing_users(temp_auth_db, monkeypatch): + auth_db.register_user("alice@example.com", "StrongPass123") + monkeypatch.setenv("ALPHAX_BOOTSTRAP_ADMIN", "1") + monkeypatch.setenv("ALPHAX_DEFAULT_ADMIN_EMAIL", "admin@alphax.local") + monkeypatch.setenv("ALPHAX_DEFAULT_ADMIN_PASSWORD", "AlphaXAdmin123") + + result = auth_db.ensure_default_admin() + assert result["created"] is False + assert result["reason"] == "users_exist" + assert auth_db.get_user_by_email("admin@alphax.local") is None + + def test_register_with_invite_code_locks_inviter_relationship(temp_auth_db): inviter = auth_db.register_user("inviter@example.com", "StrongPass123") invited = auth_db.register_user("bob@example.com", "StrongPass123", invite_code=inviter["invite_code"])