"""用户、邀请、订阅与支付预留数据库层。 第一阶段目标: - 邮箱+密码注册登录 - 邮箱验证码验证(SMTP 信息后续配置;当前只生成验证码并预留发送入口) - 邀请码锁定邀请关系 - 新用户免费订阅 1 个月,每个用户仅一次 - 预留 USDT 付费订单表结构,暂不实现网关扣款 """ from __future__ import annotations import hashlib import hmac import os import secrets import smtplib import sqlite3 from datetime import datetime, timedelta from email.message import EmailMessage from typing import Optional DB_PATH = os.getenv("ALPHAX_DB_PATH", os.path.join(os.path.dirname(__file__), "data", "altcoin_monitor.db")) SESSION_DAYS = 30 VERIFY_CODE_MINUTES = 15 FREE_TRIAL_DAYS = 30 RESEND_COOLDOWN_SECONDS = 60 def is_smtp_configured() -> bool: return all((os.getenv(k) or "").strip() for k in [ "ASTOCK_SMTP_HOST", "ASTOCK_SMTP_PORT", "ASTOCK_SMTP_USERNAME", "ASTOCK_SMTP_PASSWORD", "ASTOCK_SMTP_SENDER", ]) def send_verification_email(to_email: str, code: str) -> bool: """发送邮箱验证码。SMTP 凭据只从环境变量读取,不写入代码/数据库。""" if not is_smtp_configured(): return False host = os.getenv("ASTOCK_SMTP_HOST", "").strip() port = int(os.getenv("ASTOCK_SMTP_PORT", "465").strip() or "465") username = os.getenv("ASTOCK_SMTP_USERNAME", "").strip() password = os.getenv("ASTOCK_SMTP_PASSWORD", "") sender = os.getenv("ASTOCK_SMTP_SENDER", username).strip() msg = EmailMessage() msg["Subject"] = "AlphaX 邮箱验证码" msg["From"] = sender msg["To"] = to_email msg.set_content( f"AlphaX 邮箱验证码:{code}\n\n" f"AI Market Intelligence.\n" f"验证码 {VERIFY_CODE_MINUTES} 分钟内有效,用于完成 AlphaX 注册或登录验证。\n" f"如果不是你本人操作,请忽略本邮件。" ) msg.add_alternative( f"""
A
AlphaX
AI Market Intelligence.
邮箱验证码

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

你的验证码
{code}

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

© 2026 AlphaX · AI 驱动的 Crypto 市场情报系统
""", subtype="html", ) with smtplib.SMTP_SSL(host, port, timeout=12) 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(): # SQLite 并发治理:Web/API/cron 共用同一 DB,连接必须等待写锁而不是立即失败。 # journal_mode=WAL 只在初始化/首次连接时设置;busy_timeout 让短写事务排队。 conn = sqlite3.connect(DB_PATH, timeout=30, isolation_level=None) conn.row_factory = sqlite3.Row conn.execute("PRAGMA busy_timeout=30000") conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") return conn def init_auth_db(): conn = get_conn() conn.execute(""" CREATE TABLE IF NOT EXISTS app_user ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, password_salt TEXT NOT NULL, email_verified INTEGER DEFAULT 0, status TEXT DEFAULT 'pending_email_verification', invite_code TEXT NOT NULL UNIQUE, invited_by_user_id INTEGER, free_trial_claimed INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_login_at TEXT DEFAULT '', is_admin INTEGER DEFAULT 0, FOREIGN KEY(invited_by_user_id) REFERENCES app_user(id) ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_app_user_email ON app_user(email)") conn.execute("CREATE INDEX IF NOT EXISTS idx_app_user_invite_code ON app_user(invite_code)") conn.execute(""" CREATE TABLE IF NOT EXISTS email_verification_code ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, email TEXT NOT NULL, code_hash TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT 'register', expires_at TEXT NOT NULL, used_at TEXT DEFAULT '', created_at TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES app_user(id) ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_email_code_lookup ON email_verification_code(email, purpose, used_at)") conn.execute(""" CREATE TABLE IF NOT EXISTS user_session ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, token_hash TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, revoked_at TEXT DEFAULT '', FOREIGN KEY(user_id) REFERENCES app_user(id) ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_user_session_token ON user_session(token_hash)") conn.execute(""" CREATE TABLE IF NOT EXISTS subscription_plan ( code TEXT PRIMARY KEY, name TEXT NOT NULL, duration_days INTEGER NOT NULL, price_usdt REAL DEFAULT 0, status TEXT DEFAULT 'active', sort_order INTEGER DEFAULT 0, created_at TEXT NOT NULL ) """) conn.execute(""" INSERT OR IGNORE INTO subscription_plan (code, name, duration_days, price_usdt, status, sort_order, created_at) VALUES ('free_trial_1m', '免费体验', 30, 0, 'active', 10, ?), ('monthly_usdt', '月付', 30, 99, 'coming_soon', 20, ?), ('quarterly_usdt', '季付', 90, 259, 'coming_soon', 30, ?), ('yearly_usdt', '年付', 365, 899, 'coming_soon', 40, ?) """, (_iso(_now()), _iso(_now()), _iso(_now()), _iso(_now()))) conn.executemany(""" UPDATE subscription_plan SET name=?, duration_days=?, price_usdt=?, status=?, sort_order=? WHERE code=? """, [ ('免费体验', 30, 0, 'active', 10, 'free_trial_1m'), ('月付', 30, 99, 'coming_soon', 20, 'monthly_usdt'), ('季付', 90, 259, 'coming_soon', 30, 'quarterly_usdt'), ('年付', 365, 899, 'coming_soon', 40, 'yearly_usdt'), ]) conn.execute(""" CREATE TABLE IF NOT EXISTS user_subscription ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, plan_code TEXT NOT NULL, start_at TEXT NOT NULL, end_at TEXT NOT NULL, status TEXT DEFAULT 'active', source TEXT NOT NULL, order_id INTEGER, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES app_user(id), FOREIGN KEY(plan_code) REFERENCES subscription_plan(code) ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_user_subscription_user ON user_subscription(user_id, status, end_at)") conn.execute(""" CREATE TABLE IF NOT EXISTS payment_order ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, plan_code TEXT NOT NULL, amount_usdt REAL NOT NULL DEFAULT 0, chain TEXT NOT NULL DEFAULT 'TRC20', pay_address TEXT DEFAULT '', txid TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'pending', created_at TEXT NOT NULL, paid_at TEXT DEFAULT '', expire_at TEXT DEFAULT '', admin_note TEXT DEFAULT '', raw_payload_json TEXT DEFAULT '{}', FOREIGN KEY(user_id) REFERENCES app_user(id), FOREIGN KEY(plan_code) REFERENCES subscription_plan(code) ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_payment_order_user ON payment_order(user_id, status)") conn.execute("CREATE INDEX IF NOT EXISTS idx_payment_order_txid ON payment_order(txid)") conn.execute("CREATE INDEX IF NOT EXISTS idx_payment_order_created ON payment_order(created_at)") conn.execute(""" CREATE TABLE IF NOT EXISTS pending_registration ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL, code_hash TEXT NOT NULL, expires_at TEXT NOT NULL, used_at TEXT DEFAULT '', created_at TEXT NOT NULL ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_pending_reg_email ON pending_registration(email, used_at)") conn.execute(""" CREATE TABLE IF NOT EXISTS referral_reward ( id INTEGER PRIMARY KEY AUTOINCREMENT, inviter_user_id INTEGER NOT NULL, invitee_user_id INTEGER NOT NULL, order_id INTEGER, reward_type TEXT NOT NULL DEFAULT 'days', reward_days INTEGER DEFAULT 0, reward_amount_usdt REAL DEFAULT 0, status TEXT DEFAULT 'pending', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(inviter_user_id) REFERENCES app_user(id), FOREIGN KEY(invitee_user_id) REFERENCES app_user(id), FOREIGN KEY(order_id) REFERENCES payment_order(id) ) """) # v1.7.8: 管理模块 — is_admin列(已有表补加) + 用户活动表 try: conn.execute("ALTER TABLE app_user ADD COLUMN is_admin INTEGER DEFAULT 0") except Exception: pass conn.execute(""" CREATE TABLE IF NOT EXISTS user_activity ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, action TEXT NOT NULL, page TEXT DEFAULT '', ip TEXT DEFAULT '', created_at TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES app_user(id) ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_ua_user ON user_activity(user_id, created_at)") conn.execute("CREATE INDEX IF NOT EXISTS idx_ua_date ON user_activity(created_at)") # v1.7.9: 个性化交易助手 — 关注池、观察列表、推送规则 conn.execute(""" CREATE TABLE IF NOT EXISTS user_watchlist ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, symbol TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(user_id, symbol), FOREIGN KEY(user_id) REFERENCES app_user(id) ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_user_watchlist_user ON user_watchlist(user_id, symbol)") conn.execute(""" CREATE TABLE IF NOT EXISTS user_saved_observation ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, rec_id INTEGER NOT NULL, note TEXT DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(user_id, rec_id), FOREIGN KEY(user_id) REFERENCES app_user(id) ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_saved_obs_user ON user_saved_observation(user_id, rec_id)") conn.execute(""" CREATE TABLE IF NOT EXISTS user_push_rule ( user_id INTEGER PRIMARY KEY, watchlist_only INTEGER DEFAULT 0, min_score INTEGER DEFAULT 0, min_rr REAL DEFAULT 0, push_buy_now INTEGER DEFAULT 1, push_wait_pullback INTEGER DEFAULT 1, push_observe INTEGER DEFAULT 0, quiet_start TEXT DEFAULT '', quiet_end TEXT DEFAULT '', updated_at TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES app_user(id) ) """) conn.commit() conn.close() 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=?", (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"), } 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=?", (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=?", (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=? AND email_verified=1", (email,)).fetchone() if existing: conn.close() raise AuthError("该邮箱已注册并验证,请直接登录") # 检查冷却 latest = conn.execute(""" SELECT * FROM pending_registration WHERE email=? 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 (?, ?, ?, ?) """, (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=? 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=? WHERE id=?", (now, row["id"])) # 检查是否已有用户记录(旧流程遗留的未验证用户) existing = conn.execute("SELECT * FROM app_user WHERE email=?", (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=?", (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=?, password_salt=?, invited_by_user_id=?, updated_at=? WHERE id=? """, (pw_hash, pw_salt, inviter_id, now, existing["id"])) conn.commit() fresh = conn.execute("SELECT * FROM app_user WHERE id=?", (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=?", (ic,)).fetchone() if not inviter: conn.close() raise AuthError("邀请码无效") inviter_id = inviter["id"] own_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 ) VALUES (?, ?, ?, 1, 'active', ?, ?, 0, ?, ?) """, (email, pw_hash, pw_salt, own_invite_code, inviter_id, now, now)) conn.commit() fresh = conn.execute("SELECT * FROM app_user WHERE id=?", (cur.lastrowid,)).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=?", (invite_code,)).fetchone() if not inviter: raise AuthError("邀请码无效") inviter_id = inviter["id"] own_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 ) VALUES (?, ?, ?, 0, 'pending_email_verification', ?, ?, 0, ?, ?) """, (email, password_hash, salt, own_invite_code, inviter_id, now, now)) user_id = cur.lastrowid code = _new_verify_code() conn.execute(""" INSERT INTO email_verification_code (user_id, email, code_hash, purpose, expires_at, created_at) VALUES (?, ?, ?, 'register', ?, ?) """, (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=?", (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 sqlite3.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=? AND email_verified=1", (email,)).fetchone() if user: conn.close() raise AuthError("邮箱已验证,无需重复发送") # 查 pending_registration 冷却 latest = conn.execute(""" SELECT * FROM pending_registration WHERE email=? 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 (?, ?, ?, ?) """, (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=? 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=? WHERE id=?", (now, row["id"])) conn.execute(""" UPDATE app_user SET email_verified=1, status='active', updated_at=? WHERE id=? """, (now, row["user_id"])) conn.commit() user = conn.execute("SELECT * FROM app_user WHERE id=?", (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=?", (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=?, password_salt=?, updated_at=? WHERE id=?", (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=? WHERE token_hash=?", (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=?", (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 (?, ?, ?, ?) """, (user["id"], _hash_token(token), _iso(now), _iso(now + timedelta(days=SESSION_DAYS)))) conn.execute("UPDATE app_user SET last_login_at=?, updated_at=? WHERE id=?", (_iso(now), _iso(now), user["id"])) conn.commit() fresh = conn.execute("SELECT * FROM app_user WHERE id=?", (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=? AND s.revoked_at='' AND datetime(s.expires_at) > datetime('now') 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=?", (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_cur = 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 (?, 'free_trial_1m', 0, 'SYSTEM', '', '', 'paid', ?, ?, ?, '免费体验自动开通', '{}') """, (user_id, now, now, _iso(end))) order_id = order_cur.lastrowid sub_cur = conn.execute(""" INSERT INTO user_subscription (user_id, plan_code, start_at, end_at, status, source, order_id, created_at, updated_at) VALUES (?, 'free_trial_1m', ?, ?, 'active', 'free_trial', ?, ?, ?) """, (user_id, _iso(start), _iso(end), order_id, now, now)) conn.execute("UPDATE app_user SET free_trial_claimed=1, updated_at=? WHERE id=?", (now, user_id)) conn.commit() row = conn.execute("SELECT * FROM user_subscription WHERE id=?", (sub_cur.lastrowid,)).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=? AND status='active' AND datetime(end_at) > datetime('now') ORDER BY datetime(end_at) 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() conn = get_conn() rows = conn.execute(f"PRAGMA table_info({table_name})").fetchall() conn.close() return {r[1] for r in rows} # ====== 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=? WHERE email=?", (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=?", (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 (?,?,?,?,?)", (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 ?", (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 ?", (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 >= ?", (week_ago,) ).fetchone()[0] # DAU/WAU:去重用户数,保留为辅助指标。 dau = conn.execute( "SELECT COUNT(DISTINCT user_id) FROM user_activity WHERE created_at LIKE ?", (today + "%",) ).fetchone()[0] wau = conn.execute( "SELECT COUNT(DISTINCT user_id) FROM user_activity WHERE created_at >= ?", (week_ago,) ).fetchone()[0] active_subs = conn.execute( "SELECT COUNT(*) FROM user_subscription WHERE status='active' AND datetime(end_at) > datetime('now')" ).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 >= ? 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 >= ? GROUP BY day ORDER BY day """, (thirty_ago,)).fetchall() today_active = conn.execute(""" SELECT DISTINCT u.id, u.email, u.created_at, u.last_login_at FROM user_activity ua JOIN app_user u ON u.id = ua.user_id WHERE ua.created_at LIKE ? ORDER BY ua.created_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]} 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 ?)" tab_params = [today + "%"] if search: search_clause = "u.email LIKE ?" if not tab_where else " AND u.email LIKE ?" 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 datetime(us2.end_at) > datetime('now') THEN 0 ELSE 1 END, datetime(us2.end_at) 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 datetime(po2.created_at) 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 ? OFFSET ?", (*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 = ?") params.append(status) if search: where_parts.append("(u.email LIKE ? OR po.txid LIKE ? OR CAST(po.id AS TEXT) = ?)") 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 datetime(po.created_at) DESC, po.id DESC LIMIT ? OFFSET ? """, (*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 = ?", (user_id,) ).fetchone()[0] # 已注册(至少验证了邮箱或状态为active) registered = conn.execute( "SELECT COUNT(*) FROM app_user WHERE invited_by_user_id = ? 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 = ? 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 OR IGNORE INTO user_watchlist (user_id, symbol, created_at) VALUES (?, ?, ?)", (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=? AND symbol=?", (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=? 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 (?, ?, ?, ?, ?) 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=? AND rec_id=?", (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=? 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=?", (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 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)