feat: 登录验证 + 邀请码注册 + 用量监控后台

- 新增用户认证系统(登录/登出/邀请码注册)
- 新增管理后台(邀请码管理/用户管理/使用记录/用量统计)
- 合成接口加登录验证,每次调用记录用户和稿件内容
- SQLite存储用户数据和使用日志
- 默认admin账号: simonkoson

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
lanhao
2026-07-02 18:51:23 +08:00
parent 3b6019d0f2
commit 223761e717
10 changed files with 1304 additions and 27 deletions
+200
View File
@@ -0,0 +1,200 @@
from flask import Blueprint, request, jsonify
from app.db import get_db
from app.routes.auth import admin_required
from werkzeug.security import generate_password_hash
import secrets
import string
bp = Blueprint('admin', __name__, url_prefix='/api/admin')
def generate_invite_code():
"""生成8位邀请码(大写字母+数字,去掉易混淆字符 O/I/0/1)"""
alphabet = (
string.ascii_uppercase.replace('O', '').replace('I', '')
+ string.digits.replace('0', '').replace('1', '')
)
return ''.join(secrets.choice(alphabet) for _ in range(8))
# ========== 邀请码管理 ==========
@bp.route('/invites', methods=['GET'])
@admin_required
def list_invites():
conn = get_db()
invites = conn.execute('''
SELECT ic.*, u.username as used_by_username
FROM invite_codes ic
LEFT JOIN users u ON ic.used_by_user_id = u.id
ORDER BY ic.created_at DESC
''').fetchall()
conn.close()
return jsonify({'invites': [dict(row) for row in invites]})
@bp.route('/invites', methods=['POST'])
@admin_required
def create_invite():
data = request.get_json()
editor_name = data.get('editor_name', '').strip()
if not editor_name:
return jsonify({'error': '请填写编导姓名'}), 400
code = generate_invite_code()
conn = get_db()
try:
conn.execute(
'INSERT INTO invite_codes (code, editor_name) VALUES (?, ?)',
(code, editor_name)
)
conn.commit()
return jsonify({'success': True, 'code': code, 'editor_name': editor_name})
finally:
conn.close()
@bp.route('/invites/<int:invite_id>', methods=['DELETE'])
@admin_required
def delete_invite(invite_id):
conn = get_db()
try:
invite = conn.execute(
'SELECT * FROM invite_codes WHERE id = ? AND is_used = 0', (invite_id,)
).fetchone()
if not invite:
return jsonify({'error': '邀请码不存在或已被使用,无法删除'}), 400
conn.execute('DELETE FROM invite_codes WHERE id = ?', (invite_id,))
conn.commit()
return jsonify({'success': True})
finally:
conn.close()
# ========== 用户管理 ==========
@bp.route('/users', methods=['GET'])
@admin_required
def list_users():
conn = get_db()
users = conn.execute('''
SELECT u.id, u.username, u.display_name, u.role, u.is_active, u.created_at,
COUNT(ul.id) as usage_count,
COALESCE(SUM(ul.text_length), 0) as total_chars
FROM users u
LEFT JOIN usage_logs ul ON u.id = ul.user_id
GROUP BY u.id
ORDER BY u.created_at DESC
''').fetchall()
conn.close()
return jsonify({'users': [dict(row) for row in users]})
@bp.route('/users/<int:user_id>', methods=['PUT'])
@admin_required
def update_user(user_id):
data = request.get_json()
conn = get_db()
try:
user = conn.execute('SELECT * FROM users WHERE id = ?', (user_id,)).fetchone()
if not user:
return jsonify({'error': '用户不存在'}), 404
if 'is_active' in data:
conn.execute(
'UPDATE users SET is_active = ? WHERE id = ?',
(1 if data['is_active'] else 0, user_id)
)
if 'password' in data and data['password']:
conn.execute(
'UPDATE users SET password_hash = ? WHERE id = ?',
(generate_password_hash(data['password']), user_id)
)
if 'display_name' in data and data['display_name'].strip():
conn.execute(
'UPDATE users SET display_name = ? WHERE id = ?',
(data['display_name'].strip(), user_id)
)
conn.commit()
return jsonify({'success': True})
finally:
conn.close()
# ========== 用量日志 ==========
@bp.route('/logs', methods=['GET'])
@admin_required
def list_logs():
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
user_id = request.args.get('user_id', type=int)
date_from = request.args.get('date_from', '')
date_to = request.args.get('date_to', '')
conn = get_db()
where_clauses = []
params = []
if user_id:
where_clauses.append('user_id = ?')
params.append(user_id)
if date_from:
where_clauses.append('created_at >= ?')
params.append(date_from)
if date_to:
where_clauses.append('created_at <= ?')
params.append(date_to + ' 23:59:59')
where_sql = (' WHERE ' + ' AND '.join(where_clauses)) if where_clauses else ''
total = conn.execute(
f'SELECT COUNT(*) FROM usage_logs{where_sql}', params
).fetchone()[0]
offset = (page - 1) * per_page
logs = conn.execute(
f'SELECT * FROM usage_logs{where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?',
params + [per_page, offset]
).fetchall()
conn.close()
return jsonify({
'logs': [dict(row) for row in logs],
'total': total,
'page': page,
'per_page': per_page,
'total_pages': (total + per_page - 1) // per_page
})
@bp.route('/stats', methods=['GET'])
@admin_required
def stats():
conn = get_db()
user_stats = conn.execute('''
SELECT username, display_name,
COUNT(*) as call_count,
SUM(text_length) as total_chars,
MAX(created_at) as last_used
FROM usage_logs
GROUP BY user_id
ORDER BY call_count DESC
''').fetchall()
totals = conn.execute('''
SELECT COUNT(*) as total_calls,
COALESCE(SUM(text_length), 0) as total_chars,
COUNT(DISTINCT user_id) as active_users
FROM usage_logs
''').fetchone()
conn.close()
return jsonify({
'user_stats': [dict(row) for row in user_stats],
'totals': dict(totals)
})
+150
View File
@@ -0,0 +1,150 @@
from flask import Blueprint, request, jsonify, session
from werkzeug.security import generate_password_hash, check_password_hash
from app.db import get_db
import functools
bp = Blueprint('auth', __name__, url_prefix='/api')
def login_required(f):
"""登录验证装饰器"""
@functools.wraps(f)
def wrapped(*args, **kwargs):
if 'user_id' not in session:
return jsonify({'error': '未登录,请先登录'}), 401
return f(*args, **kwargs)
return wrapped
def admin_required(f):
"""管理员验证装饰器"""
@functools.wraps(f)
def wrapped(*args, **kwargs):
if 'user_id' not in session:
return jsonify({'error': '未登录'}), 401
if session.get('role') != 'admin':
return jsonify({'error': '无权限,仅管理员可操作'}), 403
return f(*args, **kwargs)
return wrapped
@bp.route('/login', methods=['POST'])
def login():
data = request.get_json()
username = data.get('username', '').strip()
password = data.get('password', '')
if not username or not password:
return jsonify({'error': '用户名和密码不能为空'}), 400
conn = get_db()
user = conn.execute(
'SELECT * FROM users WHERE username = ?', (username,)
).fetchone()
conn.close()
if not user or not check_password_hash(user['password_hash'], password):
return jsonify({'error': '用户名或密码错误'}), 401
if not user['is_active']:
return jsonify({'error': '账号已被停用,请联系管理员'}), 403
session.clear()
session['user_id'] = user['id']
session['username'] = user['username']
session['display_name'] = user['display_name']
session['role'] = user['role']
session.permanent = True
return jsonify({
'success': True,
'user': {
'id': user['id'],
'username': user['username'],
'display_name': user['display_name'],
'role': user['role']
}
})
@bp.route('/logout', methods=['POST'])
def logout():
session.clear()
return jsonify({'success': True})
@bp.route('/me', methods=['GET'])
@login_required
def me():
return jsonify({
'user': {
'id': session['user_id'],
'username': session['username'],
'display_name': session['display_name'],
'role': session['role']
}
})
@bp.route('/register', methods=['POST'])
def register():
"""邀请码注册"""
data = request.get_json()
invite_code = data.get('invite_code', '').strip()
username = data.get('username', '').strip()
password = data.get('password', '')
display_name = data.get('display_name', '').strip()
if not invite_code or not username or not password:
return jsonify({'error': '邀请码、用户名、密码均不能为空'}), 400
if len(username) < 2 or len(username) > 20:
return jsonify({'error': '用户名长度2-20个字符'}), 400
if len(password) < 6:
return jsonify({'error': '密码至少6位'}), 400
conn = get_db()
try:
# 验证邀请码
invite = conn.execute(
'SELECT * FROM invite_codes WHERE code = ? AND is_used = 0',
(invite_code,)
).fetchone()
if not invite:
return jsonify({'error': '邀请码无效或已被使用'}), 400
# 检查用户名是否已存在
existing = conn.execute(
'SELECT id FROM users WHERE username = ?', (username,)
).fetchone()
if existing:
return jsonify({'error': '用户名已存在,请换一个'}), 400
# 如果用户没填显示名,用邀请码绑定的编导姓名
if not display_name:
display_name = invite['editor_name']
# 创建用户
cursor = conn.execute(
'INSERT INTO users (username, display_name, password_hash, role) VALUES (?, ?, ?, ?)',
(username, display_name, generate_password_hash(password), 'editor')
)
user_id = cursor.lastrowid
# 标记邀请码已使用
conn.execute(
'UPDATE invite_codes SET is_used = 1, used_by_user_id = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?',
(user_id, invite['id'])
)
conn.commit()
return jsonify({
'success': True,
'message': f'注册成功!欢迎 {display_name}'
})
except Exception as e:
conn.rollback()
return jsonify({'error': f'注册失败: {str(e)}'}), 500
finally:
conn.close()
+31 -24
View File
@@ -1,56 +1,63 @@
from flask import Blueprint, request, jsonify
from flask import Blueprint, request, jsonify, session
import base64
import os
import requests
from app.services.minimax_service import synthesize_minimax
from app.services.cosyvoice_service import synthesize_cosyvoice
from app.routes.auth import login_required
from app.db import get_db
bp = Blueprint('tts', __name__, url_prefix='/api')
MINIMAX_API_KEY = os.getenv('MINIMAX_API_KEY', '')
MINIMAX_API_URL = 'https://api.minimax.chat/v1/t2a_v2'
COSYVOICE_API_URL = os.getenv('COSYVOICE_API_URL', 'http://127.0.0.1:5001/v1/audio/synthesis')
@bp.route('/synthesize', methods=['POST'])
@login_required
def synthesize():
data = request.get_json()
if not data or 'text' not in data:
return jsonify({'error': '缺少text参数'}), 400
text = data.get('text')
engine = data.get('engine', 'minimax')
speed = data.get('speed', 1.0)
pitch = data.get('pitch', 0)
volume = data.get('volume', 1.0)
instruction = data.get('instruction') # 用于CosyVoice的情绪指令
emotion = data.get('emotion', 'neutral') # 用于MiniMax的情绪参数
instruction = data.get('instruction')
emotion = data.get('emotion', 'neutral')
if not text.strip():
return jsonify({'error': '文本不能为空'}), 400
if len(text) > 2000:
return jsonify({'error': '文本不能超过2000字'}), 400
try:
if engine == 'cosyvoice':
audio_data = synthesize_cosyvoice(
text=text,
speed=speed,
pitch=pitch,
volume=volume,
instruction=instruction
text=text, speed=speed, pitch=pitch,
volume=volume, instruction=instruction
)
else:
audio_data = synthesize_minimax(
text=text,
speed=speed,
pitch=pitch,
volume=volume,
emotion=emotion
text=text, speed=speed, pitch=pitch,
volume=volume, emotion=emotion
)
# 记录使用日志
try:
conn = get_db()
conn.execute(
'''INSERT INTO usage_logs
(user_id, username, display_name, text_content, text_length, engine, emotion)
VALUES (?, ?, ?, ?, ?, ?, ?)''',
(session['user_id'], session['username'], session['display_name'],
text, len(text), engine, emotion)
)
conn.commit()
conn.close()
except Exception:
pass # 日志写入失败不影响正常合成
return jsonify({
'success': True,
'audio': base64.b64encode(audio_data).decode('utf-8')