356 lines
11 KiB
Python
356 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Happy Games 服务器(Flask + 登录认证 + WebSocket)
|
||
"""
|
||
from flask import Flask, send_from_directory, jsonify, request
|
||
from flask_cors import CORS
|
||
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
|
||
from flask_sqlalchemy import SQLAlchemy
|
||
from flask_socketio import SocketIO, emit
|
||
from werkzeug.security import generate_password_hash, check_password_hash
|
||
from datetime import timedelta
|
||
from functools import wraps
|
||
from jose import jwt as jose_jwt, JWTError as JoseJWTError
|
||
import os
|
||
import json
|
||
|
||
# ── SSO Cookie 验签(与 SSO 中心共享 SECRET_KEY)──
|
||
SSO_SECRET_KEY = "your-secret-key-change-in-production" # 必须与 usercenter/sso_config.py 一致
|
||
SSO_ALGORITHM = "HS256"
|
||
|
||
def verify_sso_cookie(req):
|
||
"""从请求 Cookie 中读取 SSO access_token,本地验签后返回 username"""
|
||
token = req.cookies.get("access_token")
|
||
if not token:
|
||
return None
|
||
try:
|
||
payload = jose_jwt.decode(token, SSO_SECRET_KEY, algorithms=[SSO_ALGORITHM])
|
||
return payload.get("sub")
|
||
except JoseJWTError:
|
||
return None
|
||
|
||
app = Flask(__name__)
|
||
app.config['SECRET_KEY'] = 'happy-games-secret-key'
|
||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///games_auth.db'
|
||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||
|
||
# Session cookie 配置(支持跨域)
|
||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # HTTP环境下用Lax,HTTPS才用None
|
||
app.config['SESSION_COOKIE_SECURE'] = False
|
||
|
||
# 初始化扩展
|
||
db = SQLAlchemy(app)
|
||
CORS(app, supports_credentials=True, origins='*')
|
||
|
||
# Flask-Login
|
||
login_manager = LoginManager()
|
||
login_manager.init_app(app)
|
||
|
||
# Flask-SocketIO (支持 WebSocket)
|
||
socketio = SocketIO(app, cors_allowed_origins='*', async_mode='threading')
|
||
|
||
# ========== 用户模型 ==========
|
||
|
||
class User(db.Model):
|
||
"""用户模型 - 每个游戏独立积分"""
|
||
__tablename__ = 'users'
|
||
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
username = db.Column(db.String(80), unique=True, nullable=False)
|
||
password_hash = db.Column(db.String(200), nullable=False)
|
||
created_at = db.Column(db.DateTime, default=db.func.current_timestamp())
|
||
|
||
# 各游戏独立积分 (JSON 格式: {"doudizhu": 100, "tetris": 50, ...})
|
||
game_scores = db.Column(db.Text, default='{}')
|
||
|
||
def set_password(self, password):
|
||
self.password_hash = generate_password_hash(password)
|
||
|
||
def check_password(self, password):
|
||
return check_password_hash(self.password_hash, password)
|
||
|
||
def get_game_score(self, game_id):
|
||
"""获取某个游戏的积分"""
|
||
try:
|
||
scores = json.loads(self.game_scores or '{}')
|
||
return scores.get(game_id, 0)
|
||
except:
|
||
return 0
|
||
|
||
def add_game_score(self, game_id, points):
|
||
"""增加某个游戏的积分"""
|
||
try:
|
||
scores = json.loads(self.game_scores or '{}')
|
||
scores[game_id] = scores.get(game_id, 0) + points
|
||
self.game_scores = json.dumps(scores)
|
||
return scores[game_id]
|
||
except:
|
||
return 0
|
||
|
||
def to_dict(self):
|
||
return {
|
||
'id': self.id,
|
||
'username': self.username,
|
||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||
}
|
||
|
||
# Flask-Login 要求的方法
|
||
def is_authenticated(self):
|
||
return True
|
||
|
||
def is_active(self):
|
||
return True
|
||
|
||
def is_anonymous(self):
|
||
return False
|
||
|
||
def get_id(self):
|
||
return str(self.id)
|
||
|
||
|
||
@login_manager.user_loader
|
||
def load_user(user_id):
|
||
return User.query.get(int(user_id))
|
||
|
||
|
||
def init_default_users():
|
||
"""初始化默认用户"""
|
||
default_users = [
|
||
{'username': 'admin', 'password': 'abcd1234'},
|
||
{'username': 'caixianming', 'password': 'cxm123456'}
|
||
]
|
||
|
||
for user_data in default_users:
|
||
existing = User.query.filter_by(username=user_data['username']).first()
|
||
if not existing:
|
||
user = User(username=user_data['username'])
|
||
user.set_password(user_data['password'])
|
||
db.session.add(user)
|
||
print(f"✅ 创建默认用户: {user_data['username']}")
|
||
|
||
db.session.commit()
|
||
|
||
|
||
# ========== API 登录检查装饰器 ==========
|
||
|
||
def api_login_required(f):
|
||
"""API 登录检查 - 返回 JSON 而不是重定向"""
|
||
@wraps(f)
|
||
def decorated_function(*args, **kwargs):
|
||
if not current_user.is_authenticated:
|
||
return jsonify({
|
||
'error': 'Unauthorized',
|
||
'message': '请先登录',
|
||
'code': 401
|
||
}), 401
|
||
return f(*args, **kwargs)
|
||
return decorated_function
|
||
|
||
|
||
# ========== 认证 API ==========
|
||
|
||
@app.route('/api/auth/status')
|
||
def auth_status():
|
||
"""检查登录状态 - 支持 SSO Cookie 自动登录"""
|
||
if current_user.is_authenticated:
|
||
return jsonify({
|
||
'authenticated': True,
|
||
'user': current_user.to_dict()
|
||
})
|
||
|
||
# 未登录时,检查 SSO Cookie
|
||
sso_username = verify_sso_cookie(request)
|
||
if sso_username:
|
||
user = User.query.filter_by(username=sso_username).first()
|
||
if user:
|
||
login_user(user, remember=True, duration=timedelta(days=30))
|
||
return jsonify({
|
||
'authenticated': True,
|
||
'user': user.to_dict()
|
||
})
|
||
|
||
return jsonify({'authenticated': False, 'user': None})
|
||
|
||
|
||
@app.route('/api/auth/login', methods=['POST'])
|
||
def login():
|
||
"""用户登录"""
|
||
data = request.get_json()
|
||
|
||
if not data:
|
||
return jsonify({'error': '请求数据为空'}), 400
|
||
|
||
username = data.get('username', '').strip()
|
||
password = data.get('password', '').strip()
|
||
|
||
if not username or not password:
|
||
return jsonify({'error': '用户名和密码不能为空'}), 400
|
||
|
||
user = User.query.filter_by(username=username).first()
|
||
|
||
if not user or not user.check_password(password):
|
||
return jsonify({'error': '用户名或密码错误'}), 401
|
||
|
||
login_user(user, remember=True, duration=timedelta(days=30))
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'message': '登录成功',
|
||
'user': user.to_dict()
|
||
})
|
||
|
||
|
||
@app.route('/api/auth/logout', methods=['POST'])
|
||
@login_required
|
||
def logout():
|
||
"""用户登出"""
|
||
logout_user()
|
||
resp = jsonify({'success': True, 'message': '已登出'})
|
||
# 同时清除 SSO Cookie
|
||
resp.set_cookie('access_token', '', expires=0, domain='129.211.173.87', path='/')
|
||
return resp
|
||
|
||
|
||
@app.route('/api/user/score', methods=['POST'])
|
||
@api_login_required
|
||
def update_score():
|
||
"""更新游戏积分(游戏结束后调用)"""
|
||
data = request.get_json()
|
||
if not data:
|
||
return jsonify({'error': '请求数据为空'}), 400
|
||
|
||
game_id = data.get('game') # 游戏ID,如 'doudizhu'
|
||
points = data.get('points', 0) # 增加的积分
|
||
|
||
if not game_id:
|
||
return jsonify({'error': '缺少游戏ID'}), 400
|
||
|
||
user = User.query.get(current_user.id)
|
||
if user:
|
||
new_score = user.add_game_score(game_id, points)
|
||
db.session.commit()
|
||
|
||
return jsonify({
|
||
'success': True,
|
||
'game': game_id,
|
||
'score': new_score,
|
||
'added': points
|
||
})
|
||
|
||
return jsonify({'error': '用户不存在'}), 404
|
||
|
||
|
||
@app.route('/api/user/score/<game_id>')
|
||
@api_login_required
|
||
def get_game_score(game_id):
|
||
"""获取某个游戏的积分"""
|
||
user = User.query.get(current_user.id)
|
||
if user:
|
||
score = user.get_game_score(game_id)
|
||
return jsonify({
|
||
'game': game_id,
|
||
'score': score
|
||
})
|
||
return jsonify({'error': '用户不存在'}), 404
|
||
|
||
|
||
# ========== 前端路由 ==========
|
||
|
||
@app.route('/')
|
||
def index():
|
||
"""首页"""
|
||
return send_from_directory('.', 'index.html')
|
||
|
||
|
||
# 中国象棋 - 静态文件托管(无需Next.js服务器,无WebSocket问题)
|
||
CHINESE_CHESS_DIR = os.path.join('games', 'chinese-chess', 'dist')
|
||
|
||
@app.route('/games/chinese-chess/')
|
||
def serve_chinese_chess():
|
||
"""中国象棋首页 - 静态文件"""
|
||
index_path = os.path.join(CHINESE_CHESS_DIR, 'index.html')
|
||
if os.path.exists(index_path):
|
||
return send_from_directory(CHINESE_CHESS_DIR, 'index.html')
|
||
return "<h1>中国象棋服务暂时不可用</h1><p>请稍后刷新重试</p>", 503
|
||
|
||
@app.route('/games/chinese-chess/<path:subpath>')
|
||
def serve_chinese_chess_assets(subpath):
|
||
"""中国象棋静态资源"""
|
||
file_path = os.path.join(CHINESE_CHESS_DIR, subpath)
|
||
if os.path.exists(file_path) and os.path.isfile(file_path):
|
||
# 根据文件类型设置Content-Type
|
||
if subpath.endswith('.js'):
|
||
mimetype = 'application/javascript'
|
||
elif subpath.endswith('.css'):
|
||
mimetype = 'text/css'
|
||
elif subpath.endswith('.svg'):
|
||
mimetype = 'image/svg+xml'
|
||
elif subpath.endswith('.png'):
|
||
mimetype = 'image/png'
|
||
elif subpath.endswith('.jpg') or subpath.endswith('.jpeg'):
|
||
mimetype = 'image/jpeg'
|
||
else:
|
||
mimetype = None
|
||
return send_from_directory(CHINESE_CHESS_DIR, subpath, mimetype=mimetype)
|
||
return jsonify({'error': '资源不存在'}), 404
|
||
|
||
@app.route('/games/<path:path>')
|
||
def serve_games(path):
|
||
"""游戏文件 - 支持目录和默认index.html"""
|
||
# 如果是目录,返回index.html
|
||
if not path.endswith(('.html', '.js', '.css', '.png', '.jpg', '.json')):
|
||
full_path = os.path.join('games', path, 'index.html')
|
||
if os.path.exists(full_path):
|
||
return send_from_directory(os.path.join('games', path), 'index.html')
|
||
return send_from_directory('games', path)
|
||
|
||
|
||
@app.route('/<path:path>')
|
||
def static_files(path):
|
||
"""其他静态文件"""
|
||
# 排除 API 路径
|
||
if path.startswith('api/'):
|
||
return jsonify({'error': 'Not found'}), 404
|
||
return send_from_directory('.', path)
|
||
|
||
|
||
# ========== 初始化 ==========
|
||
|
||
def init_db():
|
||
"""初始化数据库"""
|
||
with app.app_context():
|
||
db.create_all()
|
||
init_default_users()
|
||
print("✅ 数据库初始化完成")
|
||
|
||
|
||
# ========== WebSocket 事件处理 ==========
|
||
|
||
@socketio.on('connect')
|
||
def handle_connect():
|
||
"""客户端连接"""
|
||
print(f'[WebSocket] Client connected: {request.sid}')
|
||
emit('connected', {'message': 'Connected to Happy Games server'})
|
||
|
||
@socketio.on('disconnect')
|
||
def handle_disconnect():
|
||
"""客户端断开"""
|
||
print(f'[WebSocket] Client disconnected: {request.sid}')
|
||
|
||
@socketio.on('ping')
|
||
def handle_ping(data):
|
||
"""心跳测试"""
|
||
emit('pong', {'time': data.get('time'), 'server': 'happy-games'})
|
||
|
||
|
||
if __name__ == '__main__':
|
||
init_db()
|
||
|
||
print("🎮 Happy Games 服务器(Flask + WebSocket)启动...")
|
||
print(f" 访问: http://localhost:8895")
|
||
print(f" WebSocket: ws://localhost:8895")
|
||
print(f" 默认账号: admin / abcd1234")
|
||
|
||
socketio.run(app, host='0.0.0.0', port=8895, debug=False, allow_unsafe_werkzeug=True)
|