This commit is contained in:
rjb
2026-03-07 10:29:17 +08:00
parent f9df05470a
commit a789321005
13 changed files with 386 additions and 38 deletions

View File

@@ -43,7 +43,8 @@ pip install -r requirements.txt
export FLASK_ENV=development
export DATABASE_URL=mysql+pymysql://root:!Rjb12191@gz-cynosdbmysql-grp-d26pzce5.sql.tencentcdb.com:24936/liaotian_db?charset=utf8mb4
export REDIS_URL=redis://localhost:6379/0
flask db upgrade # 需先创建数据库并执行迁移
# 首次部署:在腾讯云 MySQL 上先建库(见 backend/scripts/init_db.sql再执行
flask db upgrade
python run.py # 或 gunicorn -k eventlet -w 1 run:app
```

View File

@@ -8,6 +8,7 @@
android:icon="@android:drawable/ic_menu_send"
android:roundIcon="@android:drawable/ic_menu_send"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.ChatPlatform">
<activity android:name=".presentation.login.LoginActivity" android:exported="true">
<intent-filter>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 允许对后端服务器使用明文 HTTPAndroid 9+ 默认禁止) -->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">101.43.95.130</domain>
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>

View File

@@ -23,28 +23,44 @@ limiter = Limiter(key_func=get_remote_address, default_limits=["200 per day", "5
def create_app(config_name=None):
config_name = config_name or os.getenv("FLASK_ENV", "development")
app = Flask(__name__)
app.config.from_object(config_by_name[config_name])
flask_app = Flask(__name__)
flask_app.config.from_object(config_by_name[config_name])
CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True)
db.init_app(app)
migrate.init_app(app, db)
jwt.init_app(app)
limiter.init_app(app)
socketio.init_app(app, message_queue=app.config.get("CELERY_BROKER_URL") or None)
CORS(flask_app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True)
db.init_app(flask_app)
migrate.init_app(flask_app, db)
jwt.init_app(flask_app)
limiter.init_app(flask_app)
socketio.init_app(flask_app, message_queue=flask_app.config.get("CELERY_BROKER_URL") or None)
app.redis = Redis.from_url(app.config["REDIS_URL"]) if app.config.get("REDIS_URL") else None
flask_app.redis = Redis.from_url(flask_app.config["REDIS_URL"]) if flask_app.config.get("REDIS_URL") else None
from app.api import register_blueprints
register_blueprints(app)
register_blueprints(flask_app)
from app.admin_login_routes import register_admin_login_routes
register_admin_login_routes(flask_app) # /admin-login、/admin-logout
import app.socket_events # noqa: F401 - register SocketIO handlers
from app.admin import init_admin
init_admin(app)
with flask_app.app_context():
try:
from app.utils.ensure_admin import ensure_admin_user
ensure_admin_user() # 默认管理员 admin / 123456
except Exception as e:
import warnings
warnings.warn(f"ensure_admin_user skipped: {e}", UserWarning)
@app.route("/health")
# Flask-Admin 在 create_app 内初始化会因包名 app 冲突失败,改在 run.py 中初始化
# try:
# from app.admin import init_admin
# init_admin(flask_app)
# except Exception as e:
# import warnings
# warnings.warn(f"Flask-Admin init skipped: {e}", UserWarning)
@flask_app.route("/health")
def health():
return {"status": "healthy"}, 200
return app
return flask_app

View File

@@ -1,6 +1,7 @@
"""Flask-Admin: RBAC, user management, chat monitoring."""
import jwt
from flask_admin import Admin
from flask_admin import Admin, AdminIndexView
from flask_admin.theme import Bootstrap4Theme
from flask_admin.contrib.sqla import ModelView
from flask import redirect, url_for, request, current_app
from app import db
@@ -8,30 +9,55 @@ from app.models.user import User, Role
from app.models.chat import Conversation, Message
class AdminModelView(ModelView):
def _admin_accessible():
"""统一:是否已用管理员 JWT 访问Cookie admin_token / Header Authorization / URL ?token="""
auth = (
request.cookies.get("admin_token")
or request.headers.get("Authorization")
or request.args.get("token")
)
if not auth:
return False
if not auth.startswith("Bearer "):
auth = "Bearer " + auth.strip()
try:
token = auth[7:]
payload = jwt.decode(token, current_app.config["SECRET_KEY"], algorithms=["HS256"])
uid = payload.get("sub")
user = User.query.get(uid)
if not user or not user.role_id:
return False
role = Role.query.get(user.role_id)
return role and role.name == "admin"
except Exception:
return False
class AdminIndexViewWithPrompt(AdminIndexView):
"""管理首页:未登录时显示说明,避免空白页"""
def is_accessible(self):
auth = request.headers.get("Authorization") or request.args.get("token")
if not auth or not auth.startswith("Bearer "):
return False
try:
token = auth[7:]
payload = jwt.decode(token, current_app.config["SECRET_KEY"], algorithms=["HS256"])
uid = payload.get("sub")
user = User.query.get(uid)
if not user or not user.role_id:
return False
role = Role.query.get(user.role_id)
return role and role.name == "admin"
except Exception:
return False
return _admin_accessible()
def inaccessible_callback(self, name, **kwargs):
return redirect(url_for("auth.login"))
return redirect(url_for("admin_login"))
def init_admin(app):
admin = Admin(app, name="Chat Platform Admin", template_mode="bootstrap4")
class AdminModelView(ModelView):
def is_accessible(self):
return _admin_accessible()
def inaccessible_callback(self, name, **kwargs):
return redirect(url_for("admin.index"))
def init_admin(flask_app):
admin = Admin(
name="Chat Platform Admin",
theme=Bootstrap4Theme(),
index_view=AdminIndexViewWithPrompt(name="Home"),
)
admin.add_view(AdminModelView(User, db.session, name="Users", category="Auth"))
admin.add_view(AdminModelView(Role, db.session, name="Roles", category="Auth"))
admin.add_view(AdminModelView(Conversation, db.session, name="Conversations", category="Chat"))
admin.add_view(AdminModelView(Message, db.session, name="Messages", category="Chat"))
admin.init_app(flask_app)

View File

@@ -0,0 +1,125 @@
"""
管理后台登录页:/admin-login表单登录Cookie 保持会话)
"""
from flask import request, redirect, url_for, make_response, Response, current_app
from flask_jwt_extended import create_access_token
from app.models.user import User, Role
from app.utils.auth import check_password
ADMIN_COOKIE_NAME = "admin_token"
ADMIN_COOKIE_MAX_AGE = 86400 * 7 # 7 天
LOGIN_HTML = """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>管理后台登录 - Chat Platform Admin</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; background: #f5f5f5; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.card { background: #fff; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.08); width: 100%; max-width: 360px; }
h1 { margin: 0 0 1.5rem 0; font-size: 1.25rem; color: #333; }
.field { margin-bottom: 1rem; }
label { display: block; margin-bottom: 0.35rem; font-size: 0.875rem; color: #555; }
input[type="text"], input[type="password"] { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; }
input:focus { outline: none; border-color: #333; }
.error { color: #c00; font-size: 0.875rem; margin-top: 0.5rem; }
button { width: 100%; padding: 0.6rem 1rem; margin-top: 0.5rem; background: #333; color: #fff; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; }
button:hover { background: #555; }
</style>
</head>
<body>
<div class="card">
<h1>Chat Platform Admin</h1>
<form method="post" action="/admin-login">
<div class="field">
<label for="username">用户名</label>
<input id="username" name="username" type="text" autocomplete="username" required placeholder="admin">
</div>
<div class="field">
<label for="password">密码</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<div class="field">
<button type="submit">登录</button>
</div>
<!-- error will be injected by server -->
</form>
</div>
</body>
</html>
"""
def register_admin_login_routes(flask_app):
"""注册 /admin-login 与 /admin-logout独立路径避免与 Flask-Admin /admin/* 冲突)。"""
@flask_app.route("/admin-login", methods=["GET", "POST"])
def admin_login():
if request.method == "GET":
return Response(LOGIN_HTML, mimetype="text/html; charset=utf-8")
try:
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
if not username or not password:
html = LOGIN_HTML.replace(
"</form>",
'<p class="error">请填写用户名和密码</p></form>',
)
return Response(html, mimetype="text/html; charset=utf-8")
user = User.query.filter_by(username=username).first()
if not user or not check_password(password, user.password_hash):
html = LOGIN_HTML.replace(
"</form>",
'<p class="error">用户名或密码错误</p></form>',
)
return Response(html, mimetype="text/html; charset=utf-8")
if not user.is_active:
html = LOGIN_HTML.replace(
"</form>",
'<p class="error">账号已停用</p></form>',
)
return Response(html, mimetype="text/html; charset=utf-8")
role = Role.query.get(user.role_id) if user.role_id else None
if not role or role.name != "admin":
html = LOGIN_HTML.replace(
"</form>",
'<p class="error">无管理员权限</p></form>',
)
return Response(html, mimetype="text/html; charset=utf-8")
token = create_access_token(identity=user.id)
# 直接重定向到 /admin/,避免 url_for 与 Flask-Admin 版本差异
resp = make_response(redirect("/admin/"))
resp.set_cookie(
ADMIN_COOKIE_NAME,
token,
max_age=ADMIN_COOKIE_MAX_AGE,
path="/admin",
httponly=True,
samesite="Lax",
secure=request.is_secure,
)
return resp
except Exception as e:
current_app.logger.exception("admin_login failed")
err_msg = str(e) if current_app.debug else "登录处理失败,请稍后重试"
html = LOGIN_HTML.replace(
"</form>",
f'<p class="error">{err_msg}</p></form>',
)
return Response(html, mimetype="text/html; charset=utf-8"), 500
@flask_app.route("/admin-logout", methods=["GET"])
def admin_logout():
resp = make_response(redirect(url_for("admin_login")))
resp.delete_cookie(ADMIN_COOKIE_NAME, path="/admin")
return resp
@flask_app.route("/admin/login", methods=["GET"])
def admin_login_legacy():
"""兼容旧链接:/admin/login -> /admin-login"""
return redirect(url_for("admin_login"))

View File

@@ -49,10 +49,16 @@ def register():
def login():
data = request.get_json() or {}
email = (data.get("email") or "").strip()
username = (data.get("username") or "").strip()
password = data.get("password")
if not email or not password:
return jsonify({"error": "email and password are required"}), 400
user = User.query.filter_by(email=email).first()
if not password:
return jsonify({"error": "password is required"}), 400
if email:
user = User.query.filter_by(email=email).first()
elif username:
user = User.query.filter_by(username=username).first()
else:
return jsonify({"error": "email or username is required"}), 400
if not user or not check_password(password, user.password_hash):
return jsonify({"error": "Invalid credentials"}), 401
if not user.is_active:

View File

@@ -0,0 +1,33 @@
"""
确保默认管理员账号存在username=admin, password=123456角色 admin。
"""
from app import db
from app.models.user import User, Role
from app.utils.auth import hash_password
def ensure_admin_user(admin_username="admin", admin_password="123456", admin_email=None):
"""若不存在则创建管理员角色及管理员用户。"""
if not admin_email:
admin_email = f"{admin_username}@localhost"
role = Role.query.filter_by(name="admin").first()
if not role:
role = Role(name="admin", description="Administrator")
db.session.add(role)
db.session.flush()
user = User.query.filter_by(username=admin_username).first()
if not user:
user = User(
username=admin_username,
email=admin_email,
password_hash=hash_password(admin_password),
role_id=role.id,
is_active=True,
)
db.session.add(user)
else:
if user.role_id != role.id:
user.role_id = role.id
if not user.is_active:
user.is_active = True
db.session.commit()

View File

@@ -5,6 +5,14 @@ import os
from app import create_app, socketio
app = create_app(os.getenv("FLASK_ENV", "development"))
# 避免 Flask-Admin 从包名 app 解析到错误对象,令其从本模块解析到当前 Flask 实例
app.__module__ = "run"
try:
from app.admin import init_admin
init_admin(app)
except Exception as e:
import warnings
warnings.warn(f"Flask-Admin init skipped: {e}", UserWarning)
if __name__ == "__main__":
port = int(os.getenv("PORT", 8052))

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""
创建项目所需 MySQL 数据库liaotian_db
从环境变量 DATABASE_URL 或下方默认值读取连接信息,连接后执行 CREATE DATABASE。
用法:
cd backend && python scripts/create_database.py
或指定: export DATABASE_URL='mysql+pymysql://user:pass@host:port/liaotian_db?charset=utf8mb4'
python scripts/create_database.py
"""
import os
import re
import sys
# 与 app/config.py 中默认一致,不导入 app 以免依赖 Flask 等
DEFAULT_DATABASE_URL = (
"mysql+pymysql://root:!Rjb12191@"
"gz-cynosdbmysql-grp-d26pzce5.sql.tencentcdb.com:24936/"
"liaotian_db?charset=utf8mb4"
)
def parse_database_url(url):
"""从 DATABASE_URL 解析 host, port, user, password, database。"""
# mysql+pymysql://user:password@host:port/database?query
m = re.match(
r"mysql\+pymysql://([^:]+):([^@]+)@([^/:]+):(\d+)/([^?]+)",
url.strip(),
)
if not m:
raise ValueError("DATABASE_URL 格式应为: mysql+pymysql://user:pass@host:port/dbname?charset=utf8mb4")
user, password, host, port, database = m.groups()
import urllib.parse
password = urllib.parse.unquote(password)
return {
"host": host,
"port": int(port),
"user": user,
"password": password,
"database": database.strip("/"),
}
def main():
url = os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL)
try:
params = parse_database_url(url)
except Exception as e:
print("解析 DATABASE_URL 失败:", e)
sys.exit(1)
db_name = params["database"]
print(f"连接 {params['host']}:{params['port']} (用户 {params['user']}),创建数据库: {db_name}")
try:
import pymysql
except ImportError:
print("请先安装: pip install pymysql")
sys.exit(1)
# 连接时不指定 database才能执行 CREATE DATABASE
conn = pymysql.connect(
host=params["host"],
port=params["port"],
user=params["user"],
password=params["password"],
charset="utf8mb4",
)
try:
with conn.cursor() as cur:
cur.execute(
"CREATE DATABASE IF NOT EXISTS `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
% db_name
)
conn.commit()
print(f"数据库 {db_name} 已存在或已创建成功。")
except Exception as e:
print("创建失败:", e)
sys.exit(1)
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""
用 SQLAlchemy 建表(不依赖 Flask-Migrate。适用于首次部署或 migrations 未就绪时。
用法: 在 backend 目录下设置 DATABASE_URL 后执行
python scripts/create_tables.py
或: docker compose run --rm -e DATABASE_URL=... backend python scripts/create_tables.py
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("FLASK_ENV", "development")
from app import create_app, db
from app.models.user import User, Role
from app.models.chat import Conversation, Message
from app.utils.ensure_admin import ensure_admin_user
def main():
app = create_app()
with app.app_context():
print("创建表...")
db.create_all()
print("表已创建。")
print("创建默认管理员 (admin / 123456)...")
ensure_admin_user()
print("完成。")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,5 @@
-- 在腾讯云 MySQL 上执行:先创建数据库,再在项目里运行 flask db upgrade
-- 使用有建库权限的账号连接后执行:
CREATE DATABASE IF NOT EXISTS liaotian_db
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;

View File

@@ -20,4 +20,4 @@ services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
- "16379:6379" # 主机 16379 避免与本地 Redis 冲突