From 9a60abcb8f91fb809c3e342f09e28d82de807e10 Mon Sep 17 00:00:00 2001 From: nacadmin Date: Sun, 22 Mar 2026 23:06:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routers/users.py | 286 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 backend/routers/users.py diff --git a/backend/routers/users.py b/backend/routers/users.py new file mode 100644 index 0000000..53a65af --- /dev/null +++ b/backend/routers/users.py @@ -0,0 +1,286 @@ +""" +NAC 一键上链系统 - 用户注册/登录路由 v3.1 +更新:注册时自动调用NAC钱包微服务创建原生钱包 +支持本地账号注册、登录,JWT token认证 +""" +import os +import logging +import secrets +from datetime import datetime, timedelta, timezone +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel, EmailStr, validator +from passlib.context import CryptContext +from jose import jwt, JWTError +from database import users_col, now_utc +from nac_wallet_client import create_wallet, get_wallet + +logger = logging.getLogger("nac-onboarding.users") +router = APIRouter() + +# 密码哈希 +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# JWT配置 +JWT_SECRET = os.getenv("JWT_SECRET", "nac-rwa-secret-key-2026-xtzh-cbpp") +JWT_ALGORITHM = "HS256" +JWT_EXPIRE_HOURS = 24 * 7 # 7天有效期 + +# KYC等级权限 +KYC_PERMISSIONS = { + 0: {"can_view": True, "can_onboard": False, "can_rwa": False}, + 1: {"can_view": True, "can_onboard": False, "can_rwa": False}, + 2: {"can_view": True, "can_onboard": True, "can_rwa": False}, + 3: {"can_view": True, "can_onboard": True, "can_rwa": True}, + 4: {"can_view": True, "can_onboard": True, "can_rwa": True}, +} + +# ---- Pydantic 模型 ---- + +class RegisterRequest(BaseModel): + username: str + email: str + password: str + full_name: str = "" + + @validator("username") + def username_valid(cls, v): + v = v.strip() + if len(v) < 3: + raise ValueError("用户名至少3个字符") + if len(v) > 32: + raise ValueError("用户名最多32个字符") + return v + + @validator("password") + def password_valid(cls, v): + if len(v) < 6: + raise ValueError("密码至少6个字符") + return v + +class LoginRequest(BaseModel): + username: str # 支持用户名或邮箱 + password: str + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: dict + +# ---- 工具函数 ---- + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + +def create_token(did: str, username: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS) + payload = { + "sub": did, + "username": username, + "exp": expire, + "iat": datetime.now(timezone.utc), + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + +def decode_token(token: str) -> dict: + try: + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + except JWTError: + return None + +def make_did(username: str) -> str: + """生成NAC格式的DID""" + safe = username.lower().replace(" ", "_") + rand = secrets.token_hex(4) + return f"did:nac:user:{safe}_{rand}" + +def user_to_public(user: dict) -> dict: + """返回用户公开信息(不含密码哈希)""" + return { + "did": user.get("did"), + "username": user.get("username"), + "email": user.get("email"), + "full_name": user.get("full_name", ""), + "display_name": user.get("full_name") or user.get("username"), + "kyc_level": user.get("kyc_level", 2), + "kyc_status": user.get("kyc_status", "approved"), + "role": user.get("role", "user"), + "nac_address": user.get("nac_address", ""), + "created_at": str(user.get("created_at", "")), + } + +# ---- API端点 ---- + +@router.post("/register", summary="用户注册") +async def register(req: RegisterRequest): + """注册新用户账号,并自动创建NAC原生钱包""" + username = req.username.strip() + email = req.email.strip().lower() + + # 检查用户名是否已存在 + existing = await users_col.find_one({"username": {"$regex": f"^{username}$", "$options": "i"}}) + if existing: + raise HTTPException(status_code=400, detail="用户名已被使用") + + # 检查邮箱是否已注册 + existing_email = await users_col.find_one({"email": email}) + if existing_email: + raise HTTPException(status_code=400, detail="该邮箱已注册") + + # 创建用户 + did = make_did(username) + hashed_pw = hash_password(req.password) + now = now_utc() + + user_doc = { + "did": did, + "username": username, + "email": email, + "full_name": req.full_name.strip() or username, + "display_name": req.full_name.strip() or username, + "password_hash": hashed_pw, + "kyc_level": 2, # 新注册用户默认KYC-2(可上链) + "kyc_status": "approved", + "role": "user", + "is_active": True, + "created_at": now, + "updated_at": now, + "last_login": None, + "permissions": KYC_PERMISSIONS[2], + "nac_address": "", # 钱包地址(创建后填入) + "wallet_created": False, # 钱包创建状态 + } + + await users_col.insert_one(user_doc) + logger.info(f"新用户注册: {username} ({did})") + + # ===== 自动创建NAC原生钱包 ===== + wallet_info = None + mnemonic_data = None + try: + wallet_result = await create_wallet(user_id=did, user_name=username) + if wallet_result: + nac_address = wallet_result.get("address", "") + # 将钱包地址写回用户文档 + await users_col.update_one( + {"did": did}, + {"$set": { + "nac_address": nac_address, + "wallet_created": True, + "wallet_id": wallet_result.get("wallet_id"), + "updated_at": now_utc(), + }} + ) + wallet_info = { + "address": nac_address, + "wallet_id": wallet_result.get("wallet_id"), + "assets": [ + {"symbol": "XIC", "balance": "0.00", "type": "治理币"}, + {"symbol": "XTZH", "balance": "0.00", "type": "稳定币"}, + ], + } + # 助记词仅此一次返回,不存储 + if wallet_result.get("mnemonic"): + mnemonic_data = { + "mnemonic": wallet_result["mnemonic"], + "notice": wallet_result.get("notice", "请立即备份助记词,系统不会再次显示!"), + } + logger.info(f"NAC钱包创建成功: {username} -> {nac_address}") + else: + logger.warning(f"NAC钱包创建失败(服务不可达),用户 {username} 已注册,钱包待补创建") + except Exception as e: + logger.error(f"钱包创建异常: {e},用户 {username} 已注册,钱包待补创建") + + # 生成token + token = create_token(did, username) + + response = { + "success": True, + "message": "注册成功", + "access_token": token, + "token_type": "bearer", + "user": user_to_public({**user_doc, "nac_address": wallet_info["address"] if wallet_info else ""}), + } + + # 附加钱包信息(仅注册时返回一次助记词) + if wallet_info: + response["wallet"] = wallet_info + if mnemonic_data: + response["mnemonic"] = mnemonic_data + + return response + + +@router.post("/login", summary="用户登录") +async def login(req: LoginRequest): + """用户名/邮箱 + 密码登录""" + identifier = req.username.strip() + + # 查找用户(支持用户名或邮箱) + user = await users_col.find_one({ + "$or": [ + {"username": {"$regex": f"^{identifier}$", "$options": "i"}}, + {"email": identifier.lower()}, + ] + }) + + if not user: + raise HTTPException(status_code=401, detail="用户名或密码错误") + + # 验证密码 + if not verify_password(req.password, user.get("password_hash", "")): + raise HTTPException(status_code=401, detail="用户名或密码错误") + + # 检查账号状态 + if not user.get("is_active", True): + raise HTTPException(status_code=403, detail="账号已被禁用") + + # 如果钱包尚未创建,尝试补创建 + if not user.get("wallet_created", False): + try: + wallet_result = await create_wallet(user_id=user["did"], user_name=user["username"]) + if wallet_result: + await users_col.update_one( + {"did": user["did"]}, + {"$set": { + "nac_address": wallet_result.get("address", ""), + "wallet_created": True, + "wallet_id": wallet_result.get("wallet_id"), + "updated_at": now_utc(), + }} + ) + user["nac_address"] = wallet_result.get("address", "") + logger.info(f"登录时补创建钱包成功: {user['username']}") + except Exception as e: + logger.warning(f"登录时补创建钱包失败: {e}") + + # 更新最后登录时间 + await users_col.update_one( + {"did": user["did"]}, + {"$set": {"last_login": now_utc()}} + ) + + logger.info(f"用户登录: {user['username']} ({user['did']})") + + # 生成token + token = create_token(user["did"], user["username"]) + + return { + "success": True, + "message": "登录成功", + "access_token": token, + "token_type": "bearer", + "user": user_to_public(user), + } + + +@router.get("/wallet", summary="获取当前用户的NAC钱包信息") +async def get_my_wallet(current_user: dict = Depends(lambda: None)): + """ + 获取当前用户的NAC钱包信息(余额等) + 注意:此端点需要Bearer token,由get_current_user依赖注入处理 + """ + # 此端点由onboarding.py中的get_current_user处理 + pass