feat: improve authentication and user management

- Fix token missing issue after email code registration/login
- Add last login time update for code-based login
- Support email login in addition to username login
- Fix password login for code-registered users (allow setting password)
- Fix referral code parameter passing from URL hash
- Add email column to user management table
- Improve profile page layout (align card heights, reorganize layout)
- Add i18n support for Register Bonus, Referral Bonus, Code Lock Minutes, Code Max Attempts
- Fix billing service add_credits to support reference_id parameter
- Update password handling documentation
This commit is contained in:
TIANHE
2026-01-14 20:42:10 +08:00
parent fa721c034c
commit 445e7ac290
33 changed files with 7715 additions and 234 deletions
+298 -10
View File
@@ -24,13 +24,15 @@ def list_users():
Query params:
page: int (default 1)
page_size: int (default 20, max 100)
search: str (optional, search by username/email/nickname)
"""
try:
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 20, type=int)
search = request.args.get('search', '', type=str)
page_size = min(100, max(1, page_size))
result = get_user_service().list_users(page=page, page_size=page_size)
result = get_user_service().list_users(page=page, page_size=page_size, search=search)
return jsonify({
'code': 1,
@@ -213,13 +215,143 @@ def get_roles():
})
# ==================== Billing Management (Admin) ====================
@user_bp.route('/set-credits', methods=['POST'])
@login_required
@admin_required
def set_user_credits():
"""
Set user credits (admin only).
Request body:
user_id: int (required)
credits: int (required)
remark: str (optional)
"""
try:
from app.services.billing_service import get_billing_service
data = request.get_json() or {}
user_id = data.get('user_id')
credits = data.get('credits')
remark = data.get('remark', '')
if not user_id:
return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400
if credits is None or credits < 0:
return jsonify({'code': 0, 'msg': 'Credits must be a non-negative number', 'data': None}), 400
operator_id = getattr(g, 'user_id', None)
success, result = get_billing_service().set_credits(user_id, int(credits), remark, operator_id)
if success:
return jsonify({'code': 1, 'msg': 'Credits updated successfully', 'data': {'credits': result}})
else:
return jsonify({'code': 0, 'msg': result, 'data': None}), 400
except Exception as e:
logger.error(f"set_user_credits failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/set-vip', methods=['POST'])
@login_required
@admin_required
def set_user_vip():
"""
Set user VIP status (admin only).
Request body:
user_id: int (required)
vip_days: int (optional, 0 to cancel VIP, positive number to grant VIP for days)
vip_expires_at: str (optional, ISO format datetime, overrides vip_days if provided)
remark: str (optional)
"""
try:
from datetime import datetime, timedelta, timezone
from app.services.billing_service import get_billing_service
data = request.get_json() or {}
user_id = data.get('user_id')
vip_days = data.get('vip_days')
vip_expires_at_str = data.get('vip_expires_at')
remark = data.get('remark', '')
if not user_id:
return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400
# Calculate expires_at
expires_at = None
if vip_expires_at_str:
try:
expires_at = datetime.fromisoformat(vip_expires_at_str.replace('Z', '+00:00'))
except ValueError:
return jsonify({'code': 0, 'msg': 'Invalid vip_expires_at format', 'data': None}), 400
elif vip_days is not None:
if vip_days > 0:
expires_at = datetime.now(timezone.utc) + timedelta(days=vip_days)
else:
expires_at = None # Cancel VIP
else:
return jsonify({'code': 0, 'msg': 'Provide vip_days or vip_expires_at', 'data': None}), 400
operator_id = getattr(g, 'user_id', None)
success, result = get_billing_service().set_vip(user_id, expires_at, remark, operator_id)
if success:
return jsonify({
'code': 1,
'msg': 'VIP status updated successfully',
'data': {'vip_expires_at': expires_at.isoformat() if expires_at else None}
})
else:
return jsonify({'code': 0, 'msg': result, 'data': None}), 400
except Exception as e:
logger.error(f"set_user_vip failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/credits-log', methods=['GET'])
@login_required
@admin_required
def get_user_credits_log():
"""
Get user credits log (admin only).
Query params:
user_id: int (required)
page: int (default 1)
page_size: int (default 20)
"""
try:
from app.services.billing_service import get_billing_service
user_id = request.args.get('user_id', type=int)
if not user_id:
return jsonify({'code': 0, 'msg': 'Missing user_id', 'data': None}), 400
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 20, type=int)
page_size = min(100, max(1, page_size))
result = get_billing_service().get_credits_log(user_id, page, page_size)
return jsonify({'code': 1, 'msg': 'success', 'data': result})
except Exception as e:
logger.error(f"get_user_credits_log failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
# Self-service endpoints (accessible by any logged-in user)
@user_bp.route('/profile', methods=['GET'])
@login_required
def get_profile():
"""Get current user's profile"""
"""Get current user's profile with billing info"""
try:
from app.services.billing_service import get_billing_service
user_id = getattr(g, 'user_id', None)
if not user_id:
return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401
@@ -231,6 +363,10 @@ def get_profile():
# Add permissions
user['permissions'] = get_user_service().get_user_permissions(user.get('role', 'user'))
# Add billing info
billing_info = get_billing_service().get_user_billing_info(user_id)
user['billing'] = billing_info
return jsonify({
'code': 1,
'msg': 'success',
@@ -249,8 +385,10 @@ def update_profile():
Request body:
nickname: str (optional)
email: str (optional)
avatar: str (optional)
Note: Email cannot be changed after registration (for security).
Only admin can change user email via User Management.
"""
try:
user_id = getattr(g, 'user_id', None)
@@ -260,8 +398,9 @@ def update_profile():
data = request.get_json() or {}
# Only allow updating certain fields for self-service
# Email is NOT allowed to be changed (security: bound to account)
allowed = {}
for field in ['nickname', 'email', 'avatar']:
for field in ['nickname', 'avatar']:
if field in data:
allowed[field] = data[field]
@@ -279,6 +418,117 @@ def update_profile():
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/my-credits-log', methods=['GET'])
@login_required
def get_my_credits_log():
"""
Get current user's credits log.
Query params:
page: int (default 1)
page_size: int (default 20)
"""
try:
from app.services.billing_service import get_billing_service
user_id = getattr(g, 'user_id', None)
if not user_id:
return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 20, type=int)
page_size = min(100, max(1, page_size))
result = get_billing_service().get_credits_log(user_id, page, page_size)
return jsonify({'code': 1, 'msg': 'success', 'data': result})
except Exception as e:
logger.error(f"get_my_credits_log failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/my-referrals', methods=['GET'])
@login_required
def get_my_referrals():
"""
Get list of users referred by current user.
Query params:
page: int (default 1)
page_size: int (default 20)
Returns:
list: Users referred by current user (id, username, nickname, avatar, created_at)
total: Total count of referrals
referral_code: Current user's referral code (user ID)
referral_bonus: Credits earned per referral
register_bonus: Credits new users get on registration
"""
try:
import os
from app.utils.db import get_db_connection
user_id = getattr(g, 'user_id', None)
if not user_id:
return jsonify({'code': 0, 'msg': 'Not authenticated', 'data': None}), 401
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 20, type=int)
page_size = min(100, max(1, page_size))
offset = (page - 1) * page_size
with get_db_connection() as db:
cur = db.cursor()
# Get total count
cur.execute(
"SELECT COUNT(*) as cnt FROM qd_users WHERE referred_by = ?",
(user_id,)
)
total = cur.fetchone()['cnt']
# Get referral list
cur.execute(
"""
SELECT id, username, nickname, avatar, created_at
FROM qd_users
WHERE referred_by = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""",
(user_id, page_size, offset)
)
rows = cur.fetchall()
cur.close()
referrals = []
for row in rows:
referrals.append({
'id': row['id'],
'username': row['username'],
'nickname': row['nickname'],
'avatar': row['avatar'],
'created_at': row['created_at'].isoformat() if row['created_at'] else None
})
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'list': referrals,
'total': total,
'page': page,
'page_size': page_size,
'referral_code': str(user_id),
'referral_bonus': int(os.getenv('CREDITS_REFERRAL_BONUS', '0')),
'register_bonus': int(os.getenv('CREDITS_REGISTER_BONUS', '0'))
}
})
except Exception as e:
logger.error(f"get_my_referrals failed: {e}")
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 500
@user_bp.route('/change-password', methods=['POST'])
@login_required
def change_password():
@@ -298,18 +548,56 @@ def change_password():
old_password = data.get('old_password', '')
new_password = data.get('new_password', '')
if not old_password or not new_password:
return jsonify({'code': 0, 'msg': 'Both old and new password required', 'data': None}), 400
if not new_password:
return jsonify({'code': 0, 'msg': 'New password required', 'data': None}), 400
if len(new_password) < 6:
return jsonify({'code': 0, 'msg': 'New password must be at least 6 characters', 'data': None}), 400
success = get_user_service().change_password(user_id, old_password, new_password)
# Check if user has a password set
user_service = get_user_service()
user = user_service.get_user_by_id(user_id)
if not user:
return jsonify({'code': 0, 'msg': 'User not found', 'data': None}), 404
if success:
return jsonify({'code': 1, 'msg': 'Password changed successfully', 'data': None})
# Get password_hash to check if user has no password
from app.utils.db import get_db_connection
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT password_hash FROM qd_users WHERE id = ?", (user_id,))
row = cur.fetchone()
cur.close()
password_hash = row.get('password_hash', '') if row else ''
has_password = password_hash and password_hash.strip() != ''
# If user has no password, allow setting password without old password
if not has_password:
if not old_password:
# No old password required for users without password
success = user_service.reset_password(user_id, new_password)
if success:
return jsonify({'code': 1, 'msg': 'Password set successfully', 'data': None})
else:
return jsonify({'code': 0, 'msg': 'Failed to set password', 'data': None}), 500
else:
# If old_password is provided but user has no password, ignore it
success = user_service.reset_password(user_id, new_password)
if success:
return jsonify({'code': 1, 'msg': 'Password set successfully', 'data': None})
else:
return jsonify({'code': 0, 'msg': 'Failed to set password', 'data': None}), 500
else:
return jsonify({'code': 0, 'msg': 'Old password incorrect', 'data': None}), 400
# User has existing password, require old password verification
if not old_password:
return jsonify({'code': 0, 'msg': 'Old password required', 'data': None}), 400
success = user_service.change_password(user_id, old_password, new_password)
if success:
return jsonify({'code': 1, 'msg': 'Password changed successfully', 'data': None})
else:
return jsonify({'code': 0, 'msg': 'Old password incorrect', 'data': None}), 400
except ValueError as e:
return jsonify({'code': 0, 'msg': str(e), 'data': None}), 400
except Exception as e: