feat: 登录验证 + 邀请码注册 + 用量监控后台
- 新增用户认证系统(登录/登出/邀请码注册) - 新增管理后台(邀请码管理/用户管理/使用记录/用量统计) - 合成接口加登录验证,每次调用记录用户和稿件内容 - SQLite存储用户数据和使用日志 - 默认admin账号: simonkoson Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import sqlite3
|
||||
import os
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
DB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'data')
|
||||
DB_PATH = os.path.join(DB_DIR, 'voice.db')
|
||||
|
||||
|
||||
def get_db():
|
||||
"""获取数据库连接"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
"""初始化数据库表 + 创建默认 admin 账号"""
|
||||
os.makedirs(DB_DIR, exist_ok=True)
|
||||
conn = get_db()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.executescript('''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT DEFAULT 'editor',
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invite_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
editor_name TEXT NOT NULL,
|
||||
is_used INTEGER DEFAULT 0,
|
||||
used_by_user_id INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
used_at DATETIME
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
text_content TEXT NOT NULL,
|
||||
text_length INTEGER NOT NULL,
|
||||
engine TEXT NOT NULL,
|
||||
emotion TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
''')
|
||||
|
||||
# 创建默认 admin 账号(如不存在)
|
||||
existing = cursor.execute(
|
||||
'SELECT id FROM users WHERE username = ?', ('simonkoson',)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
cursor.execute(
|
||||
'INSERT INTO users (username, display_name, password_hash, role) VALUES (?, ?, ?, ?)',
|
||||
('simonkoson', '蓝皓', generate_password_hash('liutong65'), 'admin')
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
+28
-2
@@ -1,14 +1,40 @@
|
||||
from flask import Flask, jsonify
|
||||
from flask_cors import CORS
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
CORS(app, resources={r"/api/*": {"origins": "*"}})
|
||||
|
||||
# Session 配置
|
||||
app.secret_key = os.getenv('FLASK_SECRET_KEY', 'military-tech-voice-secret-2026')
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = 86400 * 7 # 7天免重登
|
||||
|
||||
CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True)
|
||||
|
||||
# 初始化数据库
|
||||
from app.db import init_db
|
||||
init_db()
|
||||
|
||||
# 注册蓝图
|
||||
from app.routes.tts_synthesize import bp as synthesize_bp
|
||||
from app.routes.auth import bp as auth_bp
|
||||
from app.routes.admin import bp as admin_bp
|
||||
app.register_blueprint(synthesize_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
@app.route('/api/health')
|
||||
def health(): return jsonify({'status': 'ok', 'service': '军事科技AI配音系统'})
|
||||
def health():
|
||||
return jsonify({'status': 'ok', 'service': '军事科技AI配音系统'})
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
create_app().run(host='0.0.0.0', port=5000, debug=True)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user