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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user