aa
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 允许对后端服务器使用明文 HTTP(Android 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
125
saars/backend/app/admin_login_routes.py
Normal file
125
saars/backend/app/admin_login_routes.py
Normal 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"))
|
||||
@@ -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:
|
||||
|
||||
33
saars/backend/app/utils/ensure_admin.py
Normal file
33
saars/backend/app/utils/ensure_admin.py
Normal 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()
|
||||
@@ -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))
|
||||
|
||||
84
saars/backend/scripts/create_database.py
Normal file
84
saars/backend/scripts/create_database.py
Normal 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()
|
||||
33
saars/backend/scripts/create_tables.py
Normal file
33
saars/backend/scripts/create_tables.py
Normal 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()
|
||||
5
saars/backend/scripts/init_db.sql
Normal file
5
saars/backend/scripts/init_db.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- 在腾讯云 MySQL 上执行:先创建数据库,再在项目里运行 flask db upgrade
|
||||
-- 使用有建库权限的账号连接后执行:
|
||||
CREATE DATABASE IF NOT EXISTS liaotian_db
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
@@ -20,4 +20,4 @@ services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
- "16379:6379" # 主机 16379 避免与本地 Redis 冲突
|
||||
|
||||
Reference in New Issue
Block a user