1
This commit is contained in:
parent
5986c239eb
commit
ef56008ccb
@ -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=
|
||||
|
||||
@ -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 打包整个副本目录,排除本地缓存和归档备份:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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: 触发时效治理,旧形态只作背景,消息触发显式标记'
|
||||
|
||||
220
scripts/migrate_container_db_to_volume.sh
Executable file
220
scripts/migrate_container_db_to_volume.sh
Executable 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"
|
||||
@ -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"])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user