Files
nanocode/happy-games/server.py
T
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

356 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)