Add roles/ACL so admin and users can issue activation codes; exe opens review login.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -106,6 +106,17 @@ def init_db() -> None:
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
account_id INTEGER UNIQUE,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at REAL NOT NULL,
|
||||
FOREIGN KEY(account_id) REFERENCES accounts(id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -130,12 +141,27 @@ def list_machines(conn: sqlite3.Connection) -> list[dict]:
|
||||
return [row_to_dict(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def create_activation(conn: sqlite3.Connection, account_id: int, machine_id: Optional[int]) -> str:
|
||||
def create_activation(
|
||||
conn: sqlite3.Connection,
|
||||
account_id: int,
|
||||
machine_id: Optional[int],
|
||||
created_by_user_id: Optional[int] = None,
|
||||
) -> str:
|
||||
if not conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone():
|
||||
raise ValueError("账户不存在(须由管理员预先创建)")
|
||||
code = secrets.token_urlsafe(12)
|
||||
conn.execute(
|
||||
"INSERT INTO activations(code, account_id, machine_id, status, created_at) VALUES(?,?,?,?,?)",
|
||||
(code, account_id, machine_id, "pending", now()),
|
||||
)
|
||||
# created_by 存在 activations 可选列:兼容旧库无该列时忽略
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(activations)").fetchall()}
|
||||
if "created_by" in cols:
|
||||
conn.execute(
|
||||
"INSERT INTO activations(code, account_id, machine_id, status, created_at, created_by) VALUES(?,?,?,?,?,?)",
|
||||
(code, account_id, machine_id, "pending", now(), created_by_user_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO activations(code, account_id, machine_id, status, created_at) VALUES(?,?,?,?,?)",
|
||||
(code, account_id, machine_id, "pending", now()),
|
||||
)
|
||||
return code
|
||||
|
||||
|
||||
@@ -145,6 +171,10 @@ def activate_device(conn: sqlite3.Connection, code: str, fingerprint: str, machi
|
||||
raise ValueError("激活码无效")
|
||||
if row["status"] == "revoked":
|
||||
raise ValueError("激活码已作废")
|
||||
if not row["account_id"]:
|
||||
raise ValueError("激活码未绑定账户,请联系管理员重新发放")
|
||||
if not conn.execute("SELECT id FROM accounts WHERE id=? AND enabled=1", (row["account_id"],)).fetchone():
|
||||
raise ValueError("绑定账户不存在或已停用")
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
machine_id = row["machine_id"]
|
||||
@@ -156,7 +186,7 @@ def activate_device(conn: sqlite3.Connection, code: str, fingerprint: str, machi
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO machines(name, device_fingerprint, note, enabled, last_seen_at, created_at) VALUES(?,?,?,?,?,?)",
|
||||
(machine_name or f"PC-{fingerprint[:6]}", fingerprint, "激活自动创建", 1, now(), now()),
|
||||
(machine_name or f"PC-{fingerprint[:6]}", fingerprint, "激活绑定", 1, now(), now()),
|
||||
)
|
||||
machine_id = cur.lastrowid
|
||||
conn.execute("UPDATE activations SET machine_id=? WHERE id=?", (machine_id, row["id"]))
|
||||
@@ -183,18 +213,36 @@ def auth_token(conn: sqlite3.Connection, token: str) -> Optional[dict]:
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
def list_trades(conn: sqlite3.Connection, limit: int = 200) -> list[dict]:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT t.*, a.name AS account_name, m.name AS machine_name
|
||||
FROM trades t
|
||||
LEFT JOIN accounts a ON a.id = t.account_id
|
||||
LEFT JOIN machines m ON m.id = t.machine_id
|
||||
ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
def list_trades(
|
||||
conn: sqlite3.Connection,
|
||||
limit: int = 200,
|
||||
account_id: Optional[int] = None,
|
||||
) -> list[dict]:
|
||||
if account_id is None:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT t.*, a.name AS account_name, m.name AS machine_name
|
||||
FROM trades t
|
||||
LEFT JOIN accounts a ON a.id = t.account_id
|
||||
LEFT JOIN machines m ON m.id = t.machine_id
|
||||
ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT t.*, a.name AS account_name, m.name AS machine_name
|
||||
FROM trades t
|
||||
LEFT JOIN accounts a ON a.id = t.account_id
|
||||
LEFT JOIN machines m ON m.id = t.machine_id
|
||||
WHERE t.account_id=?
|
||||
ORDER BY COALESCE(t.close_time, t.open_time, t.created_at) DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(account_id, limit),
|
||||
)
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
d = row_to_dict(r)
|
||||
@@ -226,6 +274,28 @@ def get_trade(conn: sqlite3.Connection, trade_id: int) -> Optional[dict]:
|
||||
return d
|
||||
|
||||
|
||||
def list_users(conn: sqlite3.Connection) -> list[dict]:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT u.*, a.name AS account_name
|
||||
FROM users u
|
||||
LEFT JOIN accounts a ON a.id = u.account_id
|
||||
ORDER BY u.id ASC
|
||||
"""
|
||||
)
|
||||
return [row_to_dict(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def get_user_by_username(conn: sqlite3.Connection, username: str) -> Optional[dict]:
|
||||
return row_to_dict(
|
||||
conn.execute("SELECT * FROM users WHERE username=? AND enabled=1", (username,)).fetchone()
|
||||
)
|
||||
|
||||
|
||||
def get_user(conn: sqlite3.Connection, user_id: int) -> Optional[dict]:
|
||||
return row_to_dict(conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone())
|
||||
|
||||
|
||||
def upsert_bars(conn: sqlite3.Connection, symbol: str, timeframe: str, bars: list[dict]) -> int:
|
||||
n = 0
|
||||
for b in bars:
|
||||
@@ -277,61 +347,124 @@ def set_setting(conn: sqlite3.Connection, key: str, value: str) -> None:
|
||||
|
||||
|
||||
def seed_if_empty() -> None:
|
||||
"""仅确保管理员用户存在;交易账户不自动创建,须管理员手工建。"""
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from app.config import Config
|
||||
|
||||
with get_db() as conn:
|
||||
n = conn.execute("SELECT COUNT(*) AS c FROM accounts").fetchone()["c"]
|
||||
if n == 0:
|
||||
# 兼容旧库:补 activations.created_by
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(activations)").fetchall()}
|
||||
if "created_by" not in cols:
|
||||
try:
|
||||
conn.execute("ALTER TABLE activations ADD COLUMN created_by INTEGER")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
n_users = conn.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"]
|
||||
if n_users == 0:
|
||||
# 迁移旧 settings 密码,或使用 .env 默认
|
||||
old_hash = get_setting(conn, "admin_password_hash")
|
||||
pwd_hash = old_hash or generate_password_hash(Config.ADMIN_PASSWORD)
|
||||
username = get_setting(conn, "admin_user") or Config.ADMIN_USER
|
||||
conn.execute(
|
||||
"INSERT INTO accounts(name, broker, login_id, account_type, note, enabled, created_at) VALUES(?,?,?,?,?,?,?)",
|
||||
("模拟盘-主", "MT5", "", "demo", "默认账户,可改标注", 1, now()),
|
||||
)
|
||||
n2 = conn.execute("SELECT COUNT(*) AS c FROM machines").fetchone()["c"]
|
||||
if n2 == 0:
|
||||
conn.execute(
|
||||
"INSERT INTO machines(name, device_fingerprint, note, enabled, created_at) VALUES(?,?,?,?,?)",
|
||||
("未绑定机器", "", "下载通用包后用激活码绑定", 1, now()),
|
||||
)
|
||||
if not get_setting(conn, "admin_user"):
|
||||
set_setting(conn, "admin_user", Config.ADMIN_USER)
|
||||
if not get_setting(conn, "admin_password_hash"):
|
||||
set_setting(
|
||||
conn,
|
||||
"admin_password_hash",
|
||||
generate_password_hash(Config.ADMIN_PASSWORD),
|
||||
"""INSERT INTO users(username, password_hash, role, account_id, enabled, created_at)
|
||||
VALUES(?,?,?,?,?,?)""",
|
||||
(username, pwd_hash, "admin", None, 1, now()),
|
||||
)
|
||||
|
||||
|
||||
def verify_admin(username: str, password: str) -> bool:
|
||||
def verify_login(username: str, password: str) -> Optional[dict]:
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
with get_db() as conn:
|
||||
user = get_setting(conn, "admin_user", "admin")
|
||||
pwd_hash = get_setting(conn, "admin_password_hash")
|
||||
if username != user:
|
||||
return False
|
||||
if not pwd_hash:
|
||||
from app.config import Config
|
||||
|
||||
return password == Config.ADMIN_PASSWORD
|
||||
return check_password_hash(pwd_hash, password)
|
||||
user = get_user_by_username(conn, username)
|
||||
if not user:
|
||||
return None
|
||||
if not check_password_hash(user["password_hash"], password):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def change_admin_password(old_password: str, new_password: str) -> None:
|
||||
def change_user_password(user_id: int, old_password: str, new_password: str) -> None:
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
if len(new_password) < 6:
|
||||
raise ValueError("新密码至少 6 位")
|
||||
with get_db() as conn:
|
||||
pwd_hash = get_setting(conn, "admin_password_hash")
|
||||
if pwd_hash:
|
||||
if not check_password_hash(pwd_hash, old_password):
|
||||
raise ValueError("当前密码不正确")
|
||||
else:
|
||||
from app.config import Config
|
||||
user = get_user(conn, user_id)
|
||||
if not user:
|
||||
raise ValueError("用户不存在")
|
||||
if not check_password_hash(user["password_hash"], old_password):
|
||||
raise ValueError("当前密码不正确")
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash=? WHERE id=?",
|
||||
(generate_password_hash(new_password), user_id),
|
||||
)
|
||||
|
||||
if old_password != Config.ADMIN_PASSWORD:
|
||||
raise ValueError("当前密码不正确")
|
||||
set_setting(conn, "admin_password_hash", generate_password_hash(new_password))
|
||||
|
||||
def create_user(
|
||||
conn: sqlite3.Connection,
|
||||
username: str,
|
||||
password: str,
|
||||
role: str,
|
||||
account_id: Optional[int],
|
||||
) -> int:
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
username = username.strip()
|
||||
if not username:
|
||||
raise ValueError("用户名不能为空")
|
||||
if len(password) < 6:
|
||||
raise ValueError("密码至少 6 位")
|
||||
if role not in ("admin", "user"):
|
||||
raise ValueError("角色无效")
|
||||
if role == "user":
|
||||
if not account_id:
|
||||
raise ValueError("普通用户必须绑定一个交易账户")
|
||||
if not conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone():
|
||||
raise ValueError("交易账户不存在")
|
||||
taken = conn.execute(
|
||||
"SELECT id FROM users WHERE account_id=? AND role='user'",
|
||||
(account_id,),
|
||||
).fetchone()
|
||||
if taken:
|
||||
raise ValueError("该交易账户已绑定其他用户(一人一账户)")
|
||||
else:
|
||||
account_id = None
|
||||
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO users(username, password_hash, role, account_id, enabled, created_at)
|
||||
VALUES(?,?,?,?,?,?)""",
|
||||
(username, generate_password_hash(password), role, account_id, 1, now()),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise ValueError("用户名已存在或账户已被占用") from exc
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def user_can_access_account(user: dict, account_id: Optional[int]) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
if user.get("role") == "admin":
|
||||
return True
|
||||
return account_id is not None and int(user.get("account_id") or 0) == int(account_id)
|
||||
|
||||
|
||||
# 兼容旧调用名
|
||||
def verify_admin(username: str, password: str) -> bool:
|
||||
u = verify_login(username, password)
|
||||
return bool(u and u.get("role") == "admin")
|
||||
|
||||
|
||||
def change_admin_password(old_password: str, new_password: str) -> None:
|
||||
"""兼容:修改名为 admin 的管理员密码。"""
|
||||
with get_db() as conn:
|
||||
u = get_user_by_username(conn, "admin") or conn.execute(
|
||||
"SELECT * FROM users WHERE role='admin' ORDER BY id LIMIT 1"
|
||||
).fetchone()
|
||||
if not u:
|
||||
raise ValueError("未找到管理员用户")
|
||||
uid = u["id"] if isinstance(u, sqlite3.Row) else u["id"]
|
||||
change_user_password(uid, old_password, new_password)
|
||||
|
||||
Reference in New Issue
Block a user