From 4f34f5b8be583980db6989049e6d444b5fd174bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=83=9F=E9=9B=A8=E5=A6=82=E8=8A=B1?= <2324281453@qq.com> Date: Tue, 31 Dec 2024 22:27:04 +0800 Subject: [PATCH] init --- .gitignore | 48 ++++++ Dockerfile | 0 README.md | 263 ++++++++++++++++++++++++++++++ alembic.ini | 117 +++++++++++++ alembic/README | 1 + alembic/env.py | 78 +++++++++ alembic/script.py.mako | 26 +++ docker-compose.yml | 0 main.py | 32 ++++ mooc/__init__.py | 0 mooc/api/__init__.py | 0 mooc/api/deps.py | 62 +++++++ mooc/api/v1/__init__.py | 0 mooc/api/v1/api.py | 14 ++ mooc/api/v1/endpoints/__init__.py | 0 mooc/api/v1/endpoints/admin.py | 93 +++++++++++ mooc/api/v1/endpoints/auth.py | 0 mooc/api/v1/endpoints/wechat.py | 42 +++++ mooc/core/__init__.py | 0 mooc/core/config.py | 36 ++++ mooc/core/events.py | 0 mooc/core/security.py | 25 +++ mooc/crud/__init__.py | 0 mooc/crud/crud_account.py | 34 ++++ mooc/crud/crud_admin.py | 98 +++++++++++ mooc/db/__init__.py | 0 mooc/db/base.py | 5 + mooc/db/database.py | 29 ++++ mooc/db/session.py | 29 ++++ mooc/models/__init__.py | 0 mooc/models/account.py | 29 ++++ mooc/models/admin.py | 51 ++++++ mooc/schemas/__init__.py | 0 mooc/schemas/account.py | 37 +++++ mooc/schemas/admin.py | 78 +++++++++ mooc/utils/__init__.py | 0 mooc/utils/wechat_client.py | 54 ++++++ requirements.txt | 20 +++ tests/__init__.py | 0 tests/conftest.py | 0 40 files changed, 1301 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 docker-compose.yml create mode 100755 main.py create mode 100644 mooc/__init__.py create mode 100644 mooc/api/__init__.py create mode 100644 mooc/api/deps.py create mode 100644 mooc/api/v1/__init__.py create mode 100644 mooc/api/v1/api.py create mode 100644 mooc/api/v1/endpoints/__init__.py create mode 100644 mooc/api/v1/endpoints/admin.py create mode 100644 mooc/api/v1/endpoints/auth.py create mode 100644 mooc/api/v1/endpoints/wechat.py create mode 100644 mooc/core/__init__.py create mode 100644 mooc/core/config.py create mode 100644 mooc/core/events.py create mode 100644 mooc/core/security.py create mode 100644 mooc/crud/__init__.py create mode 100644 mooc/crud/crud_account.py create mode 100644 mooc/crud/crud_admin.py create mode 100644 mooc/db/__init__.py create mode 100644 mooc/db/base.py create mode 100644 mooc/db/database.py create mode 100644 mooc/db/session.py create mode 100644 mooc/models/__init__.py create mode 100644 mooc/models/account.py create mode 100644 mooc/models/admin.py create mode 100644 mooc/schemas/__init__.py create mode 100644 mooc/schemas/account.py create mode 100644 mooc/schemas/admin.py create mode 100644 mooc/utils/__init__.py create mode 100644 mooc/utils/wechat_client.py create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83fc3b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +__pycache__/ +*.py[cod] +*.class +*.so +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg +*.manifest +*.spec +pip-log.txt +pip-delete-this-directory.txt +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ +*.mo +*.pot +*.log +local_settings.py +instance/ +.webassets-cache +.env +.venv +venv/ +ENV/ +.vscode/ +.idea/ +*.swp +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..346c5f4 --- /dev/null +++ b/README.md @@ -0,0 +1,263 @@ +# ExamService + +题库小程序服务端 - FastAPI实现 + +## 项目结构 + +``` +ExamService/ +├── alembic/ # 数据库迁移相关 +├── mooc/ # 主应用目录 +│ ├── api/ # API路由 +│ │ └── v1/ # API v1版本 +│ ├── core/ # 核心配置 +│ ├── crud/ # 数据库操作 +│ ├── db/ # 数据库 +│ ├── models/ # 数据库模型 +│ ├── schemas/ # Pydantic模型 +│ └── utils/ # 工具函数 +├── tests/ # 测试目录 +└── [配置文件] +``` + + +## 安装 + +### 1. 安装 Miniconda + +在 Ubuntu 上安装 Miniconda,执行以下步骤: + +```bash +# 下载 Miniconda 安装脚本 +wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh + +# 给安装脚本添加执行权限 +chmod +x Miniconda3-latest-Linux-x86_64.sh + +# 运行安装脚本 +./Miniconda3-latest-Linux-x86_64.sh + +# 按照提示完成安装,选择安装目录并接受许可协议 + +# 激活 Miniconda +source ~/.bashrc +# 创建名为 'mooc' 的虚拟环境,Python 版本大于等于 3.13 +conda create --name mooc python>=3.13 +# 激活 'mooc' 环境 +conda activate mooc +# 安装依赖 +pip install -r requirements.txt +``` + +### 安装mysql开发库 + +```bash +# 更新包列表 +apt-get update + +# 安装 MySQL 开发包和其他必要的包 +apt-get install -y python3-dev default-libmysqlclient-dev build-essential pkg-config + + +pip install mysqlclient pymysql cryptography +``` + +## 运行 + +```bash +uvicorn main:app --reload +``` + +## 初始化数据库 + +```bash +alembic init alembic +alembic revision --autogenerate -m "Initial migration" +alembic upgrade head +``` + +## 添加数据库表 + +### 1.创建模型文件: + +**因为表格太多,可以把同类型表放在同一个文件,文件命名规则:ims_account_wechats、ims_account_wxapp、ims_account_xzapp等表格去除前缀和后缀,即为account.py** + +```python +# 这个模型类AccountWechats用于映射ims_account_wechats表: +# 1. 在ORM层中代表数据库表结构,方便CRUD操作。 +# 2. 在FastAPI中引用该模型时,可以使用Pydantic自动转换ORM对象到响应模型。 +# 3. 创建模型时需确保表名(__tablename__)与真实的数据库表名保持一致,否则查询和插入可能失败。 +# 4. 字段类型要与数据库中定义的列类型严格匹配,避免出现不兼容或异常。 +# 5. 通过Primary Key(acid)唯一标识记录,确保ORM能正确追踪和更新该对象。 +# 6. config.orm_mode = True:允许直接把SQLAlchemy模型实例转换为Pydantic模型对象。 + + +# filepath: ExamService/mooc/models/account.py +from sqlalchemy import Column, Integer, String, SmallInteger +from mooc.db.database import Base +# 类名命名规则: 去除ims前缀后采用 帕斯卡命名法,无连接符每个单词手写大写 +# AccountWechats模型用于映射数据库表 ims_account_wechats +class AccountWechats(Base): + __tablename__ = "ims_account_wechats" + + acid = Column(Integer, primary_key=True) + uniacid = Column(Integer, nullable=False) + token = Column(String(32), nullable=False) + encodingaeskey = Column(String(255), nullable=False) + level = Column(SmallInteger, nullable=False) + name = Column(String(30), nullable=False) + account = Column(String(30), nullable=False) + original = Column(String(50), nullable=False) + signature = Column(String(100), nullable=False) + country = Column(String(10), nullable=False) + province = Column(String(3), nullable=False) + city = Column(String(15), nullable=False) + username = Column(String(30), nullable=False) + password = Column(String(32), nullable=False) + lastupdate = Column(Integer, nullable=False) + key = Column(String(50), nullable=False) + secret = Column(String(50), nullable=False) + styleid = Column(Integer, nullable=False) + subscribeurl = Column(String(120), nullable=False) + auth_refresh_token = Column(String(255), nullable=False) + + class Config: + orm_mode = True # 允许与Pydantic的ORM功能兼容,直接读取数据库模型对象 +``` + +### 2.创建schema: +```python +# filepath: ExamService/mooc/schemas/account.py + +from pydantic import BaseModel +from typing import Optional + +class AccountWechatsBase(BaseModel): + """ + 数据模型基类: AccountWechatsBase + 用于描述基础字段的类型、用途和注意点。 + - 注意: 必填字段都在这里声明,每个字段都对应数据库中的一列。 + """ + uniacid: int # 微信公众号/小程序的关联ID + token: str # 访问接口时使用的token + encodingaeskey: str # 用于消息加解密的AES密钥 + level: int # 认证等级,如订阅号/服务号 + name: str # 微信公众号名称 + account: str # 公众号帐号,如微信号 + original: str # 原始ID(通常以gh_开头) + signature: str # 公众号信息签名 + country: str # 国家 + province: str # 省份 + city: str # 城市 + username: str # 后台登录用户名 + password: str # 后台登录密码 + lastupdate: int # 最后一次更新的时间戳 + key: str # 开发者Key + secret: str # 开发者Secret + styleid: int # 模板样式ID + subscribeurl: str # 订阅链接 + auth_refresh_token: str # 用来刷新授权的token + +class AccountWechatsCreate(AccountWechatsBase): + """ + 用于创建新微信帐号记录: + - 继承自AccountWechatsBase, 不额外添加字段 + - 仅表示此Schema专用于'创建'场景 + """ + + # 用于创建新记录,包含所有必填字段 + pass + +class AccountWechatsUpdate(BaseModel): + """ + 用于更新已有微信帐号记录: + - 只包含可选字段,未在此处的内容将保持不变 + - 注意: exclude_unset=True 可以避免更新空值 + """ + token: Optional[str] # 可选更新token + encodingaeskey: Optional[str]# 可选更新AES key + +class AccountWechats(AccountWechatsBase): + """ + 表示完整的微信帐号记录: + - acid: 数据库主键ID + - 包含所有字段的最终模型,ORM转换时使用 + """ + acid: int # 表中的主键ID + + class Config: + orm_mode = True # 允许与ORM对象进行直接转换 +``` + +### 3.创建 CRUD: +```python +# filepath: ExamService/mooc/crud/crud_account.py +from sqlalchemy.orm import Session +from typing import Optional + +from mooc.models.account import AccountWechats +from mooc.schemas.account import AccountWechatsCreate, AccountWechatsUpdate + +class CRUDAccountWechats: + """ + 创建CRUD + 负责对AccountWechats模型进行增删改查操作的逻辑封装。 + """ + + def create(self, db: Session, obj_in: AccountWechatsCreate) -> AccountWechats: + """ + 创建记录:将Pydantic的输入数据转换为数据库模型实例并保存。 + """ + db_obj = AccountWechats(**obj_in.dict()) # 将表单数据解包到模型实例 + db.add(db_obj) # 添加到会话 + db.commit() # 提交事务 + db.refresh(db_obj) # 刷新实例,获取数据库中的最新状态 + return db_obj + + def get(self, db: Session, acid: int) -> Optional[AccountWechats]: + """ + 根据主键 acid 查询单条数据。 + """ + return db.query(AccountWechats).filter(AccountWechats.acid == acid).first() + + def update( + self, db: Session, *, db_obj: AccountWechats, obj_in: AccountWechatsUpdate + ) -> AccountWechats: + """ + 更新记录:只修改传递进来的字段,未设置的字段不动。 + """ + for field, value in obj_in.dict(exclude_unset=True).items(): + setattr(db_obj, field, value) # 更新模型实例属性 + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def delete(self, db: Session, acid: int) -> None: + """ + 删除记录:物理删除或逻辑删除都可在此处实现。 + """ + obj = db.query(AccountWechats).filter(AccountWechats.acid == acid).first() + if obj: + db.delete(obj) + db.commit() + +# 实例化CRUD对象,方便在业务代码中直接引用 +account_wechats = CRUDAccountWechats() +``` +### 4.注册模型: +```python +# filepath: ExamService/mooc/db/base.py +from mooc.db.database import Base +from mooc.models.admin import Admin, Account, AccountWebapp +from mooc.models.account import AccountWechats # 新增,将创建好的模型类导入即可 +``` + +### 5.运行代码更新数据库信息 +```bash +python main.py +``` + +## API文档 + +启动服务后访问: http://localhost:2333/docs diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..a14c8f5 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,117 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +# version_path_separator = newline +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = mysql+pymysql://mooc:zXpPHKhE7A5x6X3A@localhost/mooc + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..36112a3 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,78 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100755 index 0000000..4b47837 --- /dev/null +++ b/main.py @@ -0,0 +1,32 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +import uvicorn +from mooc.api.v1.api import api_router +from mooc.core.config import settings +from mooc.db.database import init_db + +app = FastAPI( + title="ExamService", + description="题库小程序服务端API", + version="1.0.0" +) + +# CORS设置 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +# 初始化数据库 +init_db() + +app.include_router(api_router, prefix=settings.API_V1_STR) + +@app.get("/") +async def root(): + return {"message": "Welcome to ExamService API"} + +if __name__ == '__main__': + uvicorn.run('main:app', host='0.0.0.0', port=2333, reload=True, workers=1) \ No newline at end of file diff --git a/mooc/__init__.py b/mooc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mooc/api/__init__.py b/mooc/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mooc/api/deps.py b/mooc/api/deps.py new file mode 100644 index 0000000..c7f9e44 --- /dev/null +++ b/mooc/api/deps.py @@ -0,0 +1,62 @@ +from fastapi import Depends, HTTPException, status +from sqlalchemy.orm import Session +from mooc.db.session import get_db +from fastapi.security import OAuth2PasswordBearer +import jwt +from mooc.core.config import settings +from mooc.crud.crud_admin import admin +from mooc.models.admin import Admin + + + + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/login/access-token") + +# 获取当前用户的依赖 +def get_current_user( + db: Session = Depends(get_db), + token: str = Depends(oauth2_scheme) +) -> Admin: + """ + 验证当前用户的依赖项 + """ + try: + # PyJWT的解码方式 + payload = jwt.decode( + token, + settings.SECRET_KEY, + algorithms=[settings.ALGORITHM] + ) + username: str = payload.get("sub") + if not username: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + ) + except jwt.InvalidTokenError: # PyJWT的异常处理 + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + ) + + user = admin.get_by_username(db, username=username) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + return user + +# 验证超级管理员的依赖 +def get_current_superuser( + current_user: Admin = Depends(get_current_user), +) -> Admin: + """ + 验证超级管理员的依赖项 + """ + if current_user.is_delete != 0: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The user doesn't have enough privileges", + ) + return current_user \ No newline at end of file diff --git a/mooc/api/v1/__init__.py b/mooc/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mooc/api/v1/api.py b/mooc/api/v1/api.py new file mode 100644 index 0000000..c455633 --- /dev/null +++ b/mooc/api/v1/api.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter +from mooc.api.v1.endpoints import admin, wechat + +api_router = APIRouter() + +# עԱ·ɣǰ׺Ϊ/admins +api_router.include_router( + admin.admin_router, + prefix="/admins", + tags=["admins"] +) + +# Add WeChat endpoints +api_router.include_router(wechat.router, prefix="/wechat", tags=["wechat"]) \ No newline at end of file diff --git a/mooc/api/v1/endpoints/__init__.py b/mooc/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mooc/api/v1/endpoints/admin.py b/mooc/api/v1/endpoints/admin.py new file mode 100644 index 0000000..dc4d664 --- /dev/null +++ b/mooc/api/v1/endpoints/admin.py @@ -0,0 +1,93 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from mooc.api import deps +from mooc.crud.crud_admin import admin +from mooc.schemas.admin import Admin, AdminCreate, AdminUpdate + + +admin_router = APIRouter() +@admin_router.get("/", response_model=List[Admin]) +def read_admins( + db: Session = Depends(deps.get_db), + # 添加当前用户验证依赖 + current_user: Admin = Depends(deps.get_current_user), + skip: int = 0, + limit: int = 100,): + """ + 获取管理员列表 + 需要登录权限 + """ + admins = admin.get_multi(db, skip=skip, limit=limit) + return admins + +@admin_router.post("/", response_model=Admin) +def create_admin( + *, + db: Session = Depends(deps.get_db), + # 添加超级管理员验证依赖 + # current_user: Admin = Depends(deps.get_current_superuser), + admin_in: AdminCreate, +): + """ + 创建新管理员 + 需要超级管理员权限 + """ + admin_obj = admin.get_by_username(db, username=admin_in.username) + if admin_obj: + raise HTTPException( + status_code=400, + detail="Username already registered" + ) + return admin.create(db, obj_in=admin_in) + +@admin_router.put("/{id}", response_model=Admin) +def update_admin( + *, + db: Session = Depends(deps.get_db), + # 添加超级管理员验证依赖 + current_user: Admin = Depends(deps.get_current_superuser), + id: int, + admin_in: AdminUpdate, +): + """ + 更新管理员信息 + 需要超级管理员权限 + """ + admin_obj = admin.get(db, id=id) + if not admin_obj: + raise HTTPException( + status_code=404, + detail="Admin not found" + ) + return admin.update(db, db_obj=admin_obj, obj_in=admin_in) + +@admin_router.delete("/{id}", response_model=Admin) +def delete_admin( + *, + db: Session = Depends(deps.get_db), + # 添加超级管理员验证依赖 + current_user: Admin = Depends(deps.get_current_superuser), + id: int, +): + """ + 删除管理员 + 需要超级管理员权限 + """ + admin_obj = admin.get(db, id=id) + if not admin_obj: + raise HTTPException( + status_code=404, + detail="Admin not found" + ) + return admin.delete(db, id=id) + +# 添加获取当前用户信息的接口 +@admin_router.get("/me", response_model=Admin) +def read_user_me( + current_user: Admin = Depends(deps.get_current_user), +): + """ + 获取当前登录用户信息 + """ + return current_user \ No newline at end of file diff --git a/mooc/api/v1/endpoints/auth.py b/mooc/api/v1/endpoints/auth.py new file mode 100644 index 0000000..e69de29 diff --git a/mooc/api/v1/endpoints/wechat.py b/mooc/api/v1/endpoints/wechat.py new file mode 100644 index 0000000..89926a2 --- /dev/null +++ b/mooc/api/v1/endpoints/wechat.py @@ -0,0 +1,42 @@ +from fastapi import APIRouter, Depends, HTTPException +from typing import Dict, Any +from mooc.utils.wechat_client import WeChatClient + +router = APIRouter() +wechat_client = WeChatClient() + +@router.get("/access_token") +async def get_access_token() -> Dict[str, Any]: + """获取微信access token""" + try: + access_token = await wechat_client.get_access_token() + return {"access_token": access_token} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.post("/code2session") +async def code_to_session(code: str) -> Dict[str, Any]: + """小程序登录""" + try: + result = await wechat_client.code2session(code) + return result + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.post("/qrcode") +async def generate_qrcode(scene: str) -> bytes: + """生成小程序码""" + try: + qr_code = await wechat_client.get_unlimited_qrcode(scene) + return qr_code + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.post("/send_template") +async def send_template_message(data: Dict[str, Any]) -> Dict[str, Any]: + """发送订阅消息""" + try: + result = await wechat_client.send_template_message(data) + return result + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) \ No newline at end of file diff --git a/mooc/core/__init__.py b/mooc/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mooc/core/config.py b/mooc/core/config.py new file mode 100644 index 0000000..decd6ab --- /dev/null +++ b/mooc/core/config.py @@ -0,0 +1,36 @@ +from pydantic_settings import BaseSettings +from typing import Optional + +class Settings(BaseSettings): + PROJECT_NAME: str = "ExamService" + VERSION: str = "1.0.0" + API_V1_STR: str = "/api/v1" + + + MYSQL_USER: str = "mooc" + MYSQL_PASSWORD: str = "zXpPHKhE7A5x6X3A" + MYSQL_HOST: str = "localhost" + MYSQL_PORT: str = "3306" + MYSQL_DATABASE: str = "mooc" + + SQLALCHEMY_DATABASE_URI: Optional[str] = None + SQLALCHEMY_ECHO: bool = True + + SECRET_KEY: str = "your-secret-key-here" # ����ʹ�ø��ӵ�����ַ��� + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + + WECHAT_APPID: str = "" + WECHAT_APPSECRET: str = "" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.SQLALCHEMY_DATABASE_URI = ( + f"mysql+pymysql://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}" + f"@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DATABASE}" + ) + + class Config: + env_file = ".env" + +settings = Settings() \ No newline at end of file diff --git a/mooc/core/events.py b/mooc/core/events.py new file mode 100644 index 0000000..e69de29 diff --git a/mooc/core/security.py b/mooc/core/security.py new file mode 100644 index 0000000..a0e3703 --- /dev/null +++ b/mooc/core/security.py @@ -0,0 +1,25 @@ +from datetime import datetime, timedelta +from typing import Optional +import jwt +from mooc.core.config import settings + +def create_access_token( + subject: str, expires_delta: Optional[timedelta] = None +) -> str: + """ + + """ + if expires_delta: + expire = datetime.now(datetime.timezone.utc) + expires_delta + else: + expire = datetime.now(datetime.timezone.utc) + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode( + to_encode, + settings.SECRET_KEY, + algorithm=settings.ALGORITHM + ) + return encoded_jwt \ No newline at end of file diff --git a/mooc/crud/__init__.py b/mooc/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mooc/crud/crud_account.py b/mooc/crud/crud_account.py new file mode 100644 index 0000000..451cd04 --- /dev/null +++ b/mooc/crud/crud_account.py @@ -0,0 +1,34 @@ +from sqlalchemy.orm import Session +from typing import Optional + +from mooc.models.account import AccountWechats +from mooc.schemas.account import AccountWechatsCreate, AccountWechatsUpdate + +class CRUDAccountWechats: + def create(self, db: Session, obj_in: AccountWechatsCreate) -> AccountWechats: + db_obj = AccountWechats(**obj_in.dict()) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def get(self, db: Session, acid: int) -> Optional[AccountWechats]: + return db.query(AccountWechats).filter(AccountWechats.acid == acid).first() + + def update( + self, db: Session, *, db_obj: AccountWechats, obj_in: AccountWechatsUpdate + ) -> AccountWechats: + for field, value in obj_in.dict(exclude_unset=True).items(): + setattr(db_obj, field, value) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def delete(self, db: Session, acid: int) -> None: + obj = db.query(AccountWechats).filter(AccountWechats.acid == acid).first() + if obj: + db.delete(obj) + db.commit() + +account_wechats = CRUDAccountWechats() \ No newline at end of file diff --git a/mooc/crud/crud_admin.py b/mooc/crud/crud_admin.py new file mode 100644 index 0000000..c71fe9a --- /dev/null +++ b/mooc/crud/crud_admin.py @@ -0,0 +1,98 @@ +from typing import List, Optional +from sqlalchemy.orm import Session +from mooc.models.admin import Admin,Account,AccountWebapp +from mooc.schemas.admin import AdminCreate, AdminUpdate,AccountCreate, AccountUpdate, AccountWebappCreate, AccountWebappUpdate + + +class CRUDAdmin: + def get(self, db: Session, id: int) -> Optional[Admin]: + return db.query(Admin).filter(Admin.id == id).first() + + def get_by_username(self, db: Session, username: str) -> Optional[Admin]: + return db.query(Admin).filter(Admin.username == username).first() + + def get_multi( + self, db: Session, *, skip: int = 0, limit: int = 100 + ) -> List[Admin]: + return db.query(Admin).offset(skip).limit(limit).all() + + def create(self, db: Session, *, obj_in: AdminCreate) -> Admin: + db_obj = Admin( + weid=obj_in.weid, + username=obj_in.username, + password=obj_in.password, # ע�⣺ʵ��ʹ��ʱӦ�ö�������й�ϣ����? + pcate_id=obj_in.pcate_id, + cate_id=obj_in.cate_id, + relation_id=obj_in.relation_id, + is_delete=obj_in.is_delete + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def update( + self, db: Session, *, db_obj: Admin, obj_in: AdminUpdate + ) -> Admin: + update_data = obj_in.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_obj, field, value) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def delete(self, db: Session, *, id: int) -> Admin: + obj = db.query(Admin).get(id) + if obj: + obj.is_delete = 1 + db.add(obj) + db.commit() + return obj + +admin = CRUDAdmin() + + + +class CRUDAccount: + def get_by_uniacid(self, db: Session, *, uniacid: int) -> Optional[Account]: + return db.query(Account).filter(Account.uniacid == uniacid).first() + +account = CRUDAccount() + + + + +class CRUDAccountWebapp: + def create(self, db: Session, *, obj_in: AccountWebappCreate) -> AccountWebapp: + db_obj = AccountWebapp(**obj_in.dict()) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def get(self, db: Session, acid: int) -> Optional[AccountWebapp]: + return db.query(AccountWebapp).filter(AccountWebapp.acid == acid).first() + + def update( + self, + db: Session, + *, + db_obj: AccountWebapp, + obj_in: AccountWebappUpdate + ) -> AccountWebapp: + data = obj_in.dict(exclude_unset=True) + for field, value in data.items(): + setattr(db_obj, field, value) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def delete(self, db: Session, acid: int) -> None: + obj = db.query(AccountWebapp).filter(AccountWebapp.acid == acid).first() + if obj: + db.delete(obj) + db.commit() + +account_webapp = CRUDAccountWebapp() \ No newline at end of file diff --git a/mooc/db/__init__.py b/mooc/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mooc/db/base.py b/mooc/db/base.py new file mode 100644 index 0000000..362b548 --- /dev/null +++ b/mooc/db/base.py @@ -0,0 +1,5 @@ +from mooc.db.database import Base + +# ģעᵽBase.metadata +from mooc.models.admin import Admin, Account, AccountWebapp +from mooc.models.account import AccountWechats \ No newline at end of file diff --git a/mooc/db/database.py b/mooc/db/database.py new file mode 100644 index 0000000..70d1a47 --- /dev/null +++ b/mooc/db/database.py @@ -0,0 +1,29 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from mooc.core.config import settings + +# 创建数据库引擎 +engine = create_engine( + settings.SQLALCHEMY_DATABASE_URI, + pool_pre_ping=True, + echo=settings.SQLALCHEMY_ECHO +) + +# 创建会话工厂 +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# 创建基类 +Base = declarative_base() + +def init_db(): + """初始化数据库""" + Base.metadata.create_all(bind=engine) + +def get_db(): + """获取数据库会话""" + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/mooc/db/session.py b/mooc/db/session.py new file mode 100644 index 0000000..322c84f --- /dev/null +++ b/mooc/db/session.py @@ -0,0 +1,29 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from mooc.core.config import settings +from typing import Generator +from mooc.db.base import Base + +# 创建数据库引擎 +engine = create_engine( + settings.SQLALCHEMY_DATABASE_URI, + pool_pre_ping=True, + echo=settings.SQLALCHEMY_ECHO +) + +# 创建表 +def init_db(): + Base.metadata.create_all(bind=engine) + +# 创建会话工厂 +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +def get_db() -> Generator: + """ + 获取数据库会话的依赖项 + """ + try: + db = SessionLocal() + yield db + finally: + db.close() \ No newline at end of file diff --git a/mooc/models/__init__.py b/mooc/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mooc/models/account.py b/mooc/models/account.py new file mode 100644 index 0000000..c6881b2 --- /dev/null +++ b/mooc/models/account.py @@ -0,0 +1,29 @@ +from sqlalchemy import Column, Integer, String, SmallInteger +from mooc.db.database import Base + +class AccountWechats(Base): + __tablename__ = "ims_account_wechats" + + acid = Column(Integer, primary_key=True) + uniacid = Column(Integer, nullable=False) + token = Column(String(32), nullable=False) + encodingaeskey = Column(String(255), nullable=False) + level = Column(SmallInteger, nullable=False) + name = Column(String(30), nullable=False) + account = Column(String(30), nullable=False) + original = Column(String(50), nullable=False) + signature = Column(String(100), nullable=False) + country = Column(String(10), nullable=False) + province = Column(String(3), nullable=False) + city = Column(String(15), nullable=False) + username = Column(String(30), nullable=False) + password = Column(String(32), nullable=False) + lastupdate = Column(Integer, nullable=False) + key = Column(String(50), nullable=False) + secret = Column(String(50), nullable=False) + styleid = Column(Integer, nullable=False) + subscribeurl = Column(String(120), nullable=False) + auth_refresh_token = Column(String(255), nullable=False) + + class Config: + orm_mode = True \ No newline at end of file diff --git a/mooc/models/admin.py b/mooc/models/admin.py new file mode 100644 index 0000000..138e155 --- /dev/null +++ b/mooc/models/admin.py @@ -0,0 +1,51 @@ +from sqlalchemy import Column, Integer, String, Text, SmallInteger +from sqlalchemy.sql import func + +import time +from mooc.db.database import Base + + +class Admin(Base): + __tablename__ = "ims_goouc_fullexam_admin" + + id = Column(Integer, primary_key=True, autoincrement=True) + weid = Column(String(150), nullable=False) + username = Column(String(255), nullable=False) + password = Column(String(255), nullable=False) + createtime = Column(Integer, nullable=False, default=lambda: int(time.time())) + logintime = Column(Integer, nullable=False, default=lambda: int(time.time())) + is_delete = Column(SmallInteger, nullable=False, default=1, comment='0正常 1禁用') + pcate_id = Column(Integer, nullable=False, comment='一级类目id') + cate_id = Column(Integer, nullable=False, comment='二级级类目id') + relation_id = Column(Text, nullable=False, comment='关联试卷ID') + + class Config: + orm_mode = True + +class Account(Base): + __tablename__ = "ims_account" + + acid = Column(Integer, primary_key=True, autoincrement=True) + uniacid = Column(Integer, nullable=False, index=True) + hash = Column(String(8), nullable=False) + type = Column(SmallInteger, nullable=False) + isconnect = Column(SmallInteger, nullable=False) + isdeleted = Column(SmallInteger, nullable=False) + endtime = Column(Integer, nullable=False) + send_account_expire_status = Column(SmallInteger, nullable=False) + send_api_expire_status = Column(SmallInteger, nullable=False) + + class Config: + orm_mode = True + + + +class AccountWebapp(Base): + __tablename__ = "ims_account_webapp" + + acid = Column(Integer, primary_key=True) + uniacid = Column(Integer, index=True) + name = Column(String(255)) + + class Config: + orm_mode = True \ No newline at end of file diff --git a/mooc/schemas/__init__.py b/mooc/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mooc/schemas/account.py b/mooc/schemas/account.py new file mode 100644 index 0000000..fff5168 --- /dev/null +++ b/mooc/schemas/account.py @@ -0,0 +1,37 @@ +from pydantic import BaseModel +from typing import Optional + +class AccountWechatsBase(BaseModel): + uniacid: int + token: str + encodingaeskey: str + level: int + name: str + account: str + original: str + signature: str + country: str + province: str + city: str + username: str + password: str + lastupdate: int + key: str + secret: str + styleid: int + subscribeurl: str + auth_refresh_token: str + +class AccountWechatsCreate(AccountWechatsBase): + pass + +class AccountWechatsUpdate(BaseModel): + token: Optional[str] + encodingaeskey: Optional[str] + + +class AccountWechats(AccountWechatsBase): + acid: int + + class Config: + orm_mode = True \ No newline at end of file diff --git a/mooc/schemas/admin.py b/mooc/schemas/admin.py new file mode 100644 index 0000000..33f77a0 --- /dev/null +++ b/mooc/schemas/admin.py @@ -0,0 +1,78 @@ +from typing import Optional +from pydantic import BaseModel +from datetime import datetime + +# Adminģ +class AdminBase(BaseModel): + weid: str + username: str + pcate_id: int + cate_id: int + relation_id: str + is_delete: int = 1 + +# Adminʱģ +class AdminCreate(AdminBase): + password: str + +# Adminʱģ +class AdminUpdate(BaseModel): + weid: Optional[str] = None + username: Optional[str] = None + password: Optional[str] = None + pcate_id: Optional[int] = None + cate_id: Optional[int] = None + relation_id: Optional[str] = None + is_delete: Optional[int] = None + +# AdminӦģ +class Admin(AdminBase): + id: int + createtime: int + logintime: int + + class Config: + orm_mode = True + + +class AccountBase(BaseModel): + uniacid: int + hash: str + type: int + isconnect: int + isdeleted: int + endtime: int + send_account_expire_status: int + send_api_expire_status: int + +class AccountCreate(AccountBase): + pass + +class AccountUpdate(AccountBase): + pass + +class Account(AccountBase): + acid: int + + class Config: + orm_mode = True + + + + + +class AccountWebappBase(BaseModel): + uniacid: Optional[int] + name: Optional[str] + +class AccountWebappCreate(AccountWebappBase): + pass + +class AccountWebappUpdate(AccountWebappBase): + pass + +class AccountWebapp(AccountWebappBase): + acid: int + + class Config: + orm_mode = True \ No newline at end of file diff --git a/mooc/utils/__init__.py b/mooc/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mooc/utils/wechat_client.py b/mooc/utils/wechat_client.py new file mode 100644 index 0000000..cde7a25 --- /dev/null +++ b/mooc/utils/wechat_client.py @@ -0,0 +1,54 @@ +import json +import httpx +from typing import Optional, Dict, Any +from mooc.core.config import settings + +class WeChatClient: + def __init__(self, appid: str = None, appsecret: str = None): + self.appid = appid or settings.WECHAT_APPID + self.appsecret = appsecret or settings.WECHAT_APPSECRET + self._access_token = "" + + async def _get(self, url: str) -> Dict[str, Any]: + async with httpx.AsyncClient() as client: + response = await client.get(url, verify=False) + return response.json() + + async def _post(self, url: str, data: Dict[str, Any]) -> Dict[str, Any]: + async with httpx.AsyncClient() as client: + response = await client.post(url, json=data, verify=False) + return response.json() + + async def get_access_token(self) -> str: + """获取access token""" + url = f"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={self.appid}&secret={self.appsecret}" + result = await self._get(url) + if "access_token" in result: + self._access_token = result["access_token"] + return self._access_token + raise Exception(f"Failed to get access token: {result}") + + async def code2session(self, code: str) -> Dict[str, Any]: + """小程序登录凭证校验""" + url = f"https://api.weixin.qq.com/sns/jscode2session?appid={self.appid}&secret={self.appsecret}&js_code={code}&grant_type=authorization_code" + result = await self._get(url) + if "errcode" in result and result["errcode"] != 0: + raise Exception(f"Code2Session failed: {result['errmsg']}") + return result + + async def get_unlimited_qrcode(self, scene: str, access_token: str = None) -> bytes: + """获取小程序码""" + if not access_token: + access_token = await self.get_access_token() + url = f"https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={access_token}" + data = {"scene": scene} + async with httpx.AsyncClient() as client: + response = await client.post(url, json=data, verify=False) + return response.content + + async def send_template_message(self, data: Dict[str, Any], access_token: str = None) -> Dict[str, Any]: + """发送订阅消息""" + if not access_token: + access_token = await self.get_access_token() + url = f"https://api.weixin.qq.com/cgi-bin/message/wxopen/template/send?access_token={access_token}" + return await self._post(url, data) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f348ba3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +passlib[bcrypt] +alembic>=1.7.1 +httpx>=0.19.0 +redis>=4.0.2 +python-dotenv>=0.19.0 +sqlalchemy>=2.0.36 +fastapi>=0.115.6 +pyjwt>=2.10.1 +requests>=2.32.3 +pydantic>=2.10.4 +passlib>=1.7.4 +uvicorn>=0.34.0 +pytest>=7.4.4 +Pillow>=11.0.0 +numpy>=2.2.1 +python-multipart>=0.0.20 +pymysql>=1.1.1 +cryptography>=42.0.5 +mysqlclient>=2.2.6 +pydantic-settings>=2.7.0 \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e69de29