diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..359bb5307e8535ab7d59faf27a7377033291821e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000000000000000000000000000000000000..146ab09b7c50daf1f04814fccb913b8d87302176 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000000000000000000000000000000000000..105ce2da2d6447d11dfe32bfb846c3d5b199fc99 --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000000000000000000000000000000000000..1886999ef7ea1885a6f654bf9c48fe4dd394e102 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000000000000000000000000000000000000..1922b8edc2d904598f0bc75933cf3f94b0b5f4cd --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/oj_object.iml b/.idea/oj_object.iml new file mode 100644 index 0000000000000000000000000000000000000000..b6731d869f8bd2801d14e7065fafab010853a308 --- /dev/null +++ b/.idea/oj_object.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/course_system.db b/course_system.db new file mode 100644 index 0000000000000000000000000000000000000000..976c5b5dfb6a64855e63682c3f6081cbd45c9ba0 Binary files /dev/null and b/course_system.db differ diff --git a/oj_object.py b/oj_object.py new file mode 100644 index 0000000000000000000000000000000000000000..3792bfbbceccb5250cac0a472b7d58d408dbe1d6 --- /dev/null +++ b/oj_object.py @@ -0,0 +1,548 @@ +from flask import Flask, request,jsonify +from flask_sqlalchemy import SQLAlchemy +from flask_cors import CORS +import os +import json + + +app = Flask(__name__) +CORS(app) + +# ====================== 环境变量配置 ====================== +# 安全认证配置 +API_TOKEN = os.environ.get('API_TOKEN', '993cb7df-6ff5-40c3-9a9c-773921e3a12d') +JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'MjM3NTg1NDM4ODQ0MTYxMTE3MzUxODIzMTA0ODY2NjQ=') + +# Redis缓存配置 +REDIS_HOST = os.environ.get('REDIS_HOST', 'hkg1.clusters.zeabur.com') +REDIS_PORT = os.environ.get('REDIS_PORT', '30123') +REDIS_DB = os.environ.get('REDIS_DB', '0') +REDIS_PASSWORD = os.environ.get('REDIS_PASSWORD', '2g8o1A45x9n3hsE0m7yrfL6VkbqJXaBW') + +# GitHub OAuth配置 +GITHUB_CLIENT_ID = os.environ.get('GITHUB_CLIENT_ID', '0v231iSEPDKQ0r51vqBC') +GITHUB_CLIENT_SECRET = os.environ.get('GITHUB_CLIENT_SECRET', '0ce1c2a21b94562fb1b57e64de3c93877d656e') + +# OIDC配置 +OIDC_PROVIDERS_JSON = os.environ.get('OIDC_PROVIDERS', + '[{"name": "ioSCLub","issuer": "https://api.xauat.site","client_id": "ca_0200be03d08f4f58","client_secret": "$2b$10$AnhmhDe4L027E.XQwHyWcOV0Pi/tBTymd4zMXt9i6L73vHLxkW1q"}]') +try: + OIDC_PROVIDERS = json.loads(OIDC_PROVIDERS_JSON) +except json.JSONDecodeError: + OIDC_PROVIDERS = [] +# PostgreSQL数据库配置 +DB_HOST = os.environ.get('DB_HOST', 'hkg1.clusters.zeabur.com') +DB_PORT = os.environ.get('DB_PORT', '32627') +DB_NAME = os.environ.get('DB_NAME', 'lettest') +DB_USER = os.environ.get('DB_USER', 'root') +DB_PASSWORD = os.environ.get('DB_PASSWORD', '60zquVE1c7TLInw0ivZg9H543Kr2GM8p') + + +# ====================== 应用配置 ====================== +# 配置PGSQL数据库连接 +DATABASE_URI = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" +app.config['SQLALCHEMY_DATABASE_URI'] = DATABASE_URI +# 关闭SQLAlchemy的修改跟踪功能,提升性能 +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +# 可选:开启PGSQL连接池,优化性能(适配多请求场景) +app.config['SQLALCHEMY_POOL_SIZE'] = 5 +app.config['SQLALCHEMY_MAX_OVERFLOW'] = 2 + +app.config['JWT_SECRET_KEY'] = JWT_SECRET_KEY +app.config['API_TOKEN'] = API_TOKEN + +app.config['REDIS_HOST'] = REDIS_HOST +app.config['REDIS_PORT'] = REDIS_PORT +app.config['REDIS_DB'] = REDIS_DB +app.config['REDIS_PASSWORD'] = REDIS_PASSWORD + +app.config['GITHUB_CLIENT_ID'] = GITHUB_CLIENT_ID +app.config['GITHUB_CLIENT_SECRET'] = GITHUB_CLIENT_SECRET + +app.config['OIDC_PROVIDERS'] = OIDC_PROVIDERS + +# 初始化SQLAlchemy实例,关联Flask应用,实现ORM功能 +db = SQLAlchemy(app) + +# ====================== 数据库模型定义 ====================== +# 定义课程表模型,继承db.Model(SQLAlchemy的基础模型类) +class Course(db.Model): + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + name = db.Column(db.String(50), unique=True, nullable=False) + # 课程与学员一对多 + students = db.relationship('Student', backref='course', lazy=True) + # 课程与题目一个对多 + questions = db.relationship('Question', backref='course', lazy=True) + + def to_dict(self): + students_list = self.students \ + if hasattr(self.students, '__len__') else list(self.students) + questions_list = self.questions \ + if hasattr(self.questions, '__len__') else list(self.questions) + return \ + { + 'id': self.id, + 'name': self.name, + 'student_count': len(students_list), + 'question_count': len(questions_list) + } + +# 定义学员表模型 +class Student(db.Model): + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + name = db.Column(db.String(30), nullable=False) + # 外键:关联课程表的id字段,建立学员与课程的关联 + course_id = db.Column(db.Integer, db.ForeignKey('course.id'), nullable=False) + + def to_dict(self): + course_name = None + if self.course: + course_name = self.course.name + return \ + { + 'id': self.id, + 'name': self.name, + 'course_id': self.course_id, + 'course_name': course_name + } + +# 定义题目表模型 +class Question(db.Model): + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + content = db.Column(db.Text, nullable=False) + # 外键:关联课程表的id字段,建立题目与课程的关联 + course_id = db.Column(db.Integer, db.ForeignKey('course.id'), nullable=False) + + def to_dict(self): + course_name = None + if self.course: + course_name = self.course.name + return \ + { + 'id': self.id, + 'content': self.content, + 'course_id': self.course_id, + 'course_name': course_name + } + +# ====================== 数据库初始化 ====================== +with app.app_context(): + db.create_all() +# ====================== 认证中间件 ====================== +def check_auth(): + """检查API认证""" + # 排除不需要认证的公共路由 + public_routes = ['health_check', 'config_info', 'query_course', 'query_student', 'query_question'] + + if request.endpoint in public_routes: + return None + + # 获取Authorization头部 + auth_header = request.headers.get('Authorization') + + if not auth_header: + return jsonify({'code': 401, 'msg': '缺少认证令牌'}), 401 + + # 检查Bearer令牌格式 + if not auth_header.startswith('Bearer '): + return jsonify({'code': 401, 'msg': '令牌格式错误,应为Bearer令牌'}), 401 + + token = auth_header.split(' ')[1] + + # 验证令牌 + if token != API_TOKEN: + return jsonify({'code': 401, 'msg': '无效的认证令牌'}), 401 + return None + +@app.before_request +def before_request(): + """在每个请求之前执行""" + # 检查认证 + auth_result = check_auth() + if auth_result: + return auth_result + + +# ====================== 系统信息接口 ====================== +@app.route('/health', methods=['GET']) +def health_check(): + """健康检查接口""" + try: + # 尝试连接数据库 + db.session.execute('SELECT 1') + db_status = 'connected' + except Exception as e: + db_status = f'error: {str(e)}' + + return jsonify({ + 'code': 200, + 'msg': '系统运行正常', + 'data': { + 'service': 'Online Judge System API', + 'database': db_status, + 'environment': 'production' if not app.debug else 'development' + } + }), 200 + + +@app.route('/config/info', methods=['GET']) +def config_info(): + """获取配置信息(安全信息已隐藏)""" + return jsonify({ + 'code': 200, + 'msg': '配置信息获取成功', + 'data': { + 'database': { + 'host': DB_HOST, + 'port': DB_PORT, + 'name': DB_NAME, + 'user': DB_USER, + 'connected': True if 'lettest' in DATABASE_URI else False + }, + 'redis': { + 'host': REDIS_HOST, + 'port': REDIS_PORT, + 'enabled': True + }, + 'oauth': { + 'github_enabled': bool(GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET), + 'oidc_providers': [provider['name'] for provider in OIDC_PROVIDERS] + }, + 'security': { + 'api_auth_enabled': True, + 'jwt_enabled': bool(JWT_SECRET_KEY) + } + } + }), 200 + + +# ====================== 课程相关接口 ====================== +@app.route('/course/add', methods=['POST']) +def add_course(): + """新增课程""" + data = request.get_json() or {} + course_name = data.get('name') + + if not course_name: + return jsonify({'code': 400, 'msg': '缺少课程名称参数'}), 400 + + existing_course = Course.query.filter_by(name=course_name).first() + if existing_course: + return jsonify({'code': 400, 'msg': '课程已存在'}), 400 + + new_course = Course(name=course_name) + db.session.add(new_course) + + try: + db.session.commit() + return jsonify({ + 'code': 200, + 'msg': '课程添加成功', + 'data': { + 'course_id': new_course.id, + 'name': new_course.name + } + }), 200 + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'msg': f'课程添加失败:{str(e)}'}), 500 + + +@app.route('/course/query', methods=['GET']) +def query_course(): + """查询课程""" + course_id = request.args.get('id') + if course_id: + course = Course.query.get(course_id) + if not course: + return jsonify({'code': 404, 'msg': '课程不存在'}), 404 + + course_info = course.to_dict() + + students_list = list(course.students) \ + if hasattr(course.students, '__iter__') else [] + questions_list = list(course.questions)\ + if hasattr(course.questions, '__iter__') else [] + + course_info['students'] = [{'id': s.id, 'name': s.name} for s in students_list] + course_info['questions'] = [{'id': q.id, 'content': q.content} for q in questions_list] + + return jsonify({'code': 200, 'msg': '查询成功', 'data': course_info}), 200 + else: + all_courses = Course.query.all() + course_list = [c.to_dict() for c in all_courses] + return jsonify({'code': 200, 'msg': '查询成功', 'data': course_list}), 200 + + +@app.route('/course/update', methods=['PUT']) +def update_course(): + """修改课程""" + data = request.get_json() or {} + course_id = data.get('id') + new_name = data.get('name') + + if not course_id or not new_name: + return jsonify({'code': 400, 'msg': '缺少课程ID或新名称参数'}), 400 + course = Course.query.get(course_id) + if not course: + return jsonify({'code': 404, 'msg': '课程不存在'}), 404 + if Course.query.filter_by(name=new_name).filter(Course.id != course_id).first(): + return jsonify({'code': 400, 'msg': '新课程名称已存在'}), 400 + + course.name = new_name + + try: + db.session.commit() + return jsonify({'code': 200, 'msg': '课程修改成功'}), 200 + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'msg': f'课程修改失败:{str(e)}'}), 500 + + +@app.route('/course/delete', methods=['DELETE']) +def delete_course(): + """删除课程""" + data = request.get_json() or {} + course_id = data.get('id') + + if not course_id: + return jsonify({'code': 400, 'msg': '缺少课程ID参数'}), 400 + course = Course.query.get(course_id) + if not course: + return jsonify({'code': 404, 'msg': '课程不存在'}), 404 + try: + Student.query.filter_by(course_id=course_id).delete() + Question.query.filter_by(course_id=course_id).delete() + db.session.delete(course) + db.session.commit() + return jsonify({'code': 200, 'msg': '课程及关联学员/题目删除成功'}), 200 + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'msg': f'课程删除失败:{str(e)}'}), 500 + + +# ====================== 学员相关接口 ====================== +@app.route('/student/add', methods=['POST']) +def add_student(): + """新增学员""" + data = request.get_json() or {} + student_name = data.get('name') + course_id = data.get('course_id') + + if not student_name or not course_id: + return jsonify({'code': 400, 'msg': '缺少学员姓名或课程ID参数'}), 400 + if not Course.query.get(course_id): + return jsonify({'code': 404, 'msg': '课程不存在'}), 404 + new_student = Student(name=student_name, course_id=course_id) + db.session.add(new_student) + + try: + db.session.commit() + return jsonify({ + 'code': 200, + 'msg': '学员添加成功', + 'data': {'student_id': new_student.id} + }), 200 + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'msg': f'学员添加失败:{str(e)}'}), 500 + + +@app.route('/student/query', methods=['GET']) +def query_student(): + """查询学员""" + student_id = request.args.get('id') + course_id = request.args.get('course_id') + + if student_id: + student = Student.query.get(student_id) + if not student: + return jsonify({'code': 404, 'msg': '学员不存在'}), 404 + return jsonify({'code': 200, 'msg': '查询成功', 'data': student.to_dict()}), 200 + elif course_id: + students = Student.query.filter_by(course_id=course_id).all() + student_list = [s.to_dict() for s in students] + return jsonify({'code': 200, 'msg': '查询成功', 'data': student_list}), 200 + else: + all_students = Student.query.all() + student_list = [s.to_dict() for s in all_students] + return jsonify({'code': 200, 'msg': '查询成功', 'data': student_list}), 200 + + +@app.route('/student/update', methods=['PUT']) +def update_student(): + """修改学员""" + data = request.get_json() or {} + student_id = data.get('id') + new_name = data.get('name') + new_course_id = data.get('course_id') + + if not student_id or (not new_name and not new_course_id): + return jsonify({'code': 400, 'msg': '缺少学员ID或修改参数(姓名/课程ID)'}), 400 + student = Student.query.get(student_id) + if not student: + return jsonify({'code': 404, 'msg': '学员不存在'}), 404 + if new_course_id and not Course.query.get(new_course_id): + return jsonify({'code': 404, 'msg': '新课程不存在'}), 404 + if new_name: + student.name = new_name + if new_course_id: + student.course_id = new_course_id + + try: + db.session.commit() + return jsonify({'code': 200, 'msg': '学员修改成功'}), 200 + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'msg': f'学员修改失败:{str(e)}'}), 500 + + +@app.route('/student/delete', methods=['DELETE']) +def delete_student(): + """删除学员""" + data = request.get_json() or {} + student_id = data.get('id') + + if not student_id: + return jsonify({'code': 400, 'msg': '缺少学员ID参数'}), 400 + student = Student.query.get(student_id) + if not student: + return jsonify({'code': 404, 'msg': '学员不存在'}), 404 + + try: + db.session.delete(student) + db.session.commit() + return jsonify({'code': 200, 'msg': '学员删除成功'}), 200 + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'msg': f'学员删除失败:{str(e)}'}), 500 + + +# ====================== 题目相关接口 ====================== +@app.route('/question/add', methods=['POST']) +def add_question(): + """新增题目""" + data = request.get_json() or {} + question_content = data.get('content') + course_id = data.get('course_id') + + if not question_content or not course_id: + return jsonify({'code': 400, 'msg': '缺少题目内容或课程ID参数'}), 400 + if not Course.query.get(course_id): + return jsonify({'code': 404, 'msg': '课程不存在'}), 404 + + new_question = Question(content=question_content, course_id=course_id) + db.session.add(new_question) + + try: + db.session.commit() + return jsonify({ + 'code': 200, + 'msg': '题目添加成功', + 'data': {'question_id': new_question.id} + }), 200 + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'msg': f'题目添加失败:{str(e)}'}), 500 + + +@app.route('/question/query', methods=['GET']) +def query_question(): + """查询题目""" + question_id = request.args.get('id') + course_id = request.args.get('course_id') + + if question_id: + question = Question.query.get(question_id) + if not question: + return jsonify({'code': 404, 'msg': '题目不存在'}), 404 + return jsonify({'code': 200, 'msg': '查询成功', 'data': question.to_dict()}), 200 + elif course_id: + questions = Question.query.filter_by(course_id=course_id).all() + question_list = [q.to_dict() for q in questions] + return jsonify({'code': 200, 'msg': '查询成功', 'data': question_list}), 200 + else: + all_questions = Question.query.all() + question_list = [q.to_dict() for q in all_questions] + return jsonify({'code': 200, 'msg': '查询成功', 'data': question_list}), 200 + + +@app.route('/question/update', methods=['PUT']) +def update_question(): + """修改题目""" + data = request.get_json() or {} + question_id = data.get('id') + new_content = data.get('content') + new_course_id = data.get('course_id') + + if not question_id or (not new_content and not new_course_id): + return jsonify({'code': 400, 'msg': '缺少题目ID或修改参数(内容/课程ID)'}), 400 + + question = Question.query.get(question_id) + if not question: + return jsonify({'code': 404, 'msg': '题目不存在'}), 404 + if new_course_id and not Course.query.get(new_course_id): + return jsonify({'code': 404, 'msg': '新课程不存在'}), 404 + if new_content: + question.content = new_content + if new_course_id: + question.course_id = new_course_id + + try: + db.session.commit() + return jsonify({'code': 200, 'msg': '题目修改成功'}), 200 + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'msg': f'题目修改失败:{str(e)}'}), 500 + + +@app.route('/question/delete', methods=['DELETE']) +def delete_question(): + """删除题目""" + data = request.get_json() or {} + question_id = data.get('id') + + if not question_id: + return jsonify({'code': 400, 'msg': '缺少题目ID参数'}), 400 + question = Question.query.get(question_id) + if not question: + return jsonify({'code': 404, 'msg': '题目不存在'}), 404 + try: + db.session.delete(question) + db.session.commit() + return jsonify({'code': 200, 'msg': '题目删除成功'}), 200 + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'msg': f'题目删除失败:{str(e)}'}), 500 + + +# ====================== 认证相关接口 ====================== +@app.route('/auth/verify', methods=['POST']) +def verify_auth(): + """验证API令牌""" + data = request.get_json() or {} + token = data.get('token') + + if not token: + return jsonify({'code': 400, 'msg': '缺少令牌参数'}), 400 + + if token == API_TOKEN: + return jsonify({ + 'code': 200, + 'msg': '令牌验证成功', + 'data': { + 'valid': True, + 'token_type': 'API' + } + }), 200 + else: + return jsonify({ + 'code': 401, + 'msg': '令牌验证失败', + 'data': {'valid': False} + }), 401 + + +# ====================== 主函数入口 ====================== +if __name__ == '__main__': + app.run(debug=True, host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/webapi/fastapi_of_letcoing/.idea/.gitignore b/webapi/fastapi_of_letcoing/.idea/.gitignore deleted file mode 100644 index 6d9e6873b4625e32bc09a2ab1599e671f24536ec..0000000000000000000000000000000000000000 --- a/webapi/fastapi_of_letcoing/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# 默认忽略的文件 -/shelf/ -/workspace.xml -# 基于编辑器的 HTTP 客户端请求 -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/webapi/fastapi_of_letcoing/.idea/encodings.xml b/webapi/fastapi_of_letcoing/.idea/encodings.xml deleted file mode 100644 index df87cf951fb4858ab7a76b68dd479c98b2df2404..0000000000000000000000000000000000000000 --- a/webapi/fastapi_of_letcoing/.idea/encodings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/webapi/fastapi_of_letcoing/controllers/course_controller.py b/webapi/fastapi_of_letcoing/controllers/course_controller.py new file mode 100644 index 0000000000000000000000000000000000000000..04b1da5f89df4dd6d8919fd259f622f960690915 --- /dev/null +++ b/webapi/fastapi_of_letcoing/controllers/course_controller.py @@ -0,0 +1,200 @@ +"""Course and learning progress controllers.""" + +from __future__ import annotations + +import asyncio + +from flask import g, request +from flask_restx import Namespace, Resource, fields + +from core.di_container import inject +from middleware.auth_middleware import AuthMiddleware +from services.course_service import CourseService + +api = Namespace("courses", description="Course and learning progress operations") + +course_model = api.model( + "CourseCreate", + { + "title": fields.String(required=True, description="Course title"), + "description": fields.String(required=True, description="Course description"), + "cover_url": fields.String(required=False, description="Cover URL"), + }, +) + +course_update_model = api.model( + "CourseUpdate", + { + "title": fields.String(required=False, description="Course title"), + "description": fields.String(required=False, description="Course description"), + "cover_url": fields.String(required=False, description="Cover URL"), + "is_active": fields.Boolean(required=False, description="Active flag"), + }, +) + +course_response_model = api.model( + "CourseResponse", + { + "id": fields.Integer(description="Course ID"), + "title": fields.String(description="Course title"), + "description": fields.String(description="Course description"), + "cover_url": fields.String(description="Cover URL"), + "is_active": fields.Boolean(description="Active flag"), + "created_at": fields.String(description="Created time"), + "updated_at": fields.String(description="Updated time"), + }, +) + +progress_create_model = api.model( + "ProgressCreate", + { + "course_id": fields.Integer(required=True, description="Course ID"), + "progress": fields.Integer(required=True, description="Progress 0-100"), + }, +) + +progress_update_model = api.model( + "ProgressUpdate", + { + "progress": fields.Integer(required=False, description="Progress 0-100"), + "completed": fields.Boolean(required=False, description="Completed flag"), + }, +) + +progress_response_model = api.model( + "ProgressResponse", + { + "id": fields.Integer(description="Progress ID"), + "user_id": fields.Integer(description="User ID"), + "course_id": fields.Integer(description="Course ID"), + "progress": fields.Integer(description="Progress"), + "completed": fields.Boolean(description="Completed flag"), + "last_learned_at": fields.String(description="Last learned time"), + "created_at": fields.String(description="Created time"), + "updated_at": fields.String(description="Updated time"), + }, +) + +user_course_response_model = api.model( + "UserCourseResponse", + { + "course": fields.Nested(course_response_model), + "progress": fields.Nested(progress_response_model), + }, +) + + +def _run(coro): + return asyncio.run(coro) + + +@api.route("") +class CourseListController(Resource): + @api.marshal_list_with(course_response_model) + def get(self): + service = inject(CourseService) + limit = request.args.get("limit", 50, type=int) + offset = request.args.get("offset", 0, type=int) + active_only = request.args.get("active_only", "true").lower() == "true" + return _run(service.list_courses(limit=limit, offset=offset, active_only=active_only)), 200 + + @api.expect(course_model) + @api.marshal_with(course_response_model, code=201) + @AuthMiddleware.require_auth + def post(self): + service = inject(CourseService) + data = request.get_json(silent=True) or {} + if not data.get("title") or not data.get("description"): + return {"error": "title and description are required"}, 400 + try: + course = _run(service.create_course(data)) + return course, 201 + except Exception as exc: + return {"error": str(exc)}, 500 + + +@api.route("/") +class CourseDetailController(Resource): + @api.marshal_with(course_response_model) + def get(self, course_id: int): + service = inject(CourseService) + course = _run(service.get_course_by_id(course_id)) + if not course: + return {"error": "Course not found"}, 404 + return course, 200 + + @api.expect(course_update_model) + @api.marshal_with(course_response_model) + @AuthMiddleware.require_auth + def put(self, course_id: int): + service = inject(CourseService) + data = request.get_json(silent=True) or {} + course = _run(service.update_course(course_id, data)) + if not course: + return {"error": "Course not found"}, 404 + return course, 200 + + +@api.route("/progress") +class LearningProgressListController(Resource): + @api.marshal_list_with(user_course_response_model) + @AuthMiddleware.require_auth + def get(self): + service = inject(CourseService) + current_user = getattr(g, "current_user", None) + if not current_user: + return {"error": "Unauthorized"}, 401 + return _run(service.get_user_courses(int(current_user["id"]))), 200 + + @api.expect(progress_create_model) + @api.marshal_with(progress_response_model, code=201) + @AuthMiddleware.require_auth + def post(self): + service = inject(CourseService) + current_user = getattr(g, "current_user", None) + if not current_user: + return {"error": "Unauthorized"}, 401 + + data = request.get_json(silent=True) or {} + if "course_id" not in data or "progress" not in data: + return {"error": "course_id and progress are required"}, 400 + + try: + progress = _run(service.create_learning_progress(int(current_user["id"]), data)) + return progress, 201 + except ValueError as exc: + return {"error": str(exc)}, 400 + except Exception as exc: + return {"error": str(exc)}, 500 + + +@api.route("/progress/") +class LearningProgressDetailController(Resource): + @api.marshal_with(progress_response_model) + @AuthMiddleware.require_auth + def get(self, course_id: int): + service = inject(CourseService) + current_user = getattr(g, "current_user", None) + if not current_user: + return {"error": "Unauthorized"}, 401 + + progress = _run(service.get_learning_progress(int(current_user["id"]), course_id)) + if not progress: + return {"error": "Progress not found"}, 404 + return progress, 200 + + @api.expect(progress_update_model) + @api.marshal_with(progress_response_model) + @AuthMiddleware.require_auth + def put(self, course_id: int): + service = inject(CourseService) + current_user = getattr(g, "current_user", None) + if not current_user: + return {"error": "Unauthorized"}, 401 + + data = request.get_json(silent=True) or {} + progress = _run(service.update_learning_progress(int(current_user["id"]), course_id, data)) + if not progress: + return {"error": "Progress not found"}, 404 + return progress, 200 + diff --git a/webapi/fastapi_of_letcoing/controllers/question_controller.py b/webapi/fastapi_of_letcoing/controllers/question_controller.py new file mode 100644 index 0000000000000000000000000000000000000000..d92508ee2bca0e08a88ab1a34e6442af3e337c40 --- /dev/null +++ b/webapi/fastapi_of_letcoing/controllers/question_controller.py @@ -0,0 +1,87 @@ +from flask import request +from flask_restx import Resource, Namespace, fields +from core.di_container import inject +from interfaces.service_interfaces import IQuestionService +from models.question_models import QuestionResponse + +# 创建命名空间 +api = Namespace('questions', description='题目管理') + +# 定义数据模型 +question_model = api.model('Question', { + 'id': fields.Integer(readonly=True, description="题目ID"), + 'title': fields.String(required=True, description="题目标题"), + 'intro': fields.String(description="题目介绍"), + 'difficulty': fields.Integer(required=True, description="难度 (1-5)", min=1, max=5), + 'testcode': fields.String(description="验证代码"), + 'lang': fields.String(description="编程语言", default="cpp"), + 'created_at': fields.DateTime(readonly=True, description="创建时间"), + 'updated_at': fields.DateTime(readonly=True, description="更新时间"), +}) + +@api.route('/') +class QuestionList(Resource): + @api.doc('list_questions') + @api.marshal_list_with(question_model) + def get(self): + """获取题目列表""" + # 注入服务 + service = inject(IQuestionService) + + # 获取分页参数 + skip = request.args.get('skip', 0, type=int) + limit = request.args.get('limit', 100, type=int) + + return service.get_questions(skip=skip, limit=limit) + + @api.doc('create_question') + @api.expect(question_model) + @api.marshal_with(question_model, code=201) + def post(self): + """创建新题目""" + # 注入服务 + service = inject(IQuestionService) + + # request.json 包含前端发来的数据 + return service.create_question(request.json), 201 + +@api.route('/') +class QuestionDetail(Resource): + @api.doc('get_question') + @api.marshal_with(question_model) + def get(self, id): + """获取一个题目详情""" + # 注入服务 + service = inject(IQuestionService) + + # 提取题目ID + result = service.get_question(id) + if result: + return result + api.abort(404, "Question not found") + + @api.doc('update_question') + @api.expect(question_model) + @api.marshal_with(question_model) + def put(self, id): + """更新题目""" + # 注入服务 + service = inject(IQuestionService) + + # 更新题目信息 + result = service.update_question(id, request.json) + if result: + return result + api.abort(404, "Question not found") + + @api.doc('delete_question') + @api.response(204, 'Question deleted') + def delete(self, id): + """删除题目""" + # 注入服务 + service = inject(IQuestionService) + + # 删除题目 + if service.delete_question(id): + return '', 204 + api.abort(404, "Question not found") diff --git a/webapi/fastapi_of_letcoing/controllers/user_controller.py b/webapi/fastapi_of_letcoing/controllers/user_controller.py new file mode 100644 index 0000000000000000000000000000000000000000..2f5c683a8d268f1cc9672f7f2efeaab3e0db6861 --- /dev/null +++ b/webapi/fastapi_of_letcoing/controllers/user_controller.py @@ -0,0 +1,189 @@ +"""User management controllers.""" + +from __future__ import annotations + +import asyncio + +from flask import g, request +from flask_restx import Namespace, Resource, fields + +from core.di_container import inject +from interfaces.service_interfaces import IJWTService +from middleware.auth_middleware import AuthMiddleware +from services.user_service import UserService + +api = Namespace("users", description="User related operations") + +user_create_model = api.model( + "UserCreate", + { + "username": fields.String(required=True, description="Username"), + "email": fields.String(required=False, description="Email"), + "password": fields.String(required=True, description="Password"), + }, +) + +user_login_model = api.model( + "UserLogin", + { + "identifier": fields.String(required=False, description="Username or email"), + "email": fields.String(required=False, description="Email"), + "username": fields.String(required=False, description="Username"), + "password": fields.String(required=True, description="Password"), + }, +) + +user_update_model = api.model( + "UserUpdate", + { + "username": fields.String(required=False, description="Username"), + "email": fields.String(required=False, description="Email"), + "password": fields.String(required=False, description="Password"), + "is_active": fields.Boolean(required=False, description="Active flag"), + }, +) + +user_response_model = api.model( + "UserResponse", + { + "id": fields.Integer(description="User ID"), + "username": fields.String(description="Username"), + "email": fields.String(description="Email"), + "is_active": fields.Boolean(description="Active flag"), + "last_login": fields.String(description="Last login time"), + "created_at": fields.String(description="Created time"), + "updated_at": fields.String(description="Updated time"), + }, +) + +login_response_model = api.model( + "LoginResponse", + { + "user": fields.Nested(user_response_model), + "token": fields.String(description="Access token"), + "message": fields.String(description="Message"), + }, +) + + +def _run(coro): + return asyncio.run(coro) + + +@api.route("/register") +class UserRegisterController(Resource): + @api.expect(user_create_model) + @api.marshal_with(user_response_model, code=201) + def post(self): + service = inject(UserService) + data = request.get_json(silent=True) or {} + username = data.get("username", "").strip() + password = data.get("password", "") + email = data.get("email") + + if not username or not password: + return {"error": "username and password are required"}, 400 + + try: + user = _run(service.create_user(username=username, password=password, email=email)) + return user, 201 + except ValueError as exc: + return {"error": str(exc)}, 400 + except Exception as exc: + return {"error": str(exc)}, 500 + + +@api.route("/login") +class UserLoginController(Resource): + @api.expect(user_login_model) + @api.marshal_with(login_response_model) + def post(self): + service = inject(UserService) + jwt_service = inject(IJWTService) + data = request.get_json(silent=True) or {} + identifier = (data.get("identifier") or data.get("username") or data.get("email") or "").strip() + password = data.get("password", "") + + if not identifier or not password: + return {"error": "identifier and password are required"}, 400 + + user = _run(service.authenticate_user(identifier, password)) + if not user: + return {"error": "Invalid credentials"}, 401 + + tokens = jwt_service.generate_tokens( + { + "id": user["id"], + "username": user["username"], + "email": user.get("email"), + } + ) + + return { + "user": user, + "token": tokens.access_token, + "message": "Login successful", + }, 200 + + +@api.route("/profile") +class UserProfileController(Resource): + @AuthMiddleware.require_auth + @api.marshal_with(user_response_model) + def get(self): + current_user = getattr(g, "current_user", None) + if not current_user: + return {"error": "Unauthorized"}, 401 + return current_user, 200 + + +@api.route("") +class UserListController(Resource): + @api.marshal_list_with(user_response_model) + def get(self): + service = inject(UserService) + limit = request.args.get("limit", 50, type=int) + offset = request.args.get("offset", 0, type=int) + active_only = request.args.get("active_only", "false").lower() == "true" + users = _run(service.list_users(limit=limit, offset=offset, active_only=active_only)) + return users, 200 + + +@api.route("/") +class UserDetailController(Resource): + @api.marshal_with(user_response_model) + def get(self, user_id: int): + service = inject(UserService) + user = _run(service.get_user_by_id(user_id)) + if not user: + return {"error": "User not found"}, 404 + return user, 200 + + @api.expect(user_update_model) + @api.marshal_with(user_response_model) + def put(self, user_id: int): + service = inject(UserService) + data = request.get_json(silent=True) or {} + + try: + if "password" in data and data["password"]: + _run(service.update_user_password(user_id, data["password"])) + update_fields = {key: data.get(key) for key in ("username", "email", "is_active") if key in data} + if update_fields: + _run(service.update_user_info(user_id, **update_fields)) + user = _run(service.get_user_by_id(user_id)) + if not user: + return {"error": "User not found"}, 404 + return user, 200 + except ValueError as exc: + return {"error": str(exc)}, 400 + except Exception as exc: + return {"error": str(exc)}, 500 + + def delete(self, user_id: int): + service = inject(UserService) + deleted = _run(service.delete_user(user_id)) + if not deleted: + return {"error": "User not found"}, 404 + return "", 204 + diff --git a/webapi/fastapi_of_letcoing/core/db_container.py b/webapi/fastapi_of_letcoing/core/db_container.py new file mode 100644 index 0000000000000000000000000000000000000000..8ccf26d8165347f43f4c92177acde1a4c71587dc --- /dev/null +++ b/webapi/fastapi_of_letcoing/core/db_container.py @@ -0,0 +1,67 @@ +""" +Peewee连接PostgreSQL配置 +基于Peewee的PostgreSQL数据库连接管理及基础模型定义 +""" +from dataclasses import dataclass +from typing import Optional + +from peewee import PostgresqlDatabase, Model +from core.di_container import get_container +from interfaces.service_interfaces import IConfigService + + +@dataclass +class PeeweeDatabaseConfig: + """Peewee数据库配置类""" + + # 数据库连接参数 + host: str = "localhost" + port: int = 5432 + database: str = "letcoding" + user: str = "postgres" + password: str = "123456" + + # 连接池配置 + max_connections: int = 20 + stale_timeout: int = 300 + + def __post_init__(self): + """初始化时从ConfigService读取配置""" + try: + config_service = get_container().resolve(IConfigService) + db_config = config_service.get_database_config() + + self.host = db_config["host"] + self.port = db_config["port"] + self.database = db_config["database"] + self.user = db_config["username"] + self.password = db_config["password"] + self.max_connections = db_config["max_connections"] + self.stale_timeout = db_config["stale_timeout"] + except Exception: + # 依赖注入不可用时使用默认值 + pass + + @property + def connection_params(self) -> dict: + """获取Peewee连接参数字典""" + return { + "database": self.database, + "user": self.user, + "password": self.password, + "host": self.host, + "port": self.port, + "max_connections": self.max_connections, + "stale_timeout": self.stale_timeout + } + + +# 初始化数据库连接 +db_config = PeeweeDatabaseConfig() +db = PostgresqlDatabase(** db_config.connection_params) + + +# Peewee基础模型(所有业务模型继承此类) +class BaseModel(Model): + class Meta: + database = db # 绑定数据库连接 \ No newline at end of file diff --git a/webapi/fastapi_of_letcoing/core/service_config.py b/webapi/fastapi_of_letcoing/core/service_config.py index 3dbd17bb277addc1c53ea2e7612950142f254d0e..8d1bbf85c82a0d371971796e59a41790387f4055 100644 --- a/webapi/fastapi_of_letcoing/core/service_config.py +++ b/webapi/fastapi_of_letcoing/core/service_config.py @@ -13,7 +13,9 @@ from services.jwt_service import JWTService from services.redis_service import RedisService from services.database_service import DatabaseService from services.user_service import UserService -from interfaces.service_interfaces import IConfigService, ILoggerService, ICodeExecutionService, IRedisService, IOIDCService, IJWTService +from services.course_service import CourseService +from services.question_service import QuestionService +from interfaces.service_interfaces import IConfigService, ILoggerService, ICodeExecutionService, IRedisService, IOIDCService, IJWTService, IQuestionService def setup_services(app_config: dict) -> None: @@ -26,6 +28,7 @@ def setup_services(app_config: dict) -> None: container.register_singleton(ILoggerService, factory=lambda: LoggerService()) container.register_singleton(IRedisService, RedisService) + container.register_scoped(IQuestionService, QuestionService) container.register_scoped(ICodeExecutionService, GlotService) container.register_singleton(IOIDCService, OIDCService) container.register_singleton(IJWTService, JWTService) @@ -38,9 +41,14 @@ def setup_services(app_config: dict) -> None: def create_user_service(): config_service = container.resolve(IConfigService) return UserService(config_service) + + def create_course_service(): + config_service = container.resolve(IConfigService) + return CourseService(config_service) container.register_singleton(DatabaseService, factory=create_database_service) container.register_singleton(UserService, factory=create_user_service) + container.register_singleton(CourseService, factory=create_course_service) configure_services(service_configurator) @@ -48,4 +56,4 @@ def setup_services(app_config: dict) -> None: def get_service(service_type): """获取指定类型的服务实例""" container = get_container() - return container.resolve(service_type) \ No newline at end of file + return container.resolve(service_type) diff --git a/webapi/fastapi_of_letcoing/interfaces/course_interfaces.py b/webapi/fastapi_of_letcoing/interfaces/course_interfaces.py new file mode 100644 index 0000000000000000000000000000000000000000..47f582642e9332b519d9ae35507e50ed071822ab --- /dev/null +++ b/webapi/fastapi_of_letcoing/interfaces/course_interfaces.py @@ -0,0 +1,55 @@ +"""Course and progress request/response models.""" + +from typing import Optional + +from pydantic import BaseModel, Field + + +class CourseCreateRequest(BaseModel): + title: str = Field(min_length=2, max_length=100) + description: str + cover_url: Optional[str] = None + + +class CourseUpdateRequest(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + cover_url: Optional[str] = None + is_active: Optional[bool] = None + + +class CourseResponse(BaseModel): + id: int + title: str + description: str + cover_url: Optional[str] = None + is_active: bool = True + created_at: Optional[str] = None + updated_at: Optional[str] = None + + +class LearningProgressCreateRequest(BaseModel): + course_id: int + progress: int = Field(ge=0, le=100) + + +class LearningProgressUpdateRequest(BaseModel): + progress: Optional[int] = Field(default=None, ge=0, le=100) + completed: Optional[bool] = None + + +class LearningProgressResponse(BaseModel): + id: int + user_id: int + course_id: int + progress: int + completed: bool + last_learned_at: Optional[str] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + + +class UserCourseResponse(BaseModel): + course: CourseResponse + progress: LearningProgressResponse + diff --git a/webapi/fastapi_of_letcoing/interfaces/service_interfaces.py b/webapi/fastapi_of_letcoing/interfaces/service_interfaces.py index f5a39e7694d75310601e29040c26c8351d59ce97..f480fad11c2f349862097f3ea2ab0c35dfda4235 100644 --- a/webapi/fastapi_of_letcoing/interfaces/service_interfaces.py +++ b/webapi/fastapi_of_letcoing/interfaces/service_interfaces.py @@ -271,4 +271,33 @@ class IJWTService(ABC): Returns: 是否成功 """ - pass \ No newline at end of file + pass + + +class IQuestionService(ABC): + """题目服务接口""" + + @abstractmethod + def create_question(self, data: Dict[str, Any]) -> Any: + """创建题目""" + pass + + @abstractmethod + def get_question(self, question_id: int) -> Optional[Any]: + """获取单个题目""" + pass + + @abstractmethod + def get_questions(self, skip: int = 0, limit: int = 100) -> List[Any]: + """获取题目列表""" + pass + + @abstractmethod + def update_question(self, question_id: int, data: Dict[str, Any]) -> Optional[Any]: + """更新题目""" + pass + + @abstractmethod + def delete_question(self, question_id: int) -> bool: + """删除题目""" + pass diff --git a/webapi/fastapi_of_letcoing/interfaces/user_interfaces.py b/webapi/fastapi_of_letcoing/interfaces/user_interfaces.py new file mode 100644 index 0000000000000000000000000000000000000000..11972ef70c763cef90e0e73b95b97d9d3c67e5fd --- /dev/null +++ b/webapi/fastapi_of_letcoing/interfaces/user_interfaces.py @@ -0,0 +1,32 @@ +"""User request and response models.""" + +from typing import Optional + +from pydantic import BaseModel, Field + + +class UserCreateRequest(BaseModel): + username: str = Field(min_length=3, max_length=50) + email: Optional[str] = None + password: str = Field(min_length=6) + avatar_url: Optional[str] = None + + +class UserUpdateRequest(BaseModel): + username: Optional[str] = None + email: Optional[str] = None + password: Optional[str] = None + avatar_url: Optional[str] = None + is_active: Optional[bool] = None + + +class UserResponse(BaseModel): + id: int + username: str + email: Optional[str] = None + is_active: bool = True + avatar_url: Optional[str] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + last_login: Optional[str] = None + diff --git a/webapi/fastapi_of_letcoing/main.py b/webapi/fastapi_of_letcoing/main.py index e2c237fe0b04bd762d0ce58ca5e8f7e8085e5a75..4a095563b747be31449c5bba6692283c561206b8 100644 --- a/webapi/fastapi_of_letcoing/main.py +++ b/webapi/fastapi_of_letcoing/main.py @@ -1,11 +1,18 @@ -from flask import Flask +from flask import Flask, jsonify, request from flask_restx import Api, Resource from controllers.code_controller import api as code_api from controllers.auth_controller import api as auth_api +from controllers.course_controller import api as course_api +from controllers.user_controller import api as user_bp +from controllers.question_controller import api as question_api import os +import json +import atexit from core.service_config import setup_services from interfaces.service_interfaces import IOIDCService from dotenv import load_dotenv +from core.di_container import get_container +from core.exceptions import ServiceInitError # 加载环境变量 load_dotenv() @@ -13,51 +20,67 @@ load_dotenv() # 创建 Flask 应用 app = Flask(__name__) + +@app.before_request +def handle_preflight(): + if request.method == "OPTIONS": + return ("", 204) + + +@app.after_request +def add_cors_headers(response): + response.headers["Access-Control-Allow-Origin"] = os.environ.get("CORS_ALLOW_ORIGIN", "*") + response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS" + response.headers["Access-Control-Max-Age"] = "86400" + return response + + # 配置应用 -app.config['API_TOKEN'] = os.environ.get('API_TOKEN', '') -app.config['JWT_SECRET_KEY'] = os.environ.get('JWT_SECRET_KEY', 'your-secret-key-here') -app.config['JWT_ACCESS_TOKEN_EXPIRE'] = int(os.environ.get('JWT_ACCESS_TOKEN_EXPIRE', '3600')) -app.config['JWT_REFRESH_TOKEN_EXPIRE'] = int(os.environ.get('JWT_REFRESH_TOKEN_EXPIRE', '604800')) -app.config['JWT_ALGORITHM'] = os.environ.get('JWT_ALGORITHM', 'HS256') +app.config["API_TOKEN"] = os.environ.get("API_TOKEN", "") +app.config["JWT_SECRET_KEY"] = os.environ.get("JWT_SECRET_KEY", "your-secret-key-here") +app.config["JWT_ACCESS_TOKEN_EXPIRE"] = int(os.environ.get("JWT_ACCESS_TOKEN_EXPIRE", "3600")) +app.config["JWT_REFRESH_TOKEN_EXPIRE"] = int(os.environ.get("JWT_REFRESH_TOKEN_EXPIRE", "604800")) +app.config["JWT_ALGORITHM"] = os.environ.get("JWT_ALGORITHM", "HS256") # Redis 配置 -app.config['REDIS_HOST'] = os.environ.get('REDIS_HOST', 'localhost') -app.config['REDIS_PORT'] = int(os.environ.get('REDIS_PORT', '6379')) -app.config['REDIS_DB'] = int(os.environ.get('REDIS_DB', '0')) -app.config['REDIS_PASSWORD'] = os.environ.get('REDIS_PASSWORD') -app.config['REDIS_TIMEOUT'] = int(os.environ.get('REDIS_TIMEOUT', '5')) +app.config["REDIS_HOST"] = os.environ.get("REDIS_HOST", "localhost") +app.config["REDIS_PORT"] = int(os.environ.get("REDIS_PORT", "6379")) +app.config["REDIS_DB"] = int(os.environ.get("REDIS_DB", "0")) +app.config["REDIS_PASSWORD"] = os.environ.get("REDIS_PASSWORD") +app.config["REDIS_TIMEOUT"] = int(os.environ.get("REDIS_TIMEOUT", "5")) +app.config["REDIS_MAX_CONNECTIONS"] = int(os.environ.get("REDIS_MAX_CONNECTIONS", "10")) # 数据库配置 -app.config['DB_HOST'] = os.environ.get('DB_HOST', 'localhost') -app.config['DB_PORT'] = int(os.environ.get('DB_PORT', '5432')) -app.config['DB_NAME'] = os.environ.get('DB_NAME', 'letcoding') -app.config['DB_USER'] = os.environ.get('DB_USER', 'postgres') -app.config['DB_PASSWORD'] = os.environ.get('DB_PASSWORD', '') -app.config['DB_MAX_CONNECTIONS'] = int(os.environ.get('DB_MAX_CONNECTIONS', '20')) -app.config['DB_STALE_TIMEOUT'] = int(os.environ.get('DB_STALE_TIMEOUT', '300')) +app.config["DB_HOST"] = os.environ.get("DB_HOST", "localhost") +app.config["DB_PORT"] = int(os.environ.get("DB_PORT", "5432")) +app.config["DB_NAME"] = os.environ.get("DB_NAME", "letcoding") +app.config["DB_USER"] = os.environ.get("DB_USER", "postgres") +app.config["DB_PASSWORD"] = os.environ.get("DB_PASSWORD", "") +app.config["DB_MAX_CONNECTIONS"] = int(os.environ.get("DB_MAX_CONNECTIONS", "20")) +app.config["DB_STALE_TIMEOUT"] = int(os.environ.get("DB_STALE_TIMEOUT", "300")) +app.config["DB_SSL_MODE"] = os.environ.get("DB_SSL_MODE", "disable") # GitHub OAuth 配置 -app.config['GITHUB_CLIENT_ID'] = os.environ.get('GITHUB_CLIENT_ID') -app.config['GITHUB_CLIENT_SECRET'] = os.environ.get('GITHUB_CLIENT_SECRET') +app.config["GITHUB_CLIENT_ID"] = os.environ.get("GITHUB_CLIENT_ID") +app.config["GITHUB_CLIENT_SECRET"] = os.environ.get("GITHUB_CLIENT_SECRET") # 自定义 OIDC 提供商配置(JSON格式) -oidc_providers_json = os.environ.get('OIDC_PROVIDERS') +oidc_providers_json = os.environ.get("OIDC_PROVIDERS") if oidc_providers_json: - import json try: - app.config['OIDC_PROVIDERS'] = json.loads(oidc_providers_json) + app.config["OIDC_PROVIDERS"] = json.loads(oidc_providers_json) except json.JSONDecodeError: - app.config['OIDC_PROVIDERS'] = {} + app.config["OIDC_PROVIDERS"] = {} else: - app.config['OIDC_PROVIDERS'] = {} + app.config["OIDC_PROVIDERS"] = {} # 配置依赖注入 setup_services(app.config) # 初始化 OIDC 服务 -oidc_service = setup_services.__globals__.get('oidc_service') +oidc_service = setup_services.__globals__.get("oidc_service") if not oidc_service: - from core.di_container import get_container, inject container = get_container() oidc_service = container.resolve(IOIDCService) oidc_service.initialize_oauth(app) @@ -65,15 +88,18 @@ if not oidc_service: # 创建 API 实例 api = Api( app, - version='1.0', - title='LetCoding API', - description='代码执行 API 服务,支持多种编程语言和用户认证', - doc='/swagger/' + version="1.0", + title="LetCoding API", + description="代码执行 API 服务,支持多种编程语言和用户认证", + doc="/swagger/", ) # 注册命名空间 -api.add_namespace(code_api, path='/code') -api.add_namespace(auth_api, path='/auth') +api.add_namespace(code_api, path="/code") +api.add_namespace(auth_api, path="/auth") +api.add_namespace(user_bp, path="/user") +api.add_namespace(course_api, path="/courses") +api.add_namespace(question_api, path="/questions") -if __name__ == '__main__': - app.run(debug=True, host='0.0.0.0', port=6173) \ No newline at end of file +if __name__ == "__main__": + app.run(debug=True, host="0.0.0.0", port=6173) diff --git a/webapi/fastapi_of_letcoing/models/course_models.py b/webapi/fastapi_of_letcoing/models/course_models.py new file mode 100644 index 0000000000000000000000000000000000000000..434bb34fb934e1014919dfbba719bd0864755792 --- /dev/null +++ b/webapi/fastapi_of_letcoing/models/course_models.py @@ -0,0 +1,49 @@ +""" +课程及学习进度模型 +""" +from datetime import datetime +from peewee import AutoField, CharField, TextField, IntegerField, ForeignKeyField, DateTimeField, BooleanField + +from core.db_container import BaseModel +from models.user_models import User + + +class Course(BaseModel): + """课程模型""" + id = AutoField(primary_key=True, verbose_name="课程ID") + title = CharField(max_length=100, verbose_name="课程标题") + description = TextField(verbose_name="课程描述") + cover_url = CharField(max_length=255, null=True, verbose_name="课程封面") + is_active = BooleanField(default=True, verbose_name="是否上架") + created_at = DateTimeField(default=datetime.now, verbose_name="创建时间") + updated_at = DateTimeField(default=datetime.now, verbose_name="更新时间") + + class Meta: + table_name = "courses" + + def to_dict(self) -> dict: + data = super().to_dict() + return data + + +class LearningProgress(BaseModel): + """学习进度模型(关联用户与课程)""" + id = AutoField(primary_key=True, verbose_name="进度ID") + user = ForeignKeyField(User, on_delete='CASCADE', verbose_name="关联用户") + course = ForeignKeyField(Course, on_delete='CASCADE', verbose_name="关联课程") + progress = IntegerField(default=0, verbose_name="学习进度(百分比)") + completed = BooleanField(default=False, verbose_name="是否完成") + last_learned_at = DateTimeField(default=datetime.now, verbose_name="最后学习时间") + created_at = DateTimeField(default=datetime.now, verbose_name="创建时间") + updated_at = DateTimeField(default=datetime.now, verbose_name="更新时间") + + class Meta: + table_name = "learning_progress" + unique_together = (('user', 'course'),) # 一个用户对一个课程只能有一条进度记录 + + def to_dict(self) -> dict: + data = super().to_dict() + # 转换外键为ID + data['user_id'] = data.pop('user') + data['course_id'] = data.pop('course') + return data \ No newline at end of file diff --git a/webapi/fastapi_of_letcoing/models/db_models.py b/webapi/fastapi_of_letcoing/models/db_models.py index 00474830040bda6bbf2fdc75bbcbf81aef4f26bc..335f78ab2aec213a53d2e7863ceb9d02db5914a1 100644 --- a/webapi/fastapi_of_letcoing/models/db_models.py +++ b/webapi/fastapi_of_letcoing/models/db_models.py @@ -98,8 +98,25 @@ class User(BaseModel): data.pop("password_hash", None) return data +class Question(BaseModel): + """题目模型""" + + id = AutoField(primary_key=True, verbose_name="题目ID") + title = CharField(max_length=200, null=False, verbose_name="题目标题") + intro = TextField(null=True, verbose_name="题目介绍") + difficulty = IntegerField(null=False, verbose_name="题目难度") + testcode = TextField(null=True, verbose_name="验证代码") + lang = CharField(max_length=50, null=False, verbose_name="可使用语言") + + class Meta: + table_name = "questions" + + def to_dict(self) -> dict: + """转换为字典""" + return super().to_dict() + # 模型列表 -MODELS = [User] +MODELS = [User, Question] def create_tables(): @@ -125,4 +142,4 @@ def close_database(): """关闭数据库连接""" db = get_database() if not db.is_closed(): - db.close() \ No newline at end of file + db.close() diff --git a/webapi/fastapi_of_letcoing/models/question_models.py b/webapi/fastapi_of_letcoing/models/question_models.py new file mode 100644 index 0000000000000000000000000000000000000000..42e85228787fd76ce16e9c4c30632a0e3c7a3199 --- /dev/null +++ b/webapi/fastapi_of_letcoing/models/question_models.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime + +class QuestionCreate(BaseModel): + """创建题目模型""" + title: str + intro: Optional[str] = None + difficulty: int = Field(..., ge=1, le=5, description="难度等级 (1-5)") + testcode: Optional[str] = None + lang: str = "cpp" + +class QuestionUpdate(BaseModel): + """更新题目模型""" + title: Optional[str] = None + intro: Optional[str] = None + difficulty: Optional[int] = Field(None, ge=1, le=5, description="难度等级 (1-5)") + testcode: Optional[str] = None + lang: Optional[str] = None + +class QuestionResponse(BaseModel): + """题目响应模型""" + id: int + title: str + intro: Optional[str] = None + difficulty: int + testcode: Optional[str] = None + lang: str + created_at: datetime + updated_at: datetime + + class Config: + orm_mode = True \ No newline at end of file diff --git a/webapi/fastapi_of_letcoing/models/user_models.py b/webapi/fastapi_of_letcoing/models/user_models.py new file mode 100644 index 0000000000000000000000000000000000000000..4a32b98b3e81499ad9d21d9a2875504a6fba653b --- /dev/null +++ b/webapi/fastapi_of_letcoing/models/user_models.py @@ -0,0 +1,6 @@ +"""Compatibility alias for the Peewee User model.""" + +from .db_models import User + +__all__ = ["User"] + diff --git a/webapi/fastapi_of_letcoing/models/user_modles.py b/webapi/fastapi_of_letcoing/models/user_modles.py new file mode 100644 index 0000000000000000000000000000000000000000..1c88b9bbe7625399606f93f3521f6445fa68076a --- /dev/null +++ b/webapi/fastapi_of_letcoing/models/user_modles.py @@ -0,0 +1,28 @@ +""" +用户模型 +""" +from datetime import datetime +from peewee import AutoField, CharField, BooleanField, DateTimeField + +from core.db_container import BaseModel + + +class User(BaseModel): + """用户模型""" + + id = AutoField(primary_key=True, verbose_name="用户ID") + username = CharField(max_length=50, unique=True, index=True, verbose_name="用户名") + email = CharField(max_length=100, unique=True, index=True, verbose_name="邮箱") + password_hash = CharField(max_length=255, verbose_name="密码哈希") + is_active = BooleanField(default=True, verbose_name="是否激活") + last_login = DateTimeField(null=True, verbose_name="最后登录时间") + avatar_url = CharField(max_length=255, null=True, default=None, verbose_name="头像地址") + + class Meta: + table_name = "users" + + def to_dict(self) -> dict: + """转换为字典,排除敏感信息""" + data = super().to_dict() + data.pop("password_hash", None) + return data \ No newline at end of file diff --git a/webapi/fastapi_of_letcoing/services/course_service.py b/webapi/fastapi_of_letcoing/services/course_service.py new file mode 100644 index 0000000000000000000000000000000000000000..f7cff6d12dcfda9c7aab9712ff6176c1aff69111 --- /dev/null +++ b/webapi/fastapi_of_letcoing/services/course_service.py @@ -0,0 +1,119 @@ +"""Course and learning progress service.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from peewee import DoesNotExist + +from core.di_container import Injectable +from models.course_models import Course, LearningProgress +from models.db_models import User +from services.database_service import DatabaseService + + +class CourseService(DatabaseService, Injectable): + """Course-related database operations.""" + + async def create_course(self, course_data: Any) -> Dict[str, Any]: + data = course_data.model_dump() if hasattr(course_data, "model_dump") else dict(course_data) + return await self.create( + Course, + title=data["title"], + description=data["description"], + cover_url=data.get("cover_url"), + is_active=True, + ) + + async def get_course_by_id(self, course_id: int) -> Optional[Dict[str, Any]]: + return await self.get_by_id(Course, course_id) + + async def update_course(self, course_id: int, update_data: Any) -> Optional[Dict[str, Any]]: + data = update_data.model_dump(exclude_unset=True) if hasattr(update_data, "model_dump") else dict(update_data) + if not await self.get_course_by_id(course_id): + return None + if not data: + return await self.get_course_by_id(course_id) + await self.update(Course, course_id, **data) + return await self.get_course_by_id(course_id) + + async def list_courses(self, limit: int = 50, offset: int = 0, active_only: bool = True) -> List[Dict[str, Any]]: + query = Course.select() + if active_only: + query = query.where(Course.is_active == True) + query = query.order_by(Course.created_at.desc()).limit(limit).offset(offset) + return [course.to_dict() for course in query] + + async def create_learning_progress(self, user_id: int, progress_data: Any) -> Dict[str, Any]: + data = progress_data.model_dump() if hasattr(progress_data, "model_dump") else dict(progress_data) + if not self._user_exists(user_id): + raise ValueError("用户不存在") + if not await self.get_course_by_id(data["course_id"]): + raise ValueError("课程不存在") + if await self.get_learning_progress(user_id, data["course_id"]): + raise ValueError("学习进度已存在") + + completed = data["progress"] >= 100 + return await self.create( + LearningProgress, + user=user_id, + course=data["course_id"], + progress=data["progress"], + completed=completed, + last_learned_at=datetime.now(), + ) + + async def update_learning_progress(self, user_id: int, course_id: int, update_data: Any) -> Optional[Dict[str, Any]]: + data = update_data.model_dump(exclude_unset=True) if hasattr(update_data, "model_dump") else dict(update_data) + progress = await self.get_learning_progress(user_id, course_id) + if not progress: + return None + + if "progress" in data and data["progress"] is not None: + data["completed"] = data["progress"] >= 100 + if "completed" not in data and "progress" not in data: + return progress + + data["last_learned_at"] = datetime.now() + await self.update(LearningProgress, progress["id"], **data) + return await self.get_learning_progress(user_id, course_id) + + async def get_learning_progress(self, user_id: int, course_id: int) -> Optional[Dict[str, Any]]: + try: + progress = LearningProgress.get( + LearningProgress.user == user_id, + LearningProgress.course == course_id, + ) + return progress.to_dict() + except DoesNotExist: + return None + + async def get_user_courses(self, user_id: int) -> List[Dict[str, Any]]: + if not self._user_exists(user_id): + return [] + + query = ( + LearningProgress.select(LearningProgress, Course) + .join(Course) + .where(LearningProgress.user == user_id) + .order_by(LearningProgress.last_learned_at.desc()) + ) + + result: List[Dict[str, Any]] = [] + for progress in query: + result.append( + { + "course": progress.course.to_dict(), + "progress": progress.to_dict(), + } + ) + return result + + def _user_exists(self, user_id: int) -> bool: + try: + User.get_by_id(user_id) + return True + except DoesNotExist: + return False + diff --git a/webapi/fastapi_of_letcoing/services/question_service.py b/webapi/fastapi_of_letcoing/services/question_service.py new file mode 100644 index 0000000000000000000000000000000000000000..3d2bb17b153c5642c9a706ec7ba324857ebf1a51 --- /dev/null +++ b/webapi/fastapi_of_letcoing/services/question_service.py @@ -0,0 +1,56 @@ +from typing import Dict, Any, Optional, List +from interfaces.service_interfaces import IQuestionService +from models.db_models import Question +from models.question_models import QuestionResponse + +class QuestionService(IQuestionService): + """题目服务实现""" + + def create_question(self, data: Dict[str, Any]) -> Any: + """创建题目""" + question = Question.create( + title = data.get('title'), + intro = data.get('intro'), + difficulty = data.get('difficulty'), + testcode = data.get('testcode'), + lang = data.get('lang', 'cpp') + ) + return QuestionResponse.from_orm(question) + + def update_question(self, question_id: int, data: Dict[str, Any]) -> Optional[QuestionResponse]: + """更新题目""" + try: + question = Question.get_by_id(question_id) + + if 'title' in data: question.title = data['title'] + if 'intro' in data: question.intro = data['intro'] + if 'difficulty' in data: question.difficulty = data['difficulty'] + if 'testcode' in data: question.testcode = data['testcode'] + if 'lang' in data: question.lang = data['lang'] + + question.save() + return QuestionResponse.from_orm(question) + except Question.DoesNotExist: + return None + + def get_question(self, question_id: int) -> Optional[QuestionResponse]: + """获取一个题目""" + try: + question = Question.get_by_id(question_id) + return QuestionResponse.from_orm(question) + except Question.DoesNotExist: + return None + + def get_questions(self, skip: int = 0, limit: int = 100) -> List[QuestionResponse]: + """获取题目列表""" + questions = Question.select().offset(skip).limit(limit) + return [QuestionResponse.from_orm(q) for q in questions] + + def delete_question(self, question_id: int) -> bool: + """删除题目""" + try: + question = Question.get_by_id(question_id) + question.delete_instance() + return True + except Question.DoesNotExist: + return False \ No newline at end of file diff --git a/webapi/fastapi_of_letcoing/services/user_service.py b/webapi/fastapi_of_letcoing/services/user_service.py index 5c742d0a032389b9c9ee514e8b7fddb5604dac42..aa0821a0458ea735d4c3d2cde050f35c36ee3157 100644 --- a/webapi/fastapi_of_letcoing/services/user_service.py +++ b/webapi/fastapi_of_letcoing/services/user_service.py @@ -1,165 +1,137 @@ -""" -用户服务文件 -提供用户相关的数据库操作 -""" +"""User service with basic CRUD and authentication helpers.""" + +from __future__ import annotations + +import hashlib from datetime import datetime -from typing import List, Optional, Dict, Any +from typing import Any, Dict, List, Optional -from peewee import DoesNotExist, IntegrityError +from peewee import DoesNotExist -from services.database_service import DatabaseService -from models.db_models import User from core.di_container import Injectable +from models.db_models import User +from services.database_service import DatabaseService class UserService(DatabaseService, Injectable): - """用户服务类""" - - async def create_user(self, username: str, password_hash: str, email: Optional[str] = None) -> Dict[str, Any]: - """创建用户""" - try: - user_data = await self.create( - User, - username=username, - email=email, - password_hash=password_hash, - is_active=True - ) - return user_data - except ValueError as e: - if "username" in str(e): - raise ValueError("用户名已存在") - elif "email" in str(e): - raise ValueError("邮箱已存在") - else: - raise ValueError("创建用户失败") - except Exception as e: - raise RuntimeError(f"创建用户时发生错误: {e}") - + """User-related database operations.""" + + @staticmethod + def _hash_password(password: str) -> str: + return hashlib.sha256(password.encode("utf-8")).hexdigest() + + def _serialize_user(self, user: User, include_password: bool = False) -> Dict[str, Any]: + data = user.to_dict() + if include_password: + data["password_hash"] = user.password_hash + return data + + async def create_user(self, username: str, password: str, email: Optional[str] = None) -> Dict[str, Any]: + if await self.get_user_by_username(username): + raise ValueError("用户名已存在") + if email and await self.get_user_by_email(email): + raise ValueError("邮箱已存在") + + return await self.create( + User, + username=username, + email=email, + password_hash=self._hash_password(password), + is_active=True, + last_login=None, + ) + + async def register_user(self, user_data: Any) -> Dict[str, Any]: + data = user_data.model_dump() if hasattr(user_data, "model_dump") else dict(user_data) + return await self.create_user( + username=data["username"], + password=data["password"], + email=data.get("email"), + ) + async def get_user_by_id(self, user_id: int) -> Optional[Dict[str, Any]]: - """根据ID获取用户""" return await self.get_by_id(User, user_id) - + async def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]: - """根据用户名获取用户""" try: - user = User.get(User.username == username) - return user.to_dict() + return User.get(User.username == username).to_dict() except DoesNotExist: return None - except Exception as e: - raise RuntimeError(f"获取用户时发生错误: {e}") - + async def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]: - """根据邮箱获取用户""" try: - user = User.get(User.email == email) - return user.to_dict() + return User.get(User.email == email).to_dict() except DoesNotExist: return None - except Exception as e: - raise RuntimeError(f"获取用户时发生错误: {e}") - - async def update_user_last_login(self, user_id: int) -> bool: - """更新用户最后登录时间""" - return await self.update(User, user_id, last_login=datetime.now()) - + async def update_user_info(self, user_id: int, **kwargs) -> bool: - """更新用户信息""" - # 过滤允许更新的字段 - allowed_fields = {'username', 'email', 'is_active'} - update_data = {k: v for k, v in kwargs.items() if k in allowed_fields} - + allowed_fields = {"username", "email", "is_active"} + update_data = {key: value for key, value in kwargs.items() if key in allowed_fields and value is not None} if not update_data: return False - return await self.update(User, user_id, **update_data) - + + async def update_user_password(self, user_id: int, password: str) -> bool: + return await self.update(User, user_id, password_hash=self._hash_password(password)) + async def deactivate_user(self, user_id: int) -> bool: - """停用用户""" return await self.update(User, user_id, is_active=False) - + async def activate_user(self, user_id: int) -> bool: - """激活用户""" return await self.update(User, user_id, is_active=True) - - async def update_user_password(self, user_id: int, password_hash: str) -> bool: - """更新用户密码""" - return await self.update(User, user_id, password_hash=password_hash) - + + async def delete_user(self, user_id: int) -> bool: + return await self.delete(User, user_id) + + async def update_user_last_login(self, user_id: int) -> bool: + return await self.update(User, user_id, last_login=datetime.now()) + + async def update_last_login(self, user_id: int) -> bool: + return await self.update_user_last_login(user_id) + + async def update_user_status(self, user_id: int, is_active: bool) -> bool: + return await self.update(User, user_id, is_active=is_active) + async def list_users(self, limit: int = 50, offset: int = 0, active_only: bool = False) -> List[Dict[str, Any]]: - """获取用户列表""" - try: - query = User.select() - if active_only: - query = query.where(User.is_active == True) - - query = query.order_by(User.created_at.desc()).limit(limit).offset(offset) - - return [user.to_dict() for user in query] - except Exception as e: - raise RuntimeError(f"获取用户列表时发生错误: {e}") - - async def search_users(self, keyword: str, limit: int = 20) -> List[Dict[str, Any]]: - """搜索用户""" - try: - search_pattern = f"%{keyword}%" - query = (User.select() - .where((User.username.contains(search_pattern)) | - (User.email.contains(search_pattern))) - .limit(limit)) - - return [user.to_dict() for user in query] - except Exception as e: - raise RuntimeError(f"搜索用户时发生错误: {e}") - + query = User.select() + if active_only: + query = query.where(User.is_active == True) + query = query.order_by(User.created_at.desc()).limit(limit).offset(offset) + return [user.to_dict() for user in query] + async def count_users(self, active_only: bool = False) -> int: - """统计用户数量""" - try: - query = User.select() - if active_only: - query = query.where(User.is_active == True) - return query.count() - except Exception as e: - raise RuntimeError(f"统计用户数量时发生错误: {e}") - + query = User.select() + if active_only: + query = query.where(User.is_active == True) + return query.count() + async def get_user_with_password_hash(self, user_id: int) -> Optional[Dict[str, Any]]: - """获取包含密码哈希的用户信息(用于认证)""" try: user = User.get_by_id(user_id) - # 不排除密码哈希,用于认证 - user_dict = { - 'id': user.id, - 'username': user.username, - 'email': user.email, - 'password_hash': user.password_hash, - 'is_active': user.is_active, - 'last_login': user.last_login.isoformat() if user.last_login else None, - 'created_at': user.created_at.isoformat(), - 'updated_at': user.updated_at.isoformat() - } - return user_dict except DoesNotExist: return None - except Exception as e: - raise RuntimeError(f"获取用户认证信息时发生错误: {e}") - + return self._serialize_user(user, include_password=True) + async def get_user_with_password_hash_by_username(self, username: str) -> Optional[Dict[str, Any]]: - """根据用户名获取包含密码哈希的用户信息""" try: user = User.get(User.username == username) - user_dict = { - 'id': user.id, - 'username': user.username, - 'email': user.email, - 'password_hash': user.password_hash, - 'is_active': user.is_active, - 'last_login': user.last_login.isoformat() if user.last_login else None, - 'created_at': user.created_at.isoformat(), - 'updated_at': user.updated_at.isoformat() - } - return user_dict except DoesNotExist: return None - except Exception as e: - raise RuntimeError(f"获取用户认证信息时发生错误: {e}") \ No newline at end of file + return self._serialize_user(user, include_password=True) + + async def authenticate_user(self, identifier: str, password: str) -> Optional[Dict[str, Any]]: + user = await self.get_user_with_password_hash_by_username(identifier) + if not user: + user = await self.get_user_by_email(identifier) + if user and user.get("username"): + user = await self.get_user_with_password_hash_by_username(user["username"]) + + if not user or not user.get("is_active", False): + return None + + if self._hash_password(password) != user.get("password_hash"): + return None + + user.pop("password_hash", None) + return user + diff --git a/webapp/letapp/package.json b/webapp/letapp/package.json index 809a567dd7d035db6be9bc8438869e773517b3dc..f01a7ed438fdd859855e4fd5a8cf9c6bf3771a0c 100644 --- a/webapp/letapp/package.json +++ b/webapp/letapp/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "vue-tsc -b && vite build", + "typecheck": "vue-tsc -b --pretty false", "preview": "vite preview" }, "dependencies": { diff --git a/webapp/letapp/src/layouts/MainLayout.vue b/webapp/letapp/src/layouts/MainLayout.vue index 30cb98fc8b1d15e4a2102aaec7387dc36a2c9a5d..039d5ef102cd4cc747be7c955453bf38b101d305 100644 --- a/webapp/letapp/src/layouts/MainLayout.vue +++ b/webapp/letapp/src/layouts/MainLayout.vue @@ -22,18 +22,11 @@