This commit is contained in:
aaron 2026-05-14 10:29:18 +08:00
parent 5986c239eb
commit ef56008ccb
8 changed files with 346 additions and 4 deletions

View File

@ -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=

View File

@ -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 打包整个副本目录,排除本地缓存和归档备份:

View File

@ -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)

View File

@ -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

View File

@ -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"

View File

@ -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: 触发时效治理,旧形态只作背景,消息触发显式标记'

View File

@ -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=<container_name>."
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"

View File

@ -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"])