"""用户、邀请、订阅与支付预留数据库层。 第一阶段目标: - 邮箱+密码注册登录 - 邮箱验证码验证(SMTP 信息后续配置;当前只生成验证码并预留发送入口) - 邀请码锁定邀请关系 - 新用户免费订阅 1 个月,每个用户仅一次 - 预留 USDT 付费订单表结构,暂不实现网关扣款 """ from __future__ import annotations import hashlib import hmac import os import secrets import smtplib from datetime import datetime, timedelta from email.message import EmailMessage from pathlib import Path from typing import Optional from psycopg import IntegrityError from app.config.system_config import bootstrap_admin_config, email_config from app.db.postgres_connection import connect as pg_connect, ensure_migrations_once, table_columns REPO_ROOT = Path(__file__).resolve().parents[2] SESSION_DAYS = 30 VERIFY_CODE_MINUTES = 15 FREE_TRIAL_DAYS = 30 RESEND_COOLDOWN_SECONDS = 60 def _env_value(name: str, default: str = "") -> str: return (os.getenv(str(name or ""), default) or "").strip() def _smtp_settings() -> dict: cfg = email_config() or {} smtp = cfg.get("smtp") or {} username = _env_value(smtp.get("username_env") or "ASTOCK_SMTP_USERNAME") password = os.getenv(str(smtp.get("password_env") or "ASTOCK_SMTP_PASSWORD"), "") sender = (smtp.get("sender") or "").strip() or _env_value(smtp.get("sender_env") or "ASTOCK_SMTP_SENDER", username) try: port = int(smtp.get("port") or 465) except Exception: port = 465 try: timeout = int(smtp.get("timeout") or 12) except Exception: timeout = 12 return { "enabled": bool(cfg.get("enabled", True)), "host": (smtp.get("host") or "").strip(), "port": port, "username": username, "password": password, "sender": sender, "timeout": timeout, } def is_smtp_configured() -> bool: settings = _smtp_settings() if not settings["enabled"]: return False return all(str(settings.get(k) or "").strip() for k in ["host", "port", "username", "password", "sender"]) def send_verification_email(to_email: str, code: str) -> bool: """发送邮箱验证码。SMTP 密码只从配置指定的环境变量读取。""" if not is_smtp_configured(): return False settings = _smtp_settings() host = settings["host"] port = settings["port"] username = settings["username"] password = settings["password"] sender = settings["sender"] msg = EmailMessage() msg["Subject"] = "AlphaX Agent 邮箱验证码" msg["From"] = sender msg["To"] = to_email msg.set_content( f"AlphaX Agent 邮箱验证码:{code}\n\n" f"AI Market Intelligence.\n" f"验证码 {VERIFY_CODE_MINUTES} 分钟内有效,用于完成 AlphaX Agent 注册或登录验证。\n" f"如果不是你本人操作,请忽略本邮件。" ) msg.add_alternative( f"""
A
AlphaX Agent
AI Market Intelligence.
邮箱验证码

请使用下面的验证码完成 AlphaX Agent 注册或登录验证。验证码 {VERIFY_CODE_MINUTES} 分钟内有效。

你的验证码
{code}

如果不是你本人操作,请忽略本邮件。为保护账号安全,请不要把验证码转发给任何人。

© 2026 AlphaX Agent · AI 驱动的 Crypto 市场情报系统
""", subtype="html", ) with smtplib.SMTP_SSL(host, port, timeout=settings["timeout"]) as smtp: smtp.login(username, password) smtp.send_message(msg) return True class AuthError(Exception): """认证/订阅业务错误。""" def _now() -> datetime: return datetime.now() def _iso(dt: datetime) -> str: return dt.isoformat(timespec="seconds") def get_conn(): return pg_connect() def init_auth_db(): ensure_migrations_once() def _hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]: if not password or len(password) < 8: raise AuthError("密码至少需要8位") salt = salt or secrets.token_hex(16) digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 200_000) return digest.hex(), salt def _verify_password(password: str, stored_hash: str, salt: str) -> bool: candidate, _ = _hash_password(password, salt) return hmac.compare_digest(candidate, stored_hash) def _hash_token(value: str) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest() def _normalize_email(email: str) -> str: email = (email or "").strip().lower() if "@" not in email or "." not in email.split("@")[-1]: raise AuthError("邮箱格式不正确") return email def _new_invite_code(conn) -> str: for _ in range(20): code = secrets.token_urlsafe(8).replace("-", "").replace("_", "")[:10].upper() if len(code) < 8: continue if not conn.execute("SELECT id FROM app_user WHERE invite_code=%s", (code,)).fetchone(): return code raise AuthError("邀请码生成失败,请重试") def _new_verify_code() -> str: return f"{secrets.randbelow(1_000_000):06d}" def _row_to_dict(row): return dict(row) if row else None def _public_user(row) -> dict: d = _row_to_dict(row) or {} return { "id": d.get("id"), "email": d.get("email"), "email_verified": bool(d.get("email_verified")), "status": d.get("status"), "invite_code": d.get("invite_code"), "invited_by_user_id": d.get("invited_by_user_id"), "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() cfg = bootstrap_admin_config() or {} if not bool(cfg.get("enabled", True)): return {"created": False, "reason": "disabled"} email = _env_value(cfg.get("email_env") or "ALPHAX_DEFAULT_ADMIN_EMAIL").lower() password = os.getenv(str(cfg.get("password_env") or "ALPHAX_DEFAULT_ADMIN_PASSWORD"), "") 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) row = 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 (%s, %s, %s, 1, 'active', %s, NULL, 0, %s, %s, 1) RETURNING id """, (email, password_hash, salt, invite_code, now, now)) user_id = row.fetchone()["id"] conn.commit() return {"created": True, "email": email, "user_id": user_id} finally: conn.close() def get_user_by_email(email: str): init_auth_db() email = _normalize_email(email) conn = get_conn() row = conn.execute("SELECT * FROM app_user WHERE email=%s", (email,)).fetchone() conn.close() return _row_to_dict(row) def get_user_by_id(user_id: int): init_auth_db() conn = get_conn() row = conn.execute("SELECT * FROM app_user WHERE id=%s", (user_id,)).fetchone() conn.close() return _row_to_dict(row) def send_registration_code(email: str) -> dict: """注册步骤1:仅发送验证码,不创建用户记录。存到 pending_registration 表。""" init_auth_db() email = _normalize_email(email) conn = get_conn() now_dt = _now() now = _iso(now_dt) # 已注册并验证的用户不允许再发注册验证码 existing = conn.execute("SELECT * FROM app_user WHERE email=%s AND email_verified=1", (email,)).fetchone() if existing: conn.close() raise AuthError("该邮箱已注册并验证,请直接登录") # 检查冷却 latest = conn.execute(""" SELECT * FROM pending_registration WHERE email=%s AND used_at='' ORDER BY id DESC LIMIT 1 """, (email,)).fetchone() if latest: age = (now_dt - datetime.fromisoformat(latest["created_at"])).total_seconds() if age < RESEND_COOLDOWN_SECONDS: conn.close() raise AuthError("验证码发送过于频繁,请稍后再试") code = _new_verify_code() conn.execute(""" INSERT INTO pending_registration (email, code_hash, expires_at, created_at) VALUES (%s, %s, %s, %s) """, (email, _hash_token(code), _iso(now_dt + timedelta(minutes=VERIFY_CODE_MINUTES)), now)) conn.commit() conn.close() email_sent = send_verification_email(email, code) if is_smtp_configured() else False return { "email": email, "verification_code": code, "email_sent": bool(email_sent), } def complete_registration(email: str, code: str, password: str, invite_code: str = "") -> dict: """注册步骤2:验证验证码,通过后创建用户。不预先建占位记录。""" init_auth_db() email = _normalize_email(email) code_hash = _hash_token((code or "").strip()) conn = get_conn() now_dt = _now() now = _iso(now_dt) # 从 pending_registration 查找验证码 row = conn.execute(""" SELECT * FROM pending_registration WHERE email=%s AND used_at='' ORDER BY id DESC LIMIT 1 """, (email,)).fetchone() if not row: conn.close() raise AuthError("验证码不存在或已使用") if datetime.fromisoformat(row["expires_at"]) < now_dt: conn.close() raise AuthError("验证码已过期") if not hmac.compare_digest(row["code_hash"], code_hash): conn.close() raise AuthError("验证码错误") # 验证码通过 → 标记已使用 conn.execute("UPDATE pending_registration SET used_at=%s WHERE id=%s", (now, row["id"])) # 检查是否已有用户记录(旧流程遗留的未验证用户) existing = conn.execute("SELECT * FROM app_user WHERE email=%s", (email,)).fetchone() if existing: # 已有用户记录:更新密码、邀请码,激活 pw_hash, pw_salt = _hash_password(password) inviter_id = existing["invited_by_user_id"] ic = (invite_code or "").strip().upper() if not ic and not inviter_id: conn.close() raise AuthError("请输入邀请码") if ic and not inviter_id: inviter = conn.execute("SELECT id FROM app_user WHERE invite_code=%s", (ic,)).fetchone() if not inviter: conn.close() raise AuthError("邀请码无效") inviter_id = inviter["id"] conn.execute(""" UPDATE app_user SET email_verified=1, status='active', password_hash=%s, password_salt=%s, invited_by_user_id=%s, updated_at=%s WHERE id=%s """, (pw_hash, pw_salt, inviter_id, now, existing["id"])) conn.commit() fresh = conn.execute("SELECT * FROM app_user WHERE id=%s", (existing["id"],)).fetchone() conn.close() return _public_user(fresh) # 全新用户 → 创建 pw_hash, pw_salt = _hash_password(password) inviter_id = None ic = (invite_code or "").strip().upper() if not ic: conn.close() raise AuthError("请输入邀请码") inviter = conn.execute("SELECT id FROM app_user WHERE invite_code=%s", (ic,)).fetchone() if not inviter: conn.close() raise AuthError("邀请码无效") inviter_id = inviter["id"] own_invite_code = _new_invite_code(conn) row = 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 ) VALUES (%s, %s, %s, 1, 'active', %s, %s, 0, %s, %s) RETURNING id """, (email, pw_hash, pw_salt, own_invite_code, inviter_id, now, now)) user_id = row.fetchone()["id"] conn.commit() fresh = conn.execute("SELECT * FROM app_user WHERE id=%s", (user_id,)).fetchone() conn.close() return _public_user(fresh) def register_user(email: str, password: str, invite_code: str = "") -> dict: init_auth_db() email = _normalize_email(email) password_hash, salt = _hash_password(password) conn = get_conn() now = _iso(_now()) try: inviter_id = None invite_code = (invite_code or "").strip().upper() if invite_code: inviter = conn.execute("SELECT id FROM app_user WHERE invite_code=%s", (invite_code,)).fetchone() if not inviter: raise AuthError("邀请码无效") inviter_id = inviter["id"] own_invite_code = _new_invite_code(conn) row = 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 ) VALUES (%s, %s, %s, 0, 'pending_email_verification', %s, %s, 0, %s, %s) RETURNING id """, (email, password_hash, salt, own_invite_code, inviter_id, now, now)) user_id = row.fetchone()["id"] code = _new_verify_code() conn.execute(""" INSERT INTO email_verification_code (user_id, email, code_hash, purpose, expires_at, created_at) VALUES (%s, %s, %s, 'register', %s, %s) """, (user_id, email, _hash_token(code), _iso(_now() + timedelta(minutes=VERIFY_CODE_MINUTES)), now)) conn.commit() row = conn.execute("SELECT * FROM app_user WHERE id=%s", (user_id,)).fetchone() public = _public_user(row) email_sent = send_verification_email(email, code) if is_smtp_configured() else False public.update({ "user_id": user_id, "verification_code": code, "email_sent": bool(email_sent), "invited_by_user_id": inviter_id, }) return public except IntegrityError: raise AuthError("邮箱已注册") finally: conn.close() def resend_verification_code(email: str) -> dict: """重新发送注册验证码。优先查 pending_registration(新流程),兼容旧 email_verification_code。""" init_auth_db() email = _normalize_email(email) conn = get_conn() now_dt = _now() now = _iso(now_dt) # 已验证用户不需要重发 user = conn.execute("SELECT * FROM app_user WHERE email=%s AND email_verified=1", (email,)).fetchone() if user: conn.close() raise AuthError("邮箱已验证,无需重复发送") # 查 pending_registration 冷却 latest = conn.execute(""" SELECT * FROM pending_registration WHERE email=%s AND used_at='' ORDER BY id DESC LIMIT 1 """, (email,)).fetchone() if latest: age = (now_dt - datetime.fromisoformat(latest["created_at"])).total_seconds() if age < RESEND_COOLDOWN_SECONDS: conn.close() raise AuthError("验证码发送过于频繁,请稍后再试") else: legacy_latest = conn.execute(""" SELECT * FROM email_verification_code WHERE email=%s AND purpose='register' AND used_at='' ORDER BY id DESC LIMIT 1 """, (email,)).fetchone() if legacy_latest: age = (now_dt - datetime.fromisoformat(legacy_latest["created_at"])).total_seconds() if age < RESEND_COOLDOWN_SECONDS: conn.close() raise AuthError("验证码发送过于频繁,请稍后再试") code = _new_verify_code() conn.execute(""" INSERT INTO pending_registration (email, code_hash, expires_at, created_at) VALUES (%s, %s, %s, %s) """, (email, _hash_token(code), _iso(now_dt + timedelta(minutes=VERIFY_CODE_MINUTES)), now)) conn.commit() conn.close() email_sent = send_verification_email(email, code) if is_smtp_configured() else False return {"email": email, "verification_code": code, "email_sent": bool(email_sent)} def verify_email(email: str, code: str) -> dict: init_auth_db() email = _normalize_email(email) code_hash = _hash_token((code or "").strip()) conn = get_conn() now_dt = _now() now = _iso(now_dt) row = conn.execute(""" SELECT * FROM email_verification_code WHERE email=%s AND purpose='register' AND used_at='' ORDER BY id DESC LIMIT 1 """, (email,)).fetchone() if not row: conn.close() raise AuthError("验证码不存在或已使用") if datetime.fromisoformat(row["expires_at"]) < now_dt: conn.close() raise AuthError("验证码已过期") if not hmac.compare_digest(row["code_hash"], code_hash): conn.close() raise AuthError("验证码错误") conn.execute("UPDATE email_verification_code SET used_at=%s WHERE id=%s", (now, row["id"])) conn.execute(""" UPDATE app_user SET email_verified=1, status='active', updated_at=%s WHERE id=%s """, (now, row["user_id"])) conn.commit() user = conn.execute("SELECT * FROM app_user WHERE id=%s", (row["user_id"],)).fetchone() conn.close() return _public_user(user) def change_password(user_id: int, old_password: str, new_password: str) -> dict: """修改密码:先验证旧密码,再更新为新密码。""" init_auth_db() conn = get_conn() user = conn.execute("SELECT * FROM app_user WHERE id=%s", (user_id,)).fetchone() if not user: conn.close() raise AuthError("用户不存在") if not _verify_password(old_password, user["password_hash"], user["password_salt"]): conn.close() raise AuthError("旧密码错误") pw_hash, pw_salt = _hash_password(new_password) now = _iso(_now()) conn.execute("UPDATE app_user SET password_hash=%s, password_salt=%s, updated_at=%s WHERE id=%s", (pw_hash, pw_salt, now, user_id)) conn.commit() conn.close() return {"ok": True, "message": "密码修改成功"} def logout_user(token: str): """登出:吊销当前 session token。""" init_auth_db() if not token: return conn = get_conn() now = _iso(_now()) conn.execute("UPDATE user_session SET revoked_at=%s WHERE token_hash=%s", (now, _hash_token(token))) conn.commit() conn.close() def login_user(email: str, password: str) -> dict: init_auth_db() email = _normalize_email(email) conn = get_conn() user = conn.execute("SELECT * FROM app_user WHERE email=%s", (email,)).fetchone() if not user or not _verify_password(password, user["password_hash"], user["password_salt"]): conn.close() raise AuthError("邮箱或密码错误") if not user["email_verified"]: conn.close() raise AuthError("邮箱未验证") if user["status"] != "active": conn.close() raise AuthError("账号状态不可用") token = secrets.token_urlsafe(32) now = _now() conn.execute(""" INSERT INTO user_session (user_id, token_hash, created_at, expires_at) VALUES (%s, %s, %s, %s) """, (user["id"], _hash_token(token), _iso(now), _iso(now + timedelta(days=SESSION_DAYS)))) conn.execute("UPDATE app_user SET last_login_at=%s, updated_at=%s WHERE id=%s", (_iso(now), _iso(now), user["id"])) conn.commit() fresh = conn.execute("SELECT * FROM app_user WHERE id=%s", (user["id"],)).fetchone() conn.close() return {"token": token, "user": _public_user(fresh), "expires_at": _iso(now + timedelta(days=SESSION_DAYS))} def get_user_by_session_token(token: str): init_auth_db() if not token: return None conn = get_conn() row = conn.execute(""" SELECT u.* FROM user_session s JOIN app_user u ON u.id=s.user_id WHERE s.token_hash=%s AND s.revoked_at='' AND s.expires_at > NOW()::TEXT LIMIT 1 """, (_hash_token(token),)).fetchone() conn.close() return _public_user(row) if row else None def claim_free_trial(user_id: int) -> dict: """领取免费体验:创建 0 USDT 订单 + 订阅记录,保证订阅都有订单链路可审计。""" init_auth_db() conn = get_conn() now_dt = _now() now = _iso(now_dt) user = conn.execute("SELECT * FROM app_user WHERE id=%s", (user_id,)).fetchone() if not user: conn.close() raise AuthError("用户不存在") if not user["email_verified"] or user["status"] != "active": conn.close() raise AuthError("请先完成邮箱验证") if int(user["free_trial_claimed"] or 0): conn.close() raise AuthError("新用户免费订阅只能领取一次") start = now_dt end = now_dt + timedelta(days=FREE_TRIAL_DAYS) order_row = conn.execute(""" INSERT INTO payment_order ( user_id, plan_code, amount_usdt, chain, pay_address, txid, status, created_at, paid_at, expire_at, admin_note, raw_payload_json ) VALUES (%s, 'free_trial_1m', 0, 'SYSTEM', '', '', 'paid', %s, %s, %s, '免费体验自动开通', '{}') RETURNING id """, (user_id, now, now, _iso(end))) order_id = order_row.fetchone()["id"] sub_row = conn.execute(""" INSERT INTO user_subscription (user_id, plan_code, start_at, end_at, status, source, order_id, created_at, updated_at) VALUES (%s, 'free_trial_1m', %s, %s, 'active', 'free_trial', %s, %s, %s) RETURNING id """, (user_id, _iso(start), _iso(end), order_id, now, now)) sub_id = sub_row.fetchone()["id"] conn.execute("UPDATE app_user SET free_trial_claimed=1, updated_at=%s WHERE id=%s", (now, user_id)) conn.commit() row = conn.execute("SELECT * FROM user_subscription WHERE id=%s", (sub_id,)).fetchone() conn.close() return _row_to_dict(row) def get_current_subscription(user_id: int): init_auth_db() conn = get_conn() row = conn.execute(""" SELECT * FROM user_subscription WHERE user_id=%s AND status='active' AND end_at > NOW()::TEXT ORDER BY end_at::timestamp DESC LIMIT 1 """, (user_id,)).fetchone() conn.close() return _row_to_dict(row) def get_table_columns(table_name: str) -> set[str]: init_auth_db() return table_columns(table_name) # ====== ADMIN ====== (v1.7.8) def set_user_admin(email: str, is_admin: bool = True): """设置用户为管理员(仅开发者手动调用)""" conn = get_conn() conn.execute("UPDATE app_user SET is_admin=%s WHERE email=%s", (1 if is_admin else 0, email)) conn.commit() conn.close() return True def is_user_admin(user_id: int) -> bool: conn = get_conn() row = conn.execute("SELECT is_admin FROM app_user WHERE id=%s", (user_id,)).fetchone() conn.close() return bool(row and row[0]) def log_user_activity(user_id: int, action: str, page: str = "", ip: str = ""): """记录用户活动""" try: conn = get_conn() conn.execute( "INSERT INTO user_activity (user_id, action, page, ip, created_at) VALUES (%s,%s,%s,%s,%s)", (user_id, action, page, ip, _iso(_now())) ) conn.commit() conn.close() except Exception: pass def _plan_display_name(plan_code: str) -> str: names = { "free_trial_1m": "免费体验", "monthly_usdt": "月付", "quarterly_usdt": "季付", "yearly_usdt": "年付", } return names.get(plan_code or "", plan_code or "未开通") def _subscription_status_label(sub_status: str = "", end_at: str = "") -> str: if not sub_status: return "未开通" if sub_status != "active": return "已失效" try: if end_at and datetime.fromisoformat(str(end_at)) <= _now(): return "已过期" except Exception: pass return "有效" def get_admin_stats(): """管理看板统计数据:PV 是访问量,DAU/WAU 是去重用户数。""" init_auth_db() conn = get_conn() today = _now().strftime("%Y-%m-%d") total_users = conn.execute("SELECT COUNT(*) FROM app_user").fetchone()[0] new_users_today = conn.execute( "SELECT COUNT(*) FROM app_user WHERE created_at LIKE %s", (today + "%",) ).fetchone()[0] # PV:page_view 事件数量,不去重。 pv_total = conn.execute("SELECT COUNT(*) FROM user_activity WHERE action='page_view'").fetchone()[0] pv_today = conn.execute( "SELECT COUNT(*) FROM user_activity WHERE action='page_view' AND created_at LIKE %s", (today + "%",) ).fetchone()[0] week_ago = (_now() - timedelta(days=7)).strftime("%Y-%m-%d") pv_7d = conn.execute( "SELECT COUNT(*) FROM user_activity WHERE action='page_view' AND created_at >= %s", (week_ago,) ).fetchone()[0] # DAU/WAU:去重用户数,保留为辅助指标。 dau = conn.execute( "SELECT COUNT(DISTINCT user_id) FROM user_activity WHERE created_at LIKE %s", (today + "%",) ).fetchone()[0] wau = conn.execute( "SELECT COUNT(DISTINCT user_id) FROM user_activity WHERE created_at >= %s", (week_ago,) ).fetchone()[0] active_subs = conn.execute( "SELECT COUNT(*) FROM user_subscription WHERE status='active' AND end_at > NOW()::TEXT" ).fetchone()[0] total_orders = conn.execute("SELECT COUNT(*) FROM payment_order").fetchone()[0] paid_orders = conn.execute("SELECT COUNT(*) FROM payment_order WHERE status IN ('paid','confirmed')").fetchone()[0] thirty_ago = (_now() - timedelta(days=30)).strftime("%Y-%m-%d") pv_trend = conn.execute(""" SELECT substr(created_at, 1, 10) as day, COUNT(*) as cnt FROM user_activity WHERE action='page_view' AND created_at >= %s GROUP BY day ORDER BY day """, (thirty_ago,)).fetchall() dau_trend = conn.execute(""" SELECT substr(created_at, 1, 10) as day, COUNT(DISTINCT user_id) as cnt FROM user_activity WHERE created_at >= %s GROUP BY day ORDER BY day """, (thirty_ago,)).fetchall() today_active = conn.execute(""" SELECT u.id, u.email, u.created_at, u.last_login_at, MAX(ua.created_at) AS last_activity_at FROM user_activity ua JOIN app_user u ON u.id = ua.user_id WHERE ua.created_at LIKE %s GROUP BY u.id, u.email, u.created_at, u.last_login_at ORDER BY last_activity_at DESC LIMIT 20 """, (today + "%",)).fetchall() conn.close() return { "total_users": total_users, "new_users_today": new_users_today, "active_subscriptions": active_subs, "total_orders": total_orders, "paid_orders": paid_orders, "pv_total": pv_total, "pv_today": pv_today, "pv_7d": pv_7d, "dau_today": dau, "wau_7d": wau, "pv_trend": [{"day": r[0], "count": r[1]} for r in pv_trend], "dau_trend": [{"day": r[0], "count": r[1]} for r in dau_trend], "today_active": [ {"id": r[0], "email": r[1], "created_at": r[2], "last_login_at": r[3], "last_activity_at": r[4]} for r in today_active ], } def get_admin_users(search: str = "", offset: int = 0, limit: int = 50, tab: str = "all"): """管理员用户列表。tab: all | today_active | admin | pending""" init_auth_db() conn = get_conn() tab_where = "" tab_params = [] if tab == "admin": tab_where = "WHERE u.is_admin = 1" elif tab == "pending": tab_where = "WHERE (u.email_verified = 0 OR u.status = 'pending_email_verification')" elif tab == "today_active": today = _now().strftime("%Y-%m-%d") tab_where = "WHERE u.id IN (SELECT DISTINCT user_id FROM user_activity WHERE created_at LIKE %s)" tab_params = [today + "%"] if search: search_clause = "u.email LIKE %s" if not tab_where else " AND u.email LIKE %s" where = tab_where + search_clause params = tab_params + [f"%{search}%"] else: where = tab_where params = tab_params base_from = """ FROM app_user u LEFT JOIN user_subscription us ON us.id = ( SELECT us2.id FROM user_subscription us2 WHERE us2.user_id = u.id ORDER BY CASE WHEN us2.status='active' AND us2.end_at > NOW()::TEXT THEN 0 ELSE 1 END, us2.end_at::timestamp DESC, us2.id DESC LIMIT 1 ) LEFT JOIN subscription_plan sp ON sp.code = us.plan_code LEFT JOIN payment_order po ON po.id = us.order_id LEFT JOIN payment_order lo ON lo.id = ( SELECT po2.id FROM payment_order po2 WHERE po2.user_id = u.id ORDER BY po2.created_at::timestamp DESC, po2.id DESC LIMIT 1 ) """ select_cols = """ SELECT u.id, u.email, u.email_verified, u.status, u.is_admin, u.created_at, u.last_login_at, us.plan_code, COALESCE(sp.name, us.plan_code) AS plan_name, us.start_at, us.end_at, us.status AS subscription_status, us.source AS subscription_source, us.order_id, po.status AS subscription_order_status, lo.id AS latest_order_id, lo.plan_code AS latest_order_plan_code, lo.status AS latest_order_status, lo.amount_usdt AS latest_order_amount, lo.created_at AS latest_order_created_at, (SELECT COUNT(*) FROM payment_order po3 WHERE po3.user_id = u.id) AS order_count """ rows = conn.execute( f"{select_cols} {base_from} {where} ORDER BY u.id DESC LIMIT %s OFFSET %s", (*params, limit, offset) ).fetchall() total = conn.execute(f"SELECT COUNT(*) FROM app_user u {where}", (*params,)).fetchone()[0] conn.close() users = [] for r in rows: d = _row_to_dict(r) plan_code = d.get("plan_code") or "" latest_order_plan = d.get("latest_order_plan_code") or "" users.append({ "id": d.get("id"), "email": d.get("email"), "email_verified": bool(d.get("email_verified")), "status": d.get("status"), "registration_status": "注册完成" if d.get("email_verified") and d.get("status") == "active" else "待邮箱验证", "is_admin": bool(d.get("is_admin")), "created_at": d.get("created_at"), "last_login_at": d.get("last_login_at") or "", "subscription_plan_code": plan_code, "subscription_plan_name": d.get("plan_name") or _plan_display_name(plan_code), "subscription_start_at": d.get("start_at") or "", "subscription_end_at": d.get("end_at") or "", "subscription_status": d.get("subscription_status") or "", "subscription_status_label": _subscription_status_label(d.get("subscription_status") or "", d.get("end_at") or ""), "subscription_source": d.get("subscription_source") or "", "subscription_order_id": d.get("order_id"), "subscription_order_status": d.get("subscription_order_status") or "", "latest_order_id": d.get("latest_order_id"), "latest_order_plan_code": latest_order_plan, "latest_order_plan_name": _plan_display_name(latest_order_plan), "latest_order_status": d.get("latest_order_status") or "", "latest_order_amount": d.get("latest_order_amount"), "latest_order_created_at": d.get("latest_order_created_at") or "", "order_count": int(d.get("order_count") or 0), }) return {"total": total, "users": users} def get_admin_orders(search: str = "", offset: int = 0, limit: int = 50, status: str = "all"): """管理员订阅订单列表。与用户列表分离,订单按创建时间倒序分页。""" init_auth_db() conn = get_conn() where_parts = [] params = [] status = (status or "all").strip() if status and status != "all": where_parts.append("po.status = %s") params.append(status) if search: where_parts.append("(u.email LIKE %s OR po.txid LIKE %s OR CAST(po.id AS TEXT) = %s)") params.extend([f"%{search}%", f"%{search}%", str(search).lstrip("#")]) where = ("WHERE " + " AND ".join(where_parts)) if where_parts else "" limit = max(1, min(int(limit or 50), 200)) offset = max(0, int(offset or 0)) rows = conn.execute(f""" SELECT po.id, po.user_id, u.email, po.plan_code, COALESCE(sp.name, po.plan_code) AS plan_name, po.amount_usdt, po.chain, po.pay_address, po.txid, po.status, po.created_at, po.paid_at, po.expire_at, po.admin_note, us.id AS subscription_id, us.start_at AS subscription_start_at, us.end_at AS subscription_end_at, us.status AS subscription_status FROM payment_order po JOIN app_user u ON u.id = po.user_id LEFT JOIN subscription_plan sp ON sp.code = po.plan_code LEFT JOIN user_subscription us ON us.order_id = po.id {where} ORDER BY po.created_at::timestamp DESC, po.id DESC LIMIT %s OFFSET %s """, (*params, limit, offset)).fetchall() total = conn.execute(f""" SELECT COUNT(*) FROM payment_order po JOIN app_user u ON u.id = po.user_id {where} """, tuple(params)).fetchone()[0] status_rows = conn.execute("SELECT status, COUNT(*) FROM payment_order GROUP BY status").fetchall() conn.close() orders = [] for r in rows: d = _row_to_dict(r) orders.append({ "id": d.get("id"), "user_id": d.get("user_id"), "email": d.get("email"), "plan_code": d.get("plan_code"), "plan_name": d.get("plan_name") or _plan_display_name(d.get("plan_code") or ""), "amount_usdt": d.get("amount_usdt"), "chain": d.get("chain") or "", "pay_address": d.get("pay_address") or "", "txid": d.get("txid") or "", "status": d.get("status") or "", "created_at": d.get("created_at") or "", "paid_at": d.get("paid_at") or "", "expire_at": d.get("expire_at") or "", "admin_note": d.get("admin_note") or "", "subscription_id": d.get("subscription_id"), "subscription_start_at": d.get("subscription_start_at") or "", "subscription_end_at": d.get("subscription_end_at") or "", "subscription_status": d.get("subscription_status") or "", }) return { "total": total, "orders": orders, "status_counts": {r[0] or "unknown": r[1] for r in status_rows}, } def get_referral_stats(user_id: int): """用户推荐统计""" conn = get_conn() # 被邀请人数 total = conn.execute( "SELECT COUNT(*) FROM app_user WHERE invited_by_user_id = %s", (user_id,) ).fetchone()[0] # 已注册(至少验证了邮箱或状态为active) registered = conn.execute( "SELECT COUNT(*) FROM app_user WHERE invited_by_user_id = %s AND (email_verified = 1 OR status = 'active')", (user_id,) ).fetchone()[0] # 被邀请人列表 rows = conn.execute( "SELECT email, email_verified, status, created_at " "FROM app_user WHERE invited_by_user_id = %s ORDER BY created_at DESC LIMIT 50", (user_id,) ).fetchall() conn.close() return { "total_invited": total, "total_registered": registered, "invited_users": [ {"email": r[0], "email_verified": bool(r[1]), "status": r[2], "created_at": r[3]} for r in rows ], } # ====== PERSONALIZATION ====== (v1.7.9) def _normalize_symbol(symbol: str) -> str: s = (symbol or "").strip().upper() if not s: return "" if "/" not in s: s = s + "/USDT" return s def add_watchlist_symbol(user_id: int, symbol: str): init_auth_db() symbol = _normalize_symbol(symbol) if not symbol: return False conn = get_conn() conn.execute( "INSERT INTO user_watchlist (user_id, symbol, created_at) VALUES (%s, %s, %s) ON CONFLICT DO NOTHING", (user_id, symbol, _iso(_now())) ) conn.commit() conn.close() return True def remove_watchlist_symbol(user_id: int, symbol: str): init_auth_db() symbol = _normalize_symbol(symbol) conn = get_conn() conn.execute("DELETE FROM user_watchlist WHERE user_id=%s AND symbol=%s", (user_id, symbol)) conn.commit() conn.close() return True def get_watchlist_symbols(user_id: int): init_auth_db() conn = get_conn() rows = conn.execute("SELECT symbol FROM user_watchlist WHERE user_id=%s ORDER BY symbol", (user_id,)).fetchall() conn.close() return [r["symbol"] for r in rows] def save_observation(user_id: int, rec_id: int, note: str = ""): init_auth_db() now = _iso(_now()) conn = get_conn() conn.execute(""" INSERT INTO user_saved_observation (user_id, rec_id, note, created_at, updated_at) VALUES (%s, %s, %s, %s, %s) ON CONFLICT(user_id, rec_id) DO UPDATE SET note=excluded.note, updated_at=excluded.updated_at """, (user_id, int(rec_id), (note or "")[:200], now, now)) conn.commit() conn.close() return True def remove_observation(user_id: int, rec_id: int): init_auth_db() conn = get_conn() conn.execute("DELETE FROM user_saved_observation WHERE user_id=%s AND rec_id=%s", (user_id, int(rec_id))) conn.commit() conn.close() return True def get_saved_observations(user_id: int): init_auth_db() conn = get_conn() rows = conn.execute(""" SELECT id, user_id, rec_id, note, created_at, updated_at FROM user_saved_observation WHERE user_id=%s ORDER BY updated_at DESC, id DESC """, (user_id,)).fetchall() conn.close() return [_row_to_dict(r) for r in rows] _DEFAULT_PUSH_RULES = { "watchlist_only": False, "min_score": 0, "min_rr": 0.0, "push_buy_now": True, "push_wait_pullback": True, "push_observe": False, "quiet_start": "", "quiet_end": "", } def get_push_rules(user_id: int): init_auth_db() conn = get_conn() row = conn.execute("SELECT * FROM user_push_rule WHERE user_id=%s", (user_id,)).fetchone() conn.close() rules = dict(_DEFAULT_PUSH_RULES) if row: data = _row_to_dict(row) rules.update({ "watchlist_only": bool(data.get("watchlist_only")), "min_score": int(data.get("min_score") or 0), "min_rr": float(data.get("min_rr") or 0), "push_buy_now": bool(data.get("push_buy_now")), "push_wait_pullback": bool(data.get("push_wait_pullback")), "push_observe": bool(data.get("push_observe")), "quiet_start": data.get("quiet_start") or "", "quiet_end": data.get("quiet_end") or "", }) return rules def update_push_rules(user_id: int, rules: dict): init_auth_db() current = get_push_rules(user_id) current.update(rules or {}) now = _iso(_now()) conn = get_conn() conn.execute(""" INSERT INTO user_push_rule ( user_id, watchlist_only, min_score, min_rr, push_buy_now, push_wait_pullback, push_observe, quiet_start, quiet_end, updated_at ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT(user_id) DO UPDATE SET watchlist_only=excluded.watchlist_only, min_score=excluded.min_score, min_rr=excluded.min_rr, push_buy_now=excluded.push_buy_now, push_wait_pullback=excluded.push_wait_pullback, push_observe=excluded.push_observe, quiet_start=excluded.quiet_start, quiet_end=excluded.quiet_end, updated_at=excluded.updated_at """, ( user_id, 1 if current.get("watchlist_only") else 0, int(current.get("min_score") or 0), float(current.get("min_rr") or 0), 1 if current.get("push_buy_now") else 0, 1 if current.get("push_wait_pullback") else 0, 1 if current.get("push_observe") else 0, current.get("quiet_start") or "", current.get("quiet_end") or "", now, )) conn.commit() conn.close() return get_push_rules(user_id)