1255 lines
47 KiB
Python
1255 lines
47 KiB
Python
"""用户、邀请、订阅与支付预留数据库层。
|
||
|
||
第一阶段目标:
|
||
- 邮箱+密码注册登录
|
||
- 邮箱验证码验证(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 pathlib import Path
|
||
from typing import Optional
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||
DB_PATH = os.getenv("ALPHAX_DB_PATH", str(REPO_ROOT / "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"""
|
||
<!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:-.02em;">AlphaX</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 注册或登录验证。验证码 {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 · 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():
|
||
# 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)
|