"""用户、邀请、订阅与支付预留数据库层。
第一阶段目标:
- 邮箱+密码注册登录
- 邮箱验证码验证(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} 分钟内有效。
如果不是你本人操作,请忽略本邮件。为保护账号安全,请不要把验证码转发给任何人。
© 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)