diff --git a/saars/README.md b/saars/README.md index 6cf3dff..71ce307 100644 --- a/saars/README.md +++ b/saars/README.md @@ -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 ``` diff --git a/saars/android-app/app/src/main/AndroidManifest.xml b/saars/android-app/app/src/main/AndroidManifest.xml index 5a2c6ee..0a3f8f3 100644 --- a/saars/android-app/app/src/main/AndroidManifest.xml +++ b/saars/android-app/app/src/main/AndroidManifest.xml @@ -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"> diff --git a/saars/android-app/app/src/main/res/xml/network_security_config.xml b/saars/android-app/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..8552e75 --- /dev/null +++ b/saars/android-app/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + 101.43.95.130 + localhost + 10.0.2.2 + 127.0.0.1 + + diff --git a/saars/backend/app/__init__.py b/saars/backend/app/__init__.py index bbb4106..663c785 100644 --- a/saars/backend/app/__init__.py +++ b/saars/backend/app/__init__.py @@ -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 diff --git a/saars/backend/app/admin/__init__.py b/saars/backend/app/admin/__init__.py index ebf3602..22a1377 100644 --- a/saars/backend/app/admin/__init__.py +++ b/saars/backend/app/admin/__init__.py @@ -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) diff --git a/saars/backend/app/admin_login_routes.py b/saars/backend/app/admin_login_routes.py new file mode 100644 index 0000000..e6e004a --- /dev/null +++ b/saars/backend/app/admin_login_routes.py @@ -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 = """ + + + + + + 管理后台登录 - Chat Platform Admin + + + +
+

Chat Platform Admin

+
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + +""" + + +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( + "", + '

请填写用户名和密码

', + ) + 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( + "", + '

用户名或密码错误

', + ) + return Response(html, mimetype="text/html; charset=utf-8") + if not user.is_active: + html = LOGIN_HTML.replace( + "", + '

账号已停用

', + ) + 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( + "", + '

无管理员权限

', + ) + 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( + "", + f'

{err_msg}

', + ) + 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")) diff --git a/saars/backend/app/api/auth.py b/saars/backend/app/api/auth.py index 4b45645..20cdb9c 100644 --- a/saars/backend/app/api/auth.py +++ b/saars/backend/app/api/auth.py @@ -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: diff --git a/saars/backend/app/utils/ensure_admin.py b/saars/backend/app/utils/ensure_admin.py new file mode 100644 index 0000000..b0f52cf --- /dev/null +++ b/saars/backend/app/utils/ensure_admin.py @@ -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() diff --git a/saars/backend/run.py b/saars/backend/run.py index a28babc..40113ea 100644 --- a/saars/backend/run.py +++ b/saars/backend/run.py @@ -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)) diff --git a/saars/backend/scripts/create_database.py b/saars/backend/scripts/create_database.py new file mode 100644 index 0000000..b1e0793 --- /dev/null +++ b/saars/backend/scripts/create_database.py @@ -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() diff --git a/saars/backend/scripts/create_tables.py b/saars/backend/scripts/create_tables.py new file mode 100644 index 0000000..4c6869d --- /dev/null +++ b/saars/backend/scripts/create_tables.py @@ -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() diff --git a/saars/backend/scripts/init_db.sql b/saars/backend/scripts/init_db.sql new file mode 100644 index 0000000..ef13f51 --- /dev/null +++ b/saars/backend/scripts/init_db.sql @@ -0,0 +1,5 @@ +-- 在腾讯云 MySQL 上执行:先创建数据库,再在项目里运行 flask db upgrade +-- 使用有建库权限的账号连接后执行: +CREATE DATABASE IF NOT EXISTS liaotian_db + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; diff --git a/saars/docker-compose.yml b/saars/docker-compose.yml index b7fef03..c36e85c 100644 --- a/saars/docker-compose.yml +++ b/saars/docker-compose.yml @@ -20,4 +20,4 @@ services: redis: image: redis:7-alpine ports: - - "6379:6379" + - "16379:6379" # 主机 16379 避免与本地 Redis 冲突