alphax/app/db/auth_db.py
2026-05-16 14:52:10 +08:00

1085 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""用户、邀请、订阅与支付预留数据库层。
第一阶段目标:
- 邮箱+密码注册登录
- 邮箱验证码验证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.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 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 Agent Crypto 邮箱验证码"
msg["From"] = sender
msg["To"] = to_email
msg.set_content(
f"AlphaX Agent Crypto 邮箱验证码:{code}\n\n"
f"AI Market Intelligence.\n"
f"验证码 {VERIFY_CODE_MINUTES} 分钟内有效,用于完成 AlphaX Agent Crypto 注册或登录验证。\n"
f"如果不是你本人操作,请忽略本邮件。"
)
msg.add_alternative(
f"""
<!doctype html>
<html lang="zh-CN">
<body style="margin:0;padding:0;background:#f6f6f3;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,'PingFang SC','Microsoft YaHei',sans-serif;color:#1c1c1e;">
<div style="max-width:560px;margin:0 auto;padding:28px 18px;">
<div style="background:#ffffff;border:1px solid #ece7d8;border-radius:22px;overflow:hidden;box-shadow:0 18px 48px rgba(28,28,30,.08);">
<div style="padding:26px 28px 18px;background:linear-gradient(135deg,#1c1c1e 0%,#2d2d32 52%,#4262ff 100%);color:#ffffff;">
<div style="display:inline-block;width:34px;height:34px;line-height:34px;text-align:center;border-radius:10px;background:#ffd02f;color:#1c1c1e;font-weight:900;font-size:18px;margin-bottom:14px;">A</div>
<div style="font-size:24px;font-weight:850;letter-spacing:0;">AlphaX Agent Crypto</div>
<div style="font-size:13px;opacity:.78;margin-top:4px;letter-spacing:.02em;">AI Market Intelligence.</div>
</div>
<div style="padding:28px;">
<div style="font-size:18px;font-weight:800;margin-bottom:8px;">邮箱验证码</div>
<p style="margin:0 0 18px;color:#5f626b;font-size:14px;line-height:1.75;">请使用下面的验证码完成 AlphaX Agent Crypto 注册或登录验证。验证码 {VERIFY_CODE_MINUTES} 分钟内有效。</p>
<div style="margin:22px 0;padding:20px 18px;border-radius:18px;background:#fff8d8;border:1px solid #f2de82;text-align:center;">
<div style="font-size:13px;color:#6d642b;margin-bottom:8px;">你的验证码</div>
<div style="font-size:34px;font-weight:900;letter-spacing:8px;color:#1c1c1e;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;">{code}</div>
</div>
<p style="margin:0;color:#7a7d86;font-size:13px;line-height:1.7;">如果不是你本人操作,请忽略本邮件。为保护账号安全,请不要把验证码转发给任何人。</p>
</div>
</div>
<div style="text-align:center;color:#a0a3ad;font-size:12px;margin-top:18px;">© 2026 AlphaX Agent Crypto · AI 驱动的 Crypto 市场情报系统</div>
</div>
</body>
</html>
""",
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():
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()
email = (os.getenv("ALPHAX_DEFAULT_ADMIN_EMAIL") or "").strip().lower()
password = os.getenv("ALPHAX_DEFAULT_ADMIN_PASSWORD") or ""
enabled = (os.getenv("ALPHAX_BOOTSTRAP_ADMIN", "1") or "1").strip().lower()
if enabled in ("0", "false", "no", "off"):
return {"created": False, "reason": "disabled"}
if not email or not password:
return {"created": False, "reason": "missing_env"}
email = _normalize_email(email)
password_hash, salt = _hash_password(password)
conn = get_conn()
try:
existing_count = conn.execute("SELECT COUNT(*) FROM app_user").fetchone()[0]
if existing_count:
return {"created": False, "reason": "users_exist", "user_count": int(existing_count)}
now = _iso(_now())
invite_code = _new_invite_code(conn)
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]
# PVpage_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 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 %s
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 %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)