├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── app ├── __init__.py └── ws │ ├── __init__.py │ ├── apis.py │ ├── models.py │ ├── schema.py │ ├── views.py │ └── ws.py ├── core ├── config │ ├── __init__.py │ ├── base_config.py │ ├── common.py │ ├── dev_config.py │ └── pro_config.py ├── dependencies │ ├── __init__.py │ ├── auth.py │ └── base.py ├── i18n │ ├── README.md │ ├── __init__.py │ ├── babel.cfg │ ├── en_US │ │ └── LC_MESSAGES │ │ │ └── messages.po │ └── zh_CN │ │ └── LC_MESSAGES │ │ └── messages.po ├── manager │ ├── __init__.py │ ├── babel_cmd.py │ ├── base.py │ └── gen_code.py ├── middleware │ ├── __init__.py │ ├── fastapi_globals.py │ └── session.py ├── schema │ ├── __init__.py │ ├── base.py │ └── paginate.py ├── storage │ ├── __init__.py │ ├── db.py │ └── model.py └── utils │ ├── api_exception.py │ └── enums.py ├── main.py ├── manage.py └── requirements.txt /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .venv 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | .hypothesis/ 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | local_settings.py 54 | 55 | # Flask stuff: 56 | #instance/ 57 | .webassets-cache 58 | 59 | # Scrapy stuff: 60 | .scrapy 61 | 62 | # Sphinx documentation 63 | docs/_build/ 64 | 65 | # PyBuilder 66 | target/ 67 | 68 | # IPython Notebook 69 | .ipynb_checkpoints 70 | 71 | # pyenv 72 | .python-version 73 | 74 | # celery beat schedule file 75 | celerybeat-schedule 76 | 77 | # dotenv 78 | .env 79 | 80 | # virtualenv 81 | venv/ 82 | ENV/ 83 | 84 | # Spyder project settings 85 | .spyderproject 86 | 87 | # Rope project settings 88 | .ropeproject 89 | 90 | .idea 91 | .venv 92 | .vscode 93 | __pycache__ 94 | .pytest_cache/v/cache 95 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8.13 2 | 3 | RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 4 | 5 | RUN apt-get update && apt-get -y install openssh-server vim apt-utils && rm -rf /var/lib/apt/lists/* 6 | 7 | 8 | WORKDIR /usr/src/app 9 | ENV PYTHONPATH /usr/src/app 10 | 11 | 12 | COPY requirements.txt /usr/src/app/requirements.txt 13 | 14 | RUN pip3 install --upgrade pip -i https://pypi.douban.com/simple 15 | RUN pip3 install -i https://pypi.douban.com/simple -r requirements.txt 16 | 17 | COPY . /usr/src/app 18 | 19 | EXPOSE 8001 20 | ENV TZ=Asia/Shanghai 21 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo '$TZ' > /etc/timezone && rm -rf /root/.cache/pip 22 | 23 | CMD ["python3", "/usr/src/main.py"] 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 简介 2 | 3 | 这是一个`fastapi`的脚手架项目,解释器版本至少是python3.7 +,项目集成了`sqlalchemy2(1.4+)`、`JWT Auth`、`websocket`、`i18n`等常用功能,项目的目录结构也比较简单,也封装了一系列的web开发过程中会用到的工具,欢迎大家给项目提提建议。 4 | 5 | ## 目录结构 6 | 7 | ```shell 8 | . 9 | ├── app # 业务应用目录,下属多个应用模块 10 | │ ├── __init__.py 11 | │ ├── demo # 应用模块demo 12 | │ │ ├── apis.py # 路由定义 13 | │ │ ├── demo.py # 模块工具方法定义 14 | │ │ ├── models.py # ORM model类定义 15 | │ │ ├── schema.py # 序列化类定义 16 | │ │ └── views.py # 视图函数定义 17 | │ └── ws # websocket模块 18 | ├── core # 项目核心 19 | │ ├── config # 项目配置文件 20 | │ ├── dependencies # fastapi依赖定义 21 | │ ├── i18n # 国际化翻译 22 | │ ├── manager # manager工具 23 | │ ├── middleware # ASGI中间件定义 24 | │ ├── schema # 基础schema 25 | │ ├── storage # SQLA相关 26 | │ └── utils # 工具目录 27 | ├── docs # 项目文档 28 | ├── main.py # 入口文件 29 | ├── manage.py # manager工具 30 | ├── Dockerfile # 就是一个DOckerfile 31 | ├── README.md 32 | └── requirements.txt # 依赖文件 33 | ``` 34 | 35 | ## 主要功能模块 36 | 37 | ### 1. SQLAlchemy 38 | 39 | `1.4+`的版本,这个版本支持异步IO,但是相应的得使用异步的数据库适配器,这里使用的是`aiomysql`, 同时1.4版本已经支持SQLA的2.0写法,当然也是兼容旧版写法的。[两者区别具体查看文档](https://docs.sqlalchemy.org/en/14/changelog/migration_20.html) 40 | 41 | 与SQLA相关的代码在`/core/storage`下: 42 | 43 | - `model.py`: 定义了ORM表结构映射基础对象`Base`, 集成它来关联db中的表。 44 | - `db.py`: 初始化SQLA,添加一些监听事件。 45 | 46 | 应用代码中引入`session`可以直接从全局对象`g`中获取: 47 | 48 | ```shell 49 | from core.middleware import g 50 | g.db.execute(...) 51 | ``` 52 | 53 | 获取`engine`对象 54 | 55 | ```python 56 | from core.storage import db 57 | db.engine.execute(...) 58 | ``` 59 | 60 | 逻辑删除,orm对象 61 | ```python 62 | obj.delete() 63 | ``` 64 | 65 | ### 2.dependencies 66 | 67 | 项目下所有的fastapi依赖注入函数都在`/core/dependencies`: 68 | 69 | - base_dependen: 基础依赖注入,所有的AP都需要引入这个依赖,用于提取请求头中的`Accept-Language`来设置全局变量`local`, 用于`i18n`的翻译。 70 | - auth_dependen: 基于`Authorization`请求头的JWT认证。 71 | 72 | ### 3. config 73 | 74 | 项目的配置文件目录,分为两种环境配置文件`dev`和`prod`。启动项目时引入哪个配置文件取决于环境变量配置`CONFIG_NAME`,默认是`dev`。 75 | 76 | 已有配置: 77 | 78 | - CONFIG_NAME: 引入配置文件类型 79 | - SERVICE_NAME:项目名称 80 | - TZ:时区 81 | - TOKEN_SECRET_KEY:JWT密钥 82 | - DATABASE_MYSQL_URL:数据库DSN 83 | - SHOW_SQL:是否打开SQLA日志 84 | - RETURN_SQL:是否在接口返回SQL记录 85 | - DB_POOL_RECYCLE:数据库连接释放事件 86 | - DB_MAX_OVERFLOW:连接池缓冲个数 87 | - DB_POOL_SIZE: 连接池大小 88 | 89 | ### 4. schema 90 | 91 | 项目的序列化和反序列化是基于pydantic实现的,业务应用需要继承`BaseSchema`来扩展各自需要的序列化器。提供两种序列化器: 92 | 93 | - DataSchema: 普通序列化器 94 | - PageSchema: 分页序列化器 95 | 96 | 还提供了数据分页器:`paginate_handler`,可以接收`select`对象或迭代对象,返回分页后的数据字典。 97 | 98 | ### 5. i18n 99 | 100 | 创建翻译文件在后面的`manager.py`里会有介绍,如果是已有的翻译文件直接放入`/core/i18n/lang`里。同时,项目里需要使用翻译的地方可以如下使用,具体翻译成何种语言取决于全局变量`g.locale` 101 | 102 | ```python 103 | from core.i18n import gettext 104 | t = gettext("test") # t为翻译后的内容 105 | ``` 106 | 107 | ### 6. manager.py 108 | 109 | 项目下有一个类似`django`的项目管理工具,现在提供了两个命令: 110 | 111 | 1. startapp [appname] 112 | 113 | 在`/app`下创建一个标准的应用目录,且实现了一套增删改查的代码 114 | 115 | 2. babel [add|run] 116 | 117 | **add**: 检测代码中的引入了`gettext`的地方,并更新翻译文件`message.po`, 之后需要手动填入翻译内容。 118 | 119 | **run**: 编译message.po。 120 | 121 | ### 7. middleware 122 | 123 | 项目下的`/core/middleware`是所有的ASGI的中间件,目前共有两个中间件 124 | 125 | - `fastapi-globals.py`: `g`对象的实现,基于contextvar 126 | - `session.py`: 注册全局`session` 127 | 128 | #### 8.websocket 129 | 130 | 项目自带了websocket应用,实现了ws连接管理类`ConnectionManager`。目前是将连接信息存储在内存中,第一次连接时需要进行token验证,之后服务端会定时进行心跳检测,所以需要客户端隔一段时间就发送心跳包,否则服务端会主动断开连接。 131 | 132 | token验证格式: 133 | 134 | ```json 135 | { 136 | "Op": "bind", 137 | "SessionID": "token" 138 | } 139 | ``` 140 | 141 | 心跳检测包: 142 | 143 | ```json 144 | { 145 | "Op": "heartbeat", 146 | "SessionID": "token" 147 | } 148 | ``` 149 | 150 | #### 9.部署 151 | 152 | 下载依赖:`pip3 install -r quirements.txt` 153 | 启动项目:`python main.py` 154 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goofy-z/fastapi-web-template/9b842ae0563f9f5d9b8f72849ed3a7fe2b88a4f6/app/__init__.py -------------------------------------------------------------------------------- /app/ws/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from app.ws.ws import ConnectionManager 4 | 5 | manager = ConnectionManager() 6 | 7 | 8 | async def create_ws_manager(): 9 | manager.start() 10 | -------------------------------------------------------------------------------- /app/ws/apis.py: -------------------------------------------------------------------------------- 1 | from app.ws.schema import SockJSInfoRes 2 | from fastapi.applications import FastAPI 3 | from app.ws.views import broadcast_msg_view, ws_view, test_html_view 4 | from fastapi import APIRouter 5 | 6 | 7 | router = APIRouter(tags=["websocket"]) 8 | 9 | router.add_api_websocket_route( 10 | name="websocket连接", 11 | path="/{client_id}/{session}/{transport}", 12 | endpoint=ws_view, 13 | ) 14 | 15 | 16 | router.add_api_route( 17 | name="websocket测试", 18 | path="/test.html", 19 | endpoint=test_html_view, 20 | ) 21 | 22 | 23 | router.add_api_route(name="群推消息", path="/wss/broadcast_msg", methods=["POST"], endpoint=broadcast_msg_view) 24 | -------------------------------------------------------------------------------- /app/ws/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from app.models.base import Base 4 | from sqlalchemy import Column, String 5 | 6 | 7 | class Demo(Base): 8 | __tablename__ = "demos" 9 | id = Column(String(36), primary_key=True, default=str(uuid.uuid4)) 10 | name = Column(String(128), nullable=False, primary_key=True, unique=True) 11 | description = Column(String(512), nullable=False) 12 | -------------------------------------------------------------------------------- /app/ws/schema.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from pydantic.fields import Field 4 | from core.schema import BaseSchema 5 | 6 | 7 | class SockJSInfoRes(BaseSchema): 8 | cookie_needed: bool = False 9 | entropy: int 10 | origins: List[str] = ["*:*"] 11 | websocket: bool = True 12 | 13 | 14 | class BroadcastMsgReq(BaseSchema): 15 | msg: str = Field(description="推送消息内容") 16 | -------------------------------------------------------------------------------- /app/ws/views.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from typing import Optional 4 | from fastapi import WebSocket 5 | from starlette.responses import HTMLResponse 6 | from starlette.websockets import WebSocketDisconnect 7 | from app.ws.schema import BroadcastMsgReq 8 | 9 | from app.ws import manager 10 | from app.ws.ws import html 11 | 12 | 13 | async def ws_view( 14 | websocket: WebSocket, client_id: int, session: Optional[str] = "qeqe", transport: Optional[str] = "websocket" 15 | ): 16 | """ 17 | websocket服务 18 | """ 19 | res = await manager.connect_with_token(websocket) 20 | if not res: 21 | return 22 | try: 23 | while True: 24 | await manager.receive_json(websocket) 25 | except WebSocketDisconnect: 26 | manager.remove_session(websocket) 27 | 28 | 29 | async def test_html_view(): 30 | """ 31 | 测试html 32 | """ 33 | return HTMLResponse(html) 34 | 35 | 36 | async def sockjs_info_view(t: Optional[int]): 37 | """ 38 | 伪造sockjs的info接口 39 | """ 40 | data = { 41 | "entropy": random.randint(1, 2147483647), 42 | } 43 | return data 44 | 45 | 46 | async def broadcast_msg_view(req: BroadcastMsgReq): 47 | """ 48 | 广播消息 49 | """ 50 | msg = req.msg 51 | await manager.broadcast(req.msg) 52 | return "success" 53 | -------------------------------------------------------------------------------- /app/ws/ws.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | import jwt 5 | import asyncio 6 | from typing import Dict, List 7 | from datetime import datetime, timedelta 8 | 9 | from fastapi import FastAPI, WebSocket, WebSocketDisconnect 10 | 11 | app = FastAPI(debug=True) 12 | 13 | LOG = logging.getLogger(__name__) 14 | 15 | 16 | class ConnectionManager: 17 | _hb_handle = None # heartbeat event loop timer 18 | _hb_task = None # gc task 19 | 20 | def __init__(self): 21 | self.active_connections: Dict[List[WebSocket]] = [] 22 | self.heartbeat = 10 23 | 24 | async def connect(self, session: WebSocket): 25 | await session.accept() 26 | self.active_connections.append(session) 27 | 28 | async def connect_with_token(self, session: WebSocket): 29 | """ 30 | 需要token创建ws连接 31 | """ 32 | await session.accept() 33 | # 恢复一条消息代表接收到请求 34 | await self.send_personal_message("o", session) 35 | 36 | # 初始化连接时加上心跳检查时间 37 | self.flush_session_expire_time(session) 38 | 39 | # if self._hb_task: 40 | # print(self._hb_task.result()) 41 | self.active_connections.append(session) 42 | 43 | # 第一次消息必须发送token过来 44 | try: 45 | token = await session.receive_json() 46 | if isinstance(token, list): 47 | token = json.loads(token[0]) 48 | elif not isinstance(token, dict): 49 | token = json.loads(token) 50 | except WebSocketDisconnect: 51 | return 52 | except Exception as e: 53 | await self.send_personal_message(f"token error", session) 54 | await self.disconnect(session) 55 | return False 56 | 57 | if token.get("Op") == "bind": 58 | try: 59 | sessionID = token.get("SessionID") 60 | payload = jwt.decode( 61 | sessionID.encode("utf-8"), "31e37738-559a-47f3-8b16-5fa27a2ed410", algorithms="HS256" 62 | ) 63 | user_id = payload.get("user_id") 64 | setattr(session, "user_id", user_id) 65 | # 再次刷新失效时间 66 | self.flush_session_expire_time(session) 67 | # 发送一条消息回应token验证成功 68 | await self.send_personal_message('a[{"result":"success"}]', session) 69 | return True 70 | except: 71 | await self.send_personal_message(f"You token is inviled", session) 72 | await self.disconnect(session) 73 | else: 74 | await self.send_personal_message(f"need token", session) 75 | await self.disconnect(session) 76 | 77 | async def receive_json(self, session: WebSocket): 78 | """ 79 | 获取json格式的websocket数据 80 | """ 81 | res = await session.receive_json() 82 | self.flush_session_expire_time(session) 83 | return res 84 | 85 | async def send_personal_message(self, message: str, session: WebSocket): 86 | await session.send_text(message) 87 | 88 | async def broadcast(self, message: str): 89 | for connection in self.active_connections: 90 | msg = "a" + json.dumps([message]) 91 | await connection.send_text(msg) 92 | 93 | def start(self): 94 | # logging.info("start manager") 95 | if not self._hb_handle: 96 | loop = asyncio.get_running_loop() 97 | self._hb_handle = loop.call_later(self.heartbeat, self._heartbeat) 98 | 99 | def stop(self): 100 | if self._hb_handle is not None: 101 | self._hb_handle.cancel() 102 | self._hb_handle = None 103 | if self._hb_task is not None: 104 | self._hb_task.cancel() 105 | self._hb_task = None 106 | 107 | async def disconnect(self, session: WebSocket): 108 | """ 109 | 关闭连接 110 | """ 111 | await session.close() 112 | self.remove_session(session) 113 | 114 | def remove_session(self, session): 115 | """ 116 | 移除缓存的session 117 | """ 118 | if session in self.active_connections: 119 | LOG.info(f"session close {self.get_session_id(session)}") 120 | self.active_connections.remove(session) 121 | 122 | def _heartbeat(self): 123 | """ 124 | 启动定时task 125 | """ 126 | if self._hb_task is None: 127 | loop = asyncio.get_running_loop() 128 | self._hb_task = loop.create_task(self._heartbeat_task()) 129 | 130 | async def _heartbeat_task(self): 131 | """ 132 | 心跳检测 133 | """ 134 | sessions = self.active_connections 135 | if sessions: 136 | now = datetime.now() 137 | 138 | idx = 0 139 | while idx < len(sessions): 140 | session = sessions[idx] 141 | if session.expire_time < now: 142 | session_id = self.get_session_id(session) 143 | # 删除超时session 144 | try: 145 | await self.send_personal_message("h time out", session) 146 | logging.warn(f"heart beat check timeout {session_id}") 147 | await self.disconnect(session) 148 | except Exception as e: 149 | LOG.info(f"session {session_id} check failed {str(e)}") 150 | continue 151 | # 没有超时的设置下次超时时间, 但是索引不变 152 | await self.send_personal_message("h[]", session) 153 | idx += 1 154 | 155 | self._hb_task = None 156 | loop = asyncio.get_running_loop() 157 | self._hb_handle = loop.call_later(self.heartbeat, self._heartbeat) 158 | 159 | def flush_session_expire_time(self, session: WebSocket): 160 | """ 161 | 刷新session的过期时间 162 | """ 163 | setattr(session, "expire_time", datetime.now() + timedelta(seconds=self.heartbeat)) 164 | 165 | def get_session_id(self, session): 166 | """ 167 | 提取session的id 168 | """ 169 | if hasattr(session, "user_id"): 170 | session_id = session.user_id 171 | else: 172 | session_id = session.url 173 | return session_id 174 | 175 | 176 | html = """ 177 | 178 | 179 | 180 | Chat 181 | 182 | 183 |

WebSocket Chat

184 |

Your ID:

185 |
186 | 187 | 188 |
189 | 191 | 209 | 210 | 211 | """ 212 | -------------------------------------------------------------------------------- /core/config/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /core/config/base_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def str2bool(v): 5 | if v is None or isinstance(v, bool): 6 | return v 7 | return v.lower() in ("yes", "true", "t", "1") 8 | 9 | 10 | def str2int(v): 11 | if v is None: 12 | return v 13 | if v == "": 14 | return None 15 | return int(v) 16 | 17 | 18 | def str2float(v): 19 | if v is None: 20 | return v 21 | return int(v) 22 | 23 | 24 | class Base: 25 | # ------------------- need config --------------------- 26 | DATABASE_MYSQL_URL = os.getenv("DATABASE_MYSQL_URL", "root:dangerous@127.0.0.1:3306/test") 27 | 28 | # ------------------- option --------------------- 29 | CONFIG_NAME = "BASE" 30 | SERVICE_NAME = os.getenv("SERVICE_NAME", "fastapi-web-template") 31 | 32 | TZ = os.getenv("TZ", "Asia/Shanghai") 33 | 34 | TOKEN_SECRET_KEY = os.getenv("TOKEN_SECRET_KEY", "token_secret_key") 35 | 36 | # db 37 | DATABASE_URL = os.getenv("DATABASE_URL", f"mysql+aiomysql://{DATABASE_MYSQL_URL}?charset=utf8mb4") 38 | SHOW_SQL = str2bool(os.getenv("SHOW_SQL", "False")) 39 | RETURN_SQL = str2bool(os.getenv("RETURN_SQL", "True")) 40 | DATABASE_URL_ENCODING = os.getenv("DATABASE_URL_ENCODING", "utf8mb4") 41 | 42 | DB_POOL_RECYCLE = str2int(os.getenv("DB_POOL_RECYCLE", 3600)) 43 | DB_MAX_OVERFLOW = str2int(os.getenv("DB_MAX_OVERFLOW", 20)) 44 | DB_POOL_SIZE = str2int(os.getenv("DB_POOL_SIZE", 5)) 45 | -------------------------------------------------------------------------------- /core/config/common.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import os 4 | 5 | LOG = logging.getLogger(__name__) 6 | # noinspection PyUnresolvedReferences 7 | from .base_config import str2bool 8 | 9 | 10 | class Config: 11 | def __init__(self): 12 | self.config = None 13 | self.get_config() 14 | 15 | def get_config(self, config_name=os.getenv("CONFIG_NAME", "DEFAULT")): 16 | if config_name == "PROD": 17 | from .pro_config import ProdConfig 18 | 19 | self.config = ProdConfig() 20 | else: 21 | from .dev_config import DevConfig 22 | 23 | self.config = DevConfig() 24 | 25 | LOG.info("config use: %s", config_name) 26 | return self 27 | 28 | def __getattr__(self, item): 29 | return getattr(self.config, item) 30 | 31 | 32 | config = Config() 33 | -------------------------------------------------------------------------------- /core/config/dev_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .base_config import Base, str2bool 4 | 5 | 6 | class DevConfig(Base): 7 | CONFIG_NAME = "DEV" 8 | 9 | DATABASE_MYSQL_URL = os.getenv("DATABASE_MYSQL_URL", "root:dangerous@127.0.0.1:3306/test") 10 | 11 | PROD = str2bool(os.getenv("PROD", "False")) 12 | 13 | # db 14 | DATABASE_URL = os.getenv("DATABASE_URL", f"mysql+aiomysql://{DATABASE_MYSQL_URL}?charset=utf8mb4") 15 | SHOW_SQL = str2bool(os.getenv("SHOW_SQL", "True")) 16 | -------------------------------------------------------------------------------- /core/config/pro_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .base_config import Base, str2bool 4 | 5 | 6 | class ProdConfig(Base): 7 | CONFIG_NAME = "PROD" 8 | 9 | PROD = str2bool(os.getenv("PROD", "False")) 10 | -------------------------------------------------------------------------------- /core/dependencies/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends 2 | from .auth import auth 3 | from .base import base 4 | 5 | auth_dependen = Depends(auth) 6 | base_dependen = Depends(base) 7 | -------------------------------------------------------------------------------- /core/dependencies/auth.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | 3 | from fastapi import Header 4 | from starlette.responses import JSONResponse 5 | 6 | from core.config.common import config 7 | from core.middleware import g 8 | 9 | 10 | async def auth(Authorization: str = Header(...)): 11 | """ 12 | 接口认证依赖,这个是和base依赖分隔的,你得注意先后顺序 13 | """ 14 | header_type = "Bearer" 15 | header_name = "Authorization" 16 | algorithms = "HS256" 17 | # 只支持从header中获取jwt token, 存在token,将解析并设置user_id 18 | auth = Authorization 19 | if not auth: 20 | msg = "Need Header Authorization".format(header_name, header_type) 21 | JSONResponse({"error_info": msg, "error_type": "auth_error"}, status_code=401) 22 | parts = auth.split() 23 | if len(parts) != 2 or parts[0] != header_type: 24 | msg = "Bad {} header. Expected value '{} '".format(header_name, header_type) 25 | return JSONResponse({"error_info": msg, "error_type": "auth_error"}, status_code=401) 26 | try: 27 | payload = jwt.decode(parts[1], config.TOKEN_SECRET_KEY, algorithms=algorithms) 28 | except Exception as e: 29 | msg = f"Bad token {str(e)}" 30 | return JSONResponse({"error_info": msg, "error_type": "auth_error"}, status_code=401) 31 | # TODO check user_id 32 | g.user_id = payload.get("user_id") 33 | -------------------------------------------------------------------------------- /core/dependencies/base.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | 4 | from fastapi import Request 5 | 6 | 7 | async def base(request: Request): 8 | """ 9 | 基础依赖,你可以当作钩子函数来处理 10 | 如果需要扩展其他的dependen,需要注意调用顺序 11 | 另外你需要知道的一点是,fastapi中 dependencies如果是非异步函数则会用线程池进行处理 12 | - 1. 在线程池里contextvar的修改将不起作用 13 | """ 14 | from core.middleware.fastapi_globals import g 15 | 16 | lang = request.headers.get("Accept-Language") 17 | if lang: 18 | g.locale = lang.split(",")[0] 19 | else: 20 | g.locale = "zh-CN" 21 | -------------------------------------------------------------------------------- /core/i18n/README.md: -------------------------------------------------------------------------------- 1 | ## 手动执行翻译步骤 2 | 3 | ### 1. extract file 4 | `pybabel extract -F core/i18n/babel.cfg -k lazy_gettext -o core/i18n/messages.pot app` 5 | ### 2. 初始化翻译目录(如果不存在翻译目录需要处理一下) 6 | `pybabel init -i core/i18n/messages.pot -d core/i18n/ -l zh_CN` 7 | ### 3. 同步翻译 8 | `pybabel update -i core/i18n/messages.pot -d core/i18n/` 9 | ### 4. 编译翻译文件 10 | - 修改翻译前置文件,比如中文就是:`core/i18n/zh_CN/LC_MESSAGES/messages.po`,完成缺少的翻译内容 11 | 12 | - 编译:`pybabel compile -d core/i18n/` -------------------------------------------------------------------------------- /core/i18n/__init__.py: -------------------------------------------------------------------------------- 1 | import gettext 2 | import logging 3 | import os 4 | 5 | from babel.support import Translations 6 | from starlette.datastructures import Headers 7 | 8 | TRANSLATIONS = { 9 | # 获取当前文件所在目录,翻译文件就在当前目录下 10 | "zh-CN": Translations.load(os.path.split(os.path.realpath(__file__))[0], locales=["zh_CN"]), 11 | "en-US": Translations.load(os.path.split(os.path.realpath(__file__))[0], locales=["en_US"]), 12 | } 13 | 14 | 15 | translations = TRANSLATIONS.get("en-US") 16 | 17 | 18 | def gettext(msg: str): 19 | from core.middleware.fastapi_globals import g 20 | 21 | t = TRANSLATIONS.get(g.locale) if g.locale else TRANSLATIONS.get("en-US") 22 | return t.ugettext(msg) 23 | -------------------------------------------------------------------------------- /core/i18n/babel.cfg: -------------------------------------------------------------------------------- 1 | [python: **.py] 2 | 3 | extensions = jinja2.ext.autoescape,jinja2.ext.with_,webassets.ext.jinja2.AssetsExtension -------------------------------------------------------------------------------- /core/i18n/en_US/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | # English (United States) translations for PROJECT. 2 | # Copyright (C) 2021 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2021. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PROJECT VERSION\n" 9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 10 | "POT-Creation-Date: 2021-08-23 11:16+0800\n" 11 | "PO-Revision-Date: 2021-08-18 16:37+0800\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language: en_US\n" 14 | "Language-Team: en_US \n" 15 | "Plural-Forms: nplurals=2; plural=(n != 1)\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=utf-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Generated-By: Babel 2.9.1\n" 20 | 21 | #: venv/lib/python3.8/site-packages/click/_termui_impl.py:495 22 | msgid "{editor}: Editing failed" 23 | msgstr "" 24 | 25 | #: venv/lib/python3.8/site-packages/click/_termui_impl.py:499 26 | msgid "{editor}: Editing failed: {e}" 27 | msgstr "" 28 | 29 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:19 30 | msgid "" 31 | "Click will abort further execution because Python was configured to use " 32 | "ASCII as encoding for the environment. Consult " 33 | "https://click.palletsprojects.com/unicode-support/ for mitigation steps." 34 | msgstr "" 35 | 36 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:55 37 | msgid "" 38 | "Additional information: on this system no suitable UTF-8 locales were " 39 | "discovered. This most likely requires resolving by reconfiguring the " 40 | "locale system." 41 | msgstr "" 42 | 43 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:64 44 | msgid "" 45 | "This system supports the C.UTF-8 locale which is recommended. You might " 46 | "be able to resolve your issue by exporting the following environment " 47 | "variables:" 48 | msgstr "" 49 | 50 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:74 51 | msgid "" 52 | "This system lists some UTF-8 supporting locales that you can pick from. " 53 | "The following suitable locales were discovered: {locales}" 54 | msgstr "" 55 | 56 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:92 57 | msgid "" 58 | "Click discovered that you exported a UTF-8 locale but the locale system " 59 | "could not pick up from it because it does not exist. The exported locale " 60 | "is {locale!r} but it is not supported." 61 | msgstr "" 62 | 63 | #: venv/lib/python3.8/site-packages/click/core.py:1104 64 | msgid "Aborted!" 65 | msgstr "" 66 | 67 | #: venv/lib/python3.8/site-packages/click/core.py:1288 68 | #: venv/lib/python3.8/site-packages/click/decorators.py:435 69 | msgid "Show this message and exit." 70 | msgstr "" 71 | 72 | #: venv/lib/python3.8/site-packages/click/core.py:1317 73 | #: venv/lib/python3.8/site-packages/click/core.py:1343 74 | msgid "(Deprecated) {text}" 75 | msgstr "" 76 | 77 | #: venv/lib/python3.8/site-packages/click/core.py:1360 78 | msgid "Options" 79 | msgstr "" 80 | 81 | #: venv/lib/python3.8/site-packages/click/core.py:1386 82 | msgid "Got unexpected extra argument ({args})" 83 | msgid_plural "Got unexpected extra arguments ({args})" 84 | msgstr[0] "" 85 | msgstr[1] "" 86 | 87 | #: venv/lib/python3.8/site-packages/click/core.py:1398 88 | msgid "DeprecationWarning: The command {name!r} is deprecated." 89 | msgstr "" 90 | 91 | #: venv/lib/python3.8/site-packages/click/core.py:1616 92 | msgid "Commands" 93 | msgstr "" 94 | 95 | #: venv/lib/python3.8/site-packages/click/core.py:1648 96 | msgid "Missing command." 97 | msgstr "" 98 | 99 | #: venv/lib/python3.8/site-packages/click/core.py:1726 100 | msgid "No such command {name!r}." 101 | msgstr "" 102 | 103 | #: venv/lib/python3.8/site-packages/click/core.py:2274 104 | msgid "Value must be an iterable." 105 | msgstr "" 106 | 107 | #: venv/lib/python3.8/site-packages/click/core.py:2296 108 | msgid "Takes {nargs} values but 1 was given." 109 | msgid_plural "Takes {nargs} values but {len} were given." 110 | msgstr[0] "" 111 | msgstr[1] "" 112 | 113 | #: venv/lib/python3.8/site-packages/click/core.py:2718 114 | msgid "env var: {var}" 115 | msgstr "" 116 | 117 | #: venv/lib/python3.8/site-packages/click/core.py:2741 118 | msgid "(dynamic)" 119 | msgstr "" 120 | 121 | #: venv/lib/python3.8/site-packages/click/core.py:2751 122 | msgid "default: {default}" 123 | msgstr "" 124 | 125 | #: venv/lib/python3.8/site-packages/click/core.py:2760 126 | msgid "required" 127 | msgstr "" 128 | 129 | #: venv/lib/python3.8/site-packages/click/decorators.py:339 130 | #, python-format 131 | msgid "%(prog)s, version %(version)s" 132 | msgstr "" 133 | 134 | #: venv/lib/python3.8/site-packages/click/decorators.py:404 135 | msgid "Show the version and exit." 136 | msgstr "" 137 | 138 | #: venv/lib/python3.8/site-packages/click/exceptions.py:43 139 | #: venv/lib/python3.8/site-packages/click/exceptions.py:79 140 | msgid "Error: {message}" 141 | msgstr "" 142 | 143 | #: venv/lib/python3.8/site-packages/click/exceptions.py:71 144 | msgid "Try '{command} {option}' for help." 145 | msgstr "" 146 | 147 | #: venv/lib/python3.8/site-packages/click/exceptions.py:120 148 | msgid "Invalid value: {message}" 149 | msgstr "" 150 | 151 | #: venv/lib/python3.8/site-packages/click/exceptions.py:122 152 | msgid "Invalid value for {param_hint}: {message}" 153 | msgstr "" 154 | 155 | #: venv/lib/python3.8/site-packages/click/exceptions.py:178 156 | msgid "Missing argument" 157 | msgstr "" 158 | 159 | #: venv/lib/python3.8/site-packages/click/exceptions.py:180 160 | msgid "Missing option" 161 | msgstr "" 162 | 163 | #: venv/lib/python3.8/site-packages/click/exceptions.py:182 164 | msgid "Missing parameter" 165 | msgstr "" 166 | 167 | #: venv/lib/python3.8/site-packages/click/exceptions.py:184 168 | msgid "Missing {param_type}" 169 | msgstr "" 170 | 171 | #: venv/lib/python3.8/site-packages/click/exceptions.py:191 172 | msgid "Missing parameter: {param_name}" 173 | msgstr "" 174 | 175 | #: venv/lib/python3.8/site-packages/click/exceptions.py:211 176 | msgid "No such option: {name}" 177 | msgstr "" 178 | 179 | #: venv/lib/python3.8/site-packages/click/exceptions.py:225 180 | msgid "Did you mean {possibility}?" 181 | msgid_plural "(Possible options: {possibilities})" 182 | msgstr[0] "" 183 | msgstr[1] "" 184 | 185 | #: venv/lib/python3.8/site-packages/click/exceptions.py:261 186 | msgid "unknown error" 187 | msgstr "" 188 | 189 | #: venv/lib/python3.8/site-packages/click/exceptions.py:268 190 | msgid "Could not open file {filename!r}: {message}" 191 | msgstr "" 192 | 193 | #: venv/lib/python3.8/site-packages/click/parser.py:231 194 | msgid "Argument {name!r} takes {nargs} values." 195 | msgstr "" 196 | 197 | #: venv/lib/python3.8/site-packages/click/parser.py:413 198 | msgid "Option {name!r} does not take a value." 199 | msgstr "" 200 | 201 | #: venv/lib/python3.8/site-packages/click/parser.py:473 202 | msgid "Option {name!r} requires an argument." 203 | msgid_plural "Option {name!r} requires {nargs} arguments." 204 | msgstr[0] "" 205 | msgstr[1] "" 206 | 207 | #: venv/lib/python3.8/site-packages/click/shell_completion.py:313 208 | msgid "Shell completion is not supported for Bash versions older than 4.4." 209 | msgstr "" 210 | 211 | #: venv/lib/python3.8/site-packages/click/shell_completion.py:320 212 | msgid "Couldn't detect Bash version, shell completion is not supported." 213 | msgstr "" 214 | 215 | #: venv/lib/python3.8/site-packages/click/termui.py:161 216 | msgid "Repeat for confirmation" 217 | msgstr "" 218 | 219 | #: venv/lib/python3.8/site-packages/click/termui.py:178 220 | msgid "Error: The value you entered was invalid." 221 | msgstr "" 222 | 223 | #: venv/lib/python3.8/site-packages/click/termui.py:180 224 | msgid "Error: {e.message}" 225 | msgstr "" 226 | 227 | #: venv/lib/python3.8/site-packages/click/termui.py:191 228 | msgid "Error: The two entered values do not match." 229 | msgstr "" 230 | 231 | #: venv/lib/python3.8/site-packages/click/termui.py:245 232 | msgid "Error: invalid input" 233 | msgstr "" 234 | 235 | #: venv/lib/python3.8/site-packages/click/termui.py:796 236 | msgid "Press any key to continue..." 237 | msgstr "" 238 | 239 | #: venv/lib/python3.8/site-packages/click/types.py:258 240 | msgid "" 241 | "Choose from:\n" 242 | "\t{choices}" 243 | msgstr "" 244 | 245 | #: venv/lib/python3.8/site-packages/click/types.py:292 246 | msgid "{value!r} is not {choice}." 247 | msgid_plural "{value!r} is not one of {choices}." 248 | msgstr[0] "" 249 | msgstr[1] "" 250 | 251 | #: venv/lib/python3.8/site-packages/click/types.py:382 252 | msgid "{value!r} does not match the format {format}." 253 | msgid_plural "{value!r} does not match the formats {formats}." 254 | msgstr[0] "" 255 | msgstr[1] "" 256 | 257 | #: venv/lib/python3.8/site-packages/click/types.py:402 258 | msgid "{value!r} is not a valid {number_type}." 259 | msgstr "" 260 | 261 | #: venv/lib/python3.8/site-packages/click/types.py:458 262 | msgid "{value} is not in the range {range}." 263 | msgstr "" 264 | 265 | #: venv/lib/python3.8/site-packages/click/types.py:599 266 | msgid "{value!r} is not a valid boolean." 267 | msgstr "" 268 | 269 | #: venv/lib/python3.8/site-packages/click/types.py:623 270 | msgid "{value!r} is not a valid UUID." 271 | msgstr "" 272 | 273 | #: venv/lib/python3.8/site-packages/click/types.py:801 274 | msgid "file" 275 | msgstr "" 276 | 277 | #: venv/lib/python3.8/site-packages/click/types.py:803 278 | msgid "directory" 279 | msgstr "" 280 | 281 | #: venv/lib/python3.8/site-packages/click/types.py:805 282 | msgid "path" 283 | msgstr "" 284 | 285 | #: venv/lib/python3.8/site-packages/click/types.py:851 286 | msgid "{name} {filename!r} does not exist." 287 | msgstr "" 288 | 289 | #: venv/lib/python3.8/site-packages/click/types.py:860 290 | msgid "{name} {filename!r} is a file." 291 | msgstr "" 292 | 293 | #: venv/lib/python3.8/site-packages/click/types.py:868 294 | msgid "{name} {filename!r} is a directory." 295 | msgstr "" 296 | 297 | #: venv/lib/python3.8/site-packages/click/types.py:876 298 | msgid "{name} {filename!r} is not writable." 299 | msgstr "" 300 | 301 | #: venv/lib/python3.8/site-packages/click/types.py:884 302 | msgid "{name} {filename!r} is not readable." 303 | msgstr "" 304 | 305 | #: venv/lib/python3.8/site-packages/click/types.py:950 306 | msgid "{len_type} values are required, but {len_value} was given." 307 | msgid_plural "{len_type} values are required, but {len_value} were given." 308 | msgstr[0] "" 309 | msgstr[1] "" 310 | 311 | #~ msgid "log Value: %(value)s" 312 | #~ msgstr "" 313 | 314 | #~ msgid "Dsp Value: %(value)s" 315 | #~ msgstr "" 316 | 317 | #~ msgid "get competition rank failed" 318 | #~ msgstr "获取赛事排行榜失败" 319 | 320 | #~ msgid "competition not found" 321 | #~ msgstr "赛事不存在" 322 | 323 | #~ msgid "test exception" 324 | #~ msgstr "" 325 | 326 | #~ msgid "database name error" 327 | #~ msgstr "" 328 | 329 | #~ msgid "{}:{} not in dict_data {}" 330 | #~ msgstr "" 331 | 332 | #~ msgid "{}:{} in dict_data {}" 333 | #~ msgstr "" 334 | 335 | #~ msgid "http error: {}" 336 | #~ msgstr "" 337 | 338 | #~ msgid "test_deep_with_session_raise" 339 | #~ msgstr "" 340 | 341 | #~ msgid "test_deep_with_session_raise_catch" 342 | #~ msgstr "" 343 | 344 | #~ msgid "test_deep_with_session_commit_manually" 345 | #~ msgstr "" 346 | 347 | #~ msgid "test_top_with_session_raise" 348 | #~ msgstr "" 349 | 350 | #~ msgid "request data is not a json" 351 | #~ msgstr "" 352 | 353 | #~ msgid "can not dump {obj}" 354 | #~ msgstr "" 355 | 356 | -------------------------------------------------------------------------------- /core/i18n/zh_CN/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | # Chinese (Simplified, China) translations for PROJECT. 2 | # Copyright (C) 2021 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2021. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PROJECT VERSION\n" 9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 10 | "POT-Creation-Date: 2021-08-23 11:16+0800\n" 11 | "PO-Revision-Date: 2021-08-18 16:28+0800\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language: zh_Hans_CN\n" 14 | "Language-Team: zh_Hans_CN \n" 15 | "Plural-Forms: nplurals=1; plural=0\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=utf-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Generated-By: Babel 2.9.1\n" 20 | 21 | #: venv/lib/python3.8/site-packages/click/_termui_impl.py:495 22 | msgid "{editor}: Editing failed" 23 | msgstr "" 24 | 25 | #: venv/lib/python3.8/site-packages/click/_termui_impl.py:499 26 | msgid "{editor}: Editing failed: {e}" 27 | msgstr "" 28 | 29 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:19 30 | msgid "" 31 | "Click will abort further execution because Python was configured to use " 32 | "ASCII as encoding for the environment. Consult " 33 | "https://click.palletsprojects.com/unicode-support/ for mitigation steps." 34 | msgstr "" 35 | 36 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:55 37 | msgid "" 38 | "Additional information: on this system no suitable UTF-8 locales were " 39 | "discovered. This most likely requires resolving by reconfiguring the " 40 | "locale system." 41 | msgstr "" 42 | 43 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:64 44 | msgid "" 45 | "This system supports the C.UTF-8 locale which is recommended. You might " 46 | "be able to resolve your issue by exporting the following environment " 47 | "variables:" 48 | msgstr "" 49 | 50 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:74 51 | msgid "" 52 | "This system lists some UTF-8 supporting locales that you can pick from. " 53 | "The following suitable locales were discovered: {locales}" 54 | msgstr "" 55 | 56 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:92 57 | msgid "" 58 | "Click discovered that you exported a UTF-8 locale but the locale system " 59 | "could not pick up from it because it does not exist. The exported locale " 60 | "is {locale!r} but it is not supported." 61 | msgstr "" 62 | 63 | #: venv/lib/python3.8/site-packages/click/core.py:1104 64 | msgid "Aborted!" 65 | msgstr "" 66 | 67 | #: venv/lib/python3.8/site-packages/click/core.py:1288 68 | #: venv/lib/python3.8/site-packages/click/decorators.py:435 69 | msgid "Show this message and exit." 70 | msgstr "" 71 | 72 | #: venv/lib/python3.8/site-packages/click/core.py:1317 73 | #: venv/lib/python3.8/site-packages/click/core.py:1343 74 | msgid "(Deprecated) {text}" 75 | msgstr "" 76 | 77 | #: venv/lib/python3.8/site-packages/click/core.py:1360 78 | msgid "Options" 79 | msgstr "" 80 | 81 | #: venv/lib/python3.8/site-packages/click/core.py:1386 82 | msgid "Got unexpected extra argument ({args})" 83 | msgid_plural "Got unexpected extra arguments ({args})" 84 | msgstr[0] "" 85 | 86 | #: venv/lib/python3.8/site-packages/click/core.py:1398 87 | msgid "DeprecationWarning: The command {name!r} is deprecated." 88 | msgstr "" 89 | 90 | #: venv/lib/python3.8/site-packages/click/core.py:1616 91 | msgid "Commands" 92 | msgstr "" 93 | 94 | #: venv/lib/python3.8/site-packages/click/core.py:1648 95 | msgid "Missing command." 96 | msgstr "" 97 | 98 | #: venv/lib/python3.8/site-packages/click/core.py:1726 99 | msgid "No such command {name!r}." 100 | msgstr "" 101 | 102 | #: venv/lib/python3.8/site-packages/click/core.py:2274 103 | msgid "Value must be an iterable." 104 | msgstr "" 105 | 106 | #: venv/lib/python3.8/site-packages/click/core.py:2296 107 | msgid "Takes {nargs} values but 1 was given." 108 | msgid_plural "Takes {nargs} values but {len} were given." 109 | msgstr[0] "" 110 | 111 | #: venv/lib/python3.8/site-packages/click/core.py:2718 112 | msgid "env var: {var}" 113 | msgstr "" 114 | 115 | #: venv/lib/python3.8/site-packages/click/core.py:2741 116 | msgid "(dynamic)" 117 | msgstr "" 118 | 119 | #: venv/lib/python3.8/site-packages/click/core.py:2751 120 | msgid "default: {default}" 121 | msgstr "" 122 | 123 | #: venv/lib/python3.8/site-packages/click/core.py:2760 124 | msgid "required" 125 | msgstr "" 126 | 127 | #: venv/lib/python3.8/site-packages/click/decorators.py:339 128 | #, python-format 129 | msgid "%(prog)s, version %(version)s" 130 | msgstr "" 131 | 132 | #: venv/lib/python3.8/site-packages/click/decorators.py:404 133 | msgid "Show the version and exit." 134 | msgstr "" 135 | 136 | #: venv/lib/python3.8/site-packages/click/exceptions.py:43 137 | #: venv/lib/python3.8/site-packages/click/exceptions.py:79 138 | msgid "Error: {message}" 139 | msgstr "" 140 | 141 | #: venv/lib/python3.8/site-packages/click/exceptions.py:71 142 | msgid "Try '{command} {option}' for help." 143 | msgstr "" 144 | 145 | #: venv/lib/python3.8/site-packages/click/exceptions.py:120 146 | msgid "Invalid value: {message}" 147 | msgstr "" 148 | 149 | #: venv/lib/python3.8/site-packages/click/exceptions.py:122 150 | msgid "Invalid value for {param_hint}: {message}" 151 | msgstr "" 152 | 153 | #: venv/lib/python3.8/site-packages/click/exceptions.py:178 154 | msgid "Missing argument" 155 | msgstr "" 156 | 157 | #: venv/lib/python3.8/site-packages/click/exceptions.py:180 158 | msgid "Missing option" 159 | msgstr "" 160 | 161 | #: venv/lib/python3.8/site-packages/click/exceptions.py:182 162 | msgid "Missing parameter" 163 | msgstr "" 164 | 165 | #: venv/lib/python3.8/site-packages/click/exceptions.py:184 166 | msgid "Missing {param_type}" 167 | msgstr "" 168 | 169 | #: venv/lib/python3.8/site-packages/click/exceptions.py:191 170 | msgid "Missing parameter: {param_name}" 171 | msgstr "" 172 | 173 | #: venv/lib/python3.8/site-packages/click/exceptions.py:211 174 | msgid "No such option: {name}" 175 | msgstr "" 176 | 177 | #: venv/lib/python3.8/site-packages/click/exceptions.py:225 178 | msgid "Did you mean {possibility}?" 179 | msgid_plural "(Possible options: {possibilities})" 180 | msgstr[0] "" 181 | 182 | #: venv/lib/python3.8/site-packages/click/exceptions.py:261 183 | msgid "unknown error" 184 | msgstr "" 185 | 186 | #: venv/lib/python3.8/site-packages/click/exceptions.py:268 187 | msgid "Could not open file {filename!r}: {message}" 188 | msgstr "" 189 | 190 | #: venv/lib/python3.8/site-packages/click/parser.py:231 191 | msgid "Argument {name!r} takes {nargs} values." 192 | msgstr "" 193 | 194 | #: venv/lib/python3.8/site-packages/click/parser.py:413 195 | msgid "Option {name!r} does not take a value." 196 | msgstr "" 197 | 198 | #: venv/lib/python3.8/site-packages/click/parser.py:473 199 | msgid "Option {name!r} requires an argument." 200 | msgid_plural "Option {name!r} requires {nargs} arguments." 201 | msgstr[0] "" 202 | 203 | #: venv/lib/python3.8/site-packages/click/shell_completion.py:313 204 | msgid "Shell completion is not supported for Bash versions older than 4.4." 205 | msgstr "" 206 | 207 | #: venv/lib/python3.8/site-packages/click/shell_completion.py:320 208 | msgid "Couldn't detect Bash version, shell completion is not supported." 209 | msgstr "" 210 | 211 | #: venv/lib/python3.8/site-packages/click/termui.py:161 212 | msgid "Repeat for confirmation" 213 | msgstr "" 214 | 215 | #: venv/lib/python3.8/site-packages/click/termui.py:178 216 | msgid "Error: The value you entered was invalid." 217 | msgstr "" 218 | 219 | #: venv/lib/python3.8/site-packages/click/termui.py:180 220 | msgid "Error: {e.message}" 221 | msgstr "" 222 | 223 | #: venv/lib/python3.8/site-packages/click/termui.py:191 224 | msgid "Error: The two entered values do not match." 225 | msgstr "" 226 | 227 | #: venv/lib/python3.8/site-packages/click/termui.py:245 228 | msgid "Error: invalid input" 229 | msgstr "" 230 | 231 | #: venv/lib/python3.8/site-packages/click/termui.py:796 232 | msgid "Press any key to continue..." 233 | msgstr "" 234 | 235 | #: venv/lib/python3.8/site-packages/click/types.py:258 236 | msgid "" 237 | "Choose from:\n" 238 | "\t{choices}" 239 | msgstr "" 240 | 241 | #: venv/lib/python3.8/site-packages/click/types.py:292 242 | msgid "{value!r} is not {choice}." 243 | msgid_plural "{value!r} is not one of {choices}." 244 | msgstr[0] "" 245 | 246 | #: venv/lib/python3.8/site-packages/click/types.py:382 247 | msgid "{value!r} does not match the format {format}." 248 | msgid_plural "{value!r} does not match the formats {formats}." 249 | msgstr[0] "" 250 | 251 | #: venv/lib/python3.8/site-packages/click/types.py:402 252 | msgid "{value!r} is not a valid {number_type}." 253 | msgstr "" 254 | 255 | #: venv/lib/python3.8/site-packages/click/types.py:458 256 | msgid "{value} is not in the range {range}." 257 | msgstr "" 258 | 259 | #: venv/lib/python3.8/site-packages/click/types.py:599 260 | msgid "{value!r} is not a valid boolean." 261 | msgstr "" 262 | 263 | #: venv/lib/python3.8/site-packages/click/types.py:623 264 | msgid "{value!r} is not a valid UUID." 265 | msgstr "" 266 | 267 | #: venv/lib/python3.8/site-packages/click/types.py:801 268 | msgid "file" 269 | msgstr "" 270 | 271 | #: venv/lib/python3.8/site-packages/click/types.py:803 272 | msgid "directory" 273 | msgstr "" 274 | 275 | #: venv/lib/python3.8/site-packages/click/types.py:805 276 | msgid "path" 277 | msgstr "" 278 | 279 | #: venv/lib/python3.8/site-packages/click/types.py:851 280 | msgid "{name} {filename!r} does not exist." 281 | msgstr "" 282 | 283 | #: venv/lib/python3.8/site-packages/click/types.py:860 284 | msgid "{name} {filename!r} is a file." 285 | msgstr "" 286 | 287 | #: venv/lib/python3.8/site-packages/click/types.py:868 288 | msgid "{name} {filename!r} is a directory." 289 | msgstr "" 290 | 291 | #: venv/lib/python3.8/site-packages/click/types.py:876 292 | msgid "{name} {filename!r} is not writable." 293 | msgstr "" 294 | 295 | #: venv/lib/python3.8/site-packages/click/types.py:884 296 | msgid "{name} {filename!r} is not readable." 297 | msgstr "" 298 | 299 | #: venv/lib/python3.8/site-packages/click/types.py:950 300 | msgid "{len_type} values are required, but {len_value} was given." 301 | msgid_plural "{len_type} values are required, but {len_value} were given." 302 | msgstr[0] "" 303 | 304 | #~ msgid "log Value: %(value)s" 305 | #~ msgstr "" 306 | 307 | #~ msgid "Dsp Value: %(value)s" 308 | #~ msgstr "" 309 | 310 | #~ msgid "get competition rank failed" 311 | #~ msgstr "获取赛事排行榜失败" 312 | 313 | #~ msgid "competition not found" 314 | #~ msgstr "赛事不存在" 315 | 316 | #~ msgid "test exception" 317 | #~ msgstr "" 318 | 319 | #~ msgid "database name error" 320 | #~ msgstr "" 321 | 322 | #~ msgid "{}:{} not in dict_data {}" 323 | #~ msgstr "" 324 | 325 | #~ msgid "{}:{} in dict_data {}" 326 | #~ msgstr "" 327 | 328 | #~ msgid "http error: {}" 329 | #~ msgstr "" 330 | 331 | #~ msgid "test_deep_with_session_raise" 332 | #~ msgstr "" 333 | 334 | #~ msgid "test_deep_with_session_raise_catch" 335 | #~ msgstr "" 336 | 337 | #~ msgid "test_deep_with_session_commit_manually" 338 | #~ msgstr "" 339 | 340 | #~ msgid "test_top_with_session_raise" 341 | #~ msgstr "" 342 | 343 | #~ msgid "request data is not a json" 344 | #~ msgstr "" 345 | 346 | #~ msgid "can not dump {obj}" 347 | #~ msgstr "" 348 | 349 | -------------------------------------------------------------------------------- /core/manager/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from .base import CommandBase 5 | 6 | 7 | def registry_command(): 8 | # noinspection PyUnresolvedReferences 9 | from .gen_code import StartAppCommand 10 | from .babel_cmd import BabelCommand 11 | 12 | 13 | class ManagementUtility: 14 | """ 15 | 管理程序类 16 | """ 17 | 18 | def __init__(self, argv=None): 19 | self.argv = argv 20 | # 项目名 21 | self.prog_name = os.path.basename(self.argv[0]) 22 | 23 | def main_help_text(self): 24 | """ 25 | 帮助文档 26 | """ 27 | desc = CommandBase.get_command_desc() 28 | out = "支持命令:\n" 29 | for k, v in desc.items(): 30 | out = out + " " * 4 + k + ": " 31 | if isinstance(v, list): 32 | out += "\n" + "\n".join([" " * 8 + i for i in v]) + "\n" 33 | else: 34 | out += v + "\n" 35 | return out + "\n" 36 | 37 | def fetch_command(self, subcommand): 38 | commands = CommandBase.get_commands() 39 | try: 40 | return commands[subcommand] 41 | except KeyError: 42 | sys.stderr.write("Unknown command: %r\nType '%s help' for usage.\n" % (subcommand, self.prog_name)) 43 | sys.exit(1) 44 | 45 | def execute(self): 46 | """ 47 | 执行command 48 | """ 49 | try: 50 | command = self.argv[1] 51 | except IndexError: 52 | command = "help" 53 | if command == "help": 54 | sys.stdout.write(self.main_help_text()) 55 | sys.exit(0) 56 | executor = self.fetch_command(command) 57 | executor(*self.argv[2:]) 58 | 59 | 60 | registry_command() 61 | -------------------------------------------------------------------------------- /core/manager/babel_cmd.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import os 3 | import sys 4 | 5 | from .base import CommandBase, str2camel 6 | 7 | 8 | class BabelCommand(CommandBase): 9 | """ 10 | i18n翻译 11 | """ 12 | 13 | # 命令 14 | command_str = "babel" 15 | # 命令描述 16 | command_desc = ["add: 生成翻译文件", "run: 编译翻译文件"] 17 | 18 | @classmethod 19 | def execute(cls, action): 20 | if action == "add": 21 | cls.add_babel() 22 | elif action == "run": 23 | cls.run_babel() 24 | 25 | @classmethod 26 | def add_babel(cls): 27 | """ 28 | 创建翻译文件 29 | """ 30 | if os.system("pybabel extract -F core/i18n/babel.cfg -k gettext -o core/i18n/messages.pot ."): 31 | sys.stderr.write("生成messages.pot文件失败\n") 32 | sys.exit(1) 33 | # # 是否已经创建了翻译文件 34 | # if not os.path.exists("core/i18n/zh_CN"): 35 | # if os.system("pybabel init -i core/i18n/messages.pot -d core/i18n/ -l zh_CN"): 36 | # sys.stderr.write("生成翻译文件失败\n") 37 | # sys.exit(1) 38 | if os.system("pybabel update -i core/i18n/messages.pot -d core/i18n/"): 39 | sys.stderr.write("更新翻译文件失败\n") 40 | sys.exit(1) 41 | sys.stdout.write("\n***************** ") 42 | sys.stdout.write("生成翻译文件成功,需要更新文件: core/i18n/zh_CN/LC_MESSAGES/messages.po") 43 | sys.stdout.write(" *****************\n") 44 | 45 | @classmethod 46 | def run_babel(cls): 47 | """ 48 | 编译翻译文件 49 | """ 50 | if os.system("pybabel compile -d core/i18n/"): 51 | sys.stderr.write("生成messages.pot文件失败") 52 | sys.exit(1) 53 | sys.stdout.write("\n***************** ") 54 | sys.stdout.write("编译成功") 55 | sys.stdout.write(" *****************\n") 56 | -------------------------------------------------------------------------------- /core/manager/base.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | 5 | class CommandMetaclass(type): 6 | registry_commands = dict() 7 | commands_desc = dict() 8 | 9 | def __new__(cls, name, bases, attrs): 10 | obj = type.__new__(cls, name, bases, attrs) 11 | if name == "CommandBase": 12 | return obj 13 | 14 | command_str = name 15 | command_desc = "..." 16 | executor = None 17 | for k, v in attrs.items(): 18 | if k == "execute": 19 | executor = obj.execute 20 | # 指定命令名 21 | if k == "command_str": 22 | command_str = v 23 | # 指定命令描述 24 | if k == "command_desc": 25 | command_desc = v 26 | cls.registry_commands[command_str] = executor 27 | cls.commands_desc[command_str] = command_desc 28 | return obj 29 | 30 | 31 | class CommandBase(object, metaclass=CommandMetaclass): 32 | """ 33 | command基类,所有的继承类只能实现类方法 34 | """ 35 | 36 | @classmethod 37 | def get_commands(cls): 38 | return CommandMetaclass.registry_commands 39 | 40 | @classmethod 41 | def get_command_desc(cls): 42 | return CommandMetaclass.commands_desc 43 | 44 | @classmethod 45 | def execute(cls, *args, **kwargs): 46 | """ 47 | 子类必须实现execute方法 48 | """ 49 | raise NotImplementedError 50 | 51 | 52 | def str2camel(string): 53 | """ 54 | 字符串转驼峰 55 | """ 56 | return "".join(list(map(lambda x: x.capitalize(), string.split("_")))) 57 | -------------------------------------------------------------------------------- /core/manager/gen_code.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import os 3 | import sys 4 | 5 | from .base import CommandBase, str2camel 6 | 7 | 8 | apis_file = """ 9 | from fastapi import APIRouter 10 | 11 | from core.dependencies import base_dependen 12 | from core.schema.base import DataSchema, PageSchema 13 | from app.{app_name}.views import list_{app_name}_view, create_{app_name}_view, delete_{app_name}_view 14 | from app.{app_name}.schema import {AppName}ListRes 15 | 16 | 17 | router = APIRouter(prefix="/v1/{app_name}", tags=["{app_name} project"]) 18 | 19 | router.add_api_route( 20 | name="list {app_name}", 21 | path="/", 22 | endpoint=list_{app_name}_view, 23 | methods=["GET"], 24 | response_model=PageSchema[{AppName}ListRes], 25 | dependencies=[base_dependen,] 26 | ) 27 | 28 | router.add_api_route( 29 | name="create {app_name}", 30 | path="/", 31 | endpoint=create_{app_name}_view, 32 | methods=["POST"], 33 | dependencies=[base_dependen,] 34 | ) 35 | 36 | router.add_api_route( 37 | name="delete {app_name}", 38 | path="/", 39 | endpoint=delete_{app_name}_view, 40 | methods=["DELETE"], 41 | dependencies=[base_dependen,] 42 | ) 43 | """ 44 | 45 | schema_file = """ 46 | from typing import List, Optional 47 | from core.schema import BaseSchema 48 | from pydantic.fields import Field 49 | 50 | 51 | class {AppName}ListRes(BaseSchema): 52 | id: Optional[str] = Field(description="id") 53 | name: Optional[str] = Field(description="名称") 54 | description: Optional[str] = Field(description="描述") 55 | 56 | class {AppName}CreateReq(BaseSchema): 57 | name: Optional[str] = Field(description="名称") 58 | description: Optional[str] = Field(description="描述") 59 | 60 | """ 61 | 62 | views_file = """ 63 | from typing import Any, Optional 64 | from app.{app_name}.{app_name} import list_{app_name}, create_{app_name}, delete_{app_name} 65 | from app.{app_name}.schema import {AppName}CreateReq 66 | 67 | 68 | async def list_{app_name}_view( 69 | page: Optional[int] = 1, 70 | limit: Optional[int] = 10, 71 | ): 72 | return await list_{app_name}(page, limit) 73 | 74 | async def create_{app_name}_view(item: {AppName}CreateReq): 75 | await create_{app_name}(item.name, item.description) 76 | return {{"data": "success"}} 77 | 78 | async def delete_{app_name}_view(record_id: str): 79 | await delete_{app_name}(record_id) 80 | return {{"data": "success"}} 81 | 82 | """ 83 | 84 | app_util_file = """ 85 | from sqlalchemy import select, insert 86 | from core.middleware import g 87 | from core.schema import paginate_handler 88 | from core.utils.api_exception import NotFoundException 89 | from core.i18n import gettext 90 | from app.{app_name}.models import {AppName} 91 | 92 | 93 | async def list_{app_name}(page: int, limit: int): 94 | stmt = select({AppName}) 95 | return await paginate_handler(page=page, limit=limit, db=g.db, stmt=stmt) 96 | 97 | async def create_{app_name}(name: str, description: str): 98 | return await g.db.execute(insert({AppName}).values({{"name": name, "description": description}})) 99 | 100 | async def delete_{app_name}(record_id: str): 101 | obj = await g.db.get({AppName}, record_id) 102 | if not obj: 103 | raise NotFoundException(gettext("record not found")) 104 | await obj.delete() 105 | return 106 | 107 | """ 108 | 109 | model_file = """ 110 | import uuid 111 | 112 | from core.storage import Base 113 | from sqlalchemy import Column, String 114 | 115 | 116 | class {AppName}(Base): 117 | __tablename__ = "{app_name}" 118 | id = Column(String(36), primary_key=True, default=str(uuid.uuid4())) 119 | name = Column(String(128), nullable=False) 120 | description = Column(String(512), nullable=False) 121 | 122 | """ 123 | 124 | 125 | class StartAppCommand(CommandBase): 126 | """ 127 | 自动生成标准模块代码 128 | """ 129 | 130 | # the command 131 | command_str = "startapp" 132 | 133 | # command description 134 | command_desc = ["[app_name]: 自动生成app名称为[app_name]标准模块代码"] 135 | 136 | # default app name is 'demo' 137 | app_name = "demo" 138 | 139 | # the app module root dir 140 | root = "./app" 141 | 142 | # copyright 143 | copyright_str = "" 144 | 145 | @classmethod 146 | def execute(cls, app_name): 147 | cls.app_name = app_name 148 | cls.init_dir() 149 | cls.add_apis_file() 150 | cls.add_app_util_file() 151 | cls.add_model_file() 152 | cls.add_scheme_file() 153 | cls.add_view_file() 154 | cls.runner_file_handler() 155 | print("create app ' %s ' success" % app_name) 156 | 157 | @classmethod 158 | def init_dir(cls): 159 | """ 160 | create app module dir 161 | """ 162 | cls.root = os.path.join(cls.root, cls.app_name) 163 | if os.path.exists(cls.root): 164 | sys.stderr.write(f"app {cls.app_name} already exists") 165 | sys.exit(1) 166 | os.makedirs(cls.root) 167 | with open(os.path.join(cls.root, "__init__.py"), "w") as f: 168 | f.write(cls.copyright_str) 169 | 170 | @classmethod 171 | def add_apis_file(cls): 172 | """ 173 | 创建目录和init文件 174 | """ 175 | api_file_path = os.path.join(cls.root, f"apis.py") 176 | with open(api_file_path, "w") as f: 177 | f.write(cls.copyright_str) 178 | f.write(apis_file.format(AppName=str2camel(cls.app_name), app_name=cls.app_name)) 179 | 180 | @classmethod 181 | def add_model_file(cls): 182 | """ 183 | 创建模型文件 184 | """ 185 | api_file_path = os.path.join(cls.root, "models.py") 186 | with open(api_file_path, "w") as f: 187 | f.write(cls.copyright_str) 188 | f.write(model_file.format(AppName=str2camel(cls.app_name), app_name=cls.app_name)) 189 | 190 | @classmethod 191 | def add_scheme_file(cls): 192 | """ 193 | 创建schema文件 194 | """ 195 | api_file_path = os.path.join(cls.root, "schema.py") 196 | with open(api_file_path, "w") as f: 197 | f.write(cls.copyright_str) 198 | f.write(schema_file.format(AppName=str2camel(cls.app_name), app_name=cls.app_name)) 199 | 200 | @classmethod 201 | def add_view_file(cls): 202 | """ 203 | 创建view文件 204 | """ 205 | api_file_path = os.path.join(cls.root, f"views.py") 206 | with open(api_file_path, "w") as f: 207 | f.write(cls.copyright_str) 208 | f.write(views_file.format(AppName=str2camel(cls.app_name), app_name=cls.app_name)) 209 | 210 | @classmethod 211 | def add_app_util_file(cls): 212 | """ 213 | 创建controller文件 214 | """ 215 | api_file_path = os.path.join(cls.root, f"{cls.app_name}.py") 216 | with open(api_file_path, "w") as f: 217 | f.write(cls.copyright_str) 218 | f.write(app_util_file.format(AppName=str2camel(cls.app_name), app_name=cls.app_name)) 219 | 220 | @classmethod 221 | def runner_file_handler(cls): 222 | """ 223 | register router and configure sqla model 224 | """ 225 | new_c = [] 226 | with open("main.py", "r+") as f: 227 | f.seek(0) 228 | while True: 229 | c = f.readline() 230 | new_c.append(c) 231 | if c == "": 232 | break 233 | elif "+gencode:register-router" in c: 234 | new_c.append(f" from app.{cls.app_name}.apis import router as {cls.app_name}_router\n") 235 | new_c.append(f" app.include_router({cls.app_name}_router)\n\n") 236 | elif "+gencode:configure-model" in c: 237 | new_c.append(f" # noinspection PyUnresolvedReferences\n") 238 | new_c.append(f" from app.{cls.app_name} import models\n\n") 239 | 240 | with open("main.py", "w") as f: 241 | f.writelines(new_c) 242 | -------------------------------------------------------------------------------- /core/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | import time 2 | from fastapi import FastAPI 3 | from starlette.requests import Request 4 | 5 | from .fastapi_globals import GlobalsMiddleware, g 6 | from .session import DbSessionMiddleware 7 | 8 | 9 | def register_http_middleware(app: FastAPI): 10 | """ 11 | add middleware 12 | """ 13 | # use g.db for get sqla session 14 | app.add_middleware(DbSessionMiddleware) 15 | 16 | # the global object: g 17 | app.add_middleware(GlobalsMiddleware) 18 | -------------------------------------------------------------------------------- /core/middleware/fastapi_globals.py: -------------------------------------------------------------------------------- 1 | from contextvars import ContextVar, Token 2 | from typing import Any, Dict 3 | 4 | from starlette.types import ASGIApp, Receive, Scope, Send 5 | 6 | 7 | class Globals: 8 | __slots__ = ("_vars", "_reset_tokens") 9 | 10 | _vars: Dict[str, ContextVar] 11 | _reset_tokens: Dict[str, Token] 12 | 13 | def __init__(self) -> None: 14 | object.__setattr__(self, "_vars", {}) 15 | object.__setattr__(self, "_reset_tokens", {}) 16 | 17 | def reset(self) -> None: 18 | for _name, var in self._vars.items(): 19 | try: 20 | var.reset(self._reset_tokens[_name]) 21 | # ValueError will be thrown if the reset() happens in 22 | # a different context compared to the original set(). 23 | # Then just set to None for this new context. 24 | except ValueError: 25 | var.set(None) 26 | 27 | def _ensure_var(self, item: str) -> None: 28 | if item not in self._vars: 29 | self._vars[item] = ContextVar(f"globals:{item}", default=None) 30 | self._reset_tokens[item] = self._vars[item].set(None) 31 | 32 | def __getattr__(self, item: str) -> Any: 33 | return self._vars[item].get() 34 | 35 | def __setattr__(self, item: str, value: Any) -> None: 36 | self._ensure_var(item) 37 | self._vars[item].set(value) 38 | 39 | 40 | class GlobalsMiddleware: 41 | def __init__(self, app: ASGIApp) -> None: 42 | self.app = app 43 | 44 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 45 | if scope["type"] != "lifespan": 46 | g.reset() 47 | await self.app(scope, receive, send) 48 | 49 | 50 | g = Globals() 51 | -------------------------------------------------------------------------------- /core/middleware/session.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import session 2 | from starlette.types import ASGIApp, Receive, Scope, Send 3 | from . import g 4 | from core.storage import db 5 | 6 | 7 | class DbSessionMiddleware: 8 | def __init__(self, app: ASGIApp) -> None: 9 | self.app = app 10 | 11 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 12 | if scope["type"] != "lifespan": 13 | session = db.session() 14 | # add global session 15 | g.db = session 16 | 17 | # add sql log 18 | g.sql_log = [] 19 | try: 20 | await self.app(scope, receive, send) 21 | except Exception as e: 22 | await session.rollback() 23 | raise e 24 | else: 25 | await session.commit() 26 | finally: 27 | await session.close() 28 | else: 29 | await self.app(scope, receive, send) 30 | -------------------------------------------------------------------------------- /core/schema/__init__.py: -------------------------------------------------------------------------------- 1 | from core.schema.base import DataSchema 2 | from core.schema.base import PageSchema 3 | from core.schema.base import BaseSchema 4 | from core.schema.paginate import paginate_handler 5 | -------------------------------------------------------------------------------- /core/schema/base.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | import datetime 3 | 4 | from typing import Any, Optional, Generic, List 5 | from pydantic import BaseModel, create_model 6 | from pydantic.fields import Field, T 7 | from pydantic.main import BaseConfig 8 | from pydantic.generics import GenericModel 9 | 10 | 11 | class SchemaConfig(BaseConfig): 12 | ... 13 | orm_mode = True 14 | json_encoders = { 15 | datetime: lambda v: v.timestamp() * 1000, 16 | decimal.Decimal: lambda v: float(v), 17 | } 18 | 19 | 20 | class BaseSchema(BaseModel): 21 | __config__ = SchemaConfig 22 | 23 | 24 | class DataSchema(GenericModel, Generic[T]): 25 | """普通json序列化器""" 26 | 27 | data: T 28 | 29 | 30 | class PageSchema(GenericModel, Generic[T]): 31 | """分页序列化器""" 32 | 33 | data: List[T] 34 | total_count: int = Field(..., description="总条数") 35 | total_page: int = Field(..., description="总页数") 36 | current_page: int = Field(..., description="当前页") 37 | -------------------------------------------------------------------------------- /core/schema/paginate.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | from sqlalchemy import select 3 | from sqlalchemy.orm.session import Session 4 | from sqlalchemy.sql.functions import func 5 | 6 | 7 | async def paginate_handler( 8 | page: int, 9 | limit: int, 10 | data: Optional[Any] = None, 11 | db: Optional[Session] = None, 12 | stmt: Optional[select] = None, 13 | total: Optional[int] = None, 14 | ): 15 | """ 16 | 数据分页 17 | """ 18 | if page > 0: 19 | offset = (page - 1) * limit 20 | else: 21 | page = 1 22 | offset = 0 23 | if db and select: 24 | res = await db.execute(stmt.offset(offset).limit(limit)) 25 | res = res.scalars().all() 26 | res_total = total or await db.scalar(select(func.count()).select_from(stmt)) 27 | else: 28 | res = data[offset : offset + limit] 29 | res_total = total or len(data) 30 | 31 | return { 32 | "total_count": res_total, 33 | "current_page": page, 34 | "total_page": (res_total // limit) or 1, 35 | "data": res, 36 | } 37 | -------------------------------------------------------------------------------- /core/storage/__init__.py: -------------------------------------------------------------------------------- 1 | from .db import * 2 | from .model import Base 3 | -------------------------------------------------------------------------------- /core/storage/db.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from sqlalchemy import orm, event, false 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | from sqlalchemy.orm import sessionmaker, Session 6 | from sqlalchemy.ext.asyncio import create_async_engine 7 | from core.config.common import config 8 | from .model import Base, HasPrivate 9 | 10 | 11 | class SQLAlchemy(object): 12 | def __init__(self, database_url=None, echo=False): 13 | self.engine = None 14 | self.session = None 15 | 16 | if database_url: 17 | self.update_engine(database_url, echo) 18 | 19 | def update_engine(self, database_url, echo): 20 | self.engine = create_async_engine( 21 | database_url, 22 | echo=echo, 23 | pool_size=config.DB_MAX_OVERFLOW, 24 | max_overflow=config.DB_POOL_SIZE, 25 | future=True, 26 | pool_recycle=config.DB_POOL_RECYCLE, 27 | query_cache_size=1500, # 设置缓存sql条数 28 | ) 29 | self.session = self.create_session() 30 | 31 | def create_session(self, options=None): 32 | return sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession) 33 | 34 | 35 | async def create_db(app=None, auto_create_table=True): 36 | db.update_engine(config.DATABASE_URL, echo=config.SHOW_SQL) 37 | print("db init success") 38 | if auto_create_table: 39 | async with db.engine.begin() as conn: 40 | await conn.run_sync(Base.metadata.create_all) 41 | 42 | # show_sql控制, 收集每一条执行sql记录存放到g对象里,最终会在接口返回报文里添加这些记录 43 | if config.RETURN_SQL: 44 | 45 | @event.listens_for(db.engine.sync_engine, "before_cursor_execute") 46 | def before_cursor_execute(conn, cursor, statement, parameters, context, executemany): 47 | conn.info.setdefault("query_start_time", []).append(time.time()) 48 | 49 | @event.listens_for(db.engine.sync_engine, "after_cursor_execute") 50 | def after_cursor_execute(conn, cursor, statement, parameters, context, executemany): 51 | total = time.time() - conn.info["query_start_time"].pop(-1) 52 | tmp = [parameters] if not executemany else parameters 53 | from core.middleware import g 54 | 55 | for i in tmp: 56 | g.sql_log.append({"sql": (statement % i).replace("\n", ""), "duration": f"{int(total * 1000)} ms"}) 57 | 58 | 59 | @event.listens_for(Session, "do_orm_execute") 60 | def _add_filtering_criteria(execute_state): 61 | """ 62 | soft delete 63 | more detail: https://docs.sqlalchemy.org/en/14/_modules/examples/extending_query/filter_public.html 64 | """ 65 | 66 | if ( 67 | not execute_state.is_column_load 68 | and not execute_state.is_relationship_load 69 | and not execute_state.execution_options.get("include_private", False) 70 | ): 71 | execute_state.statement = execute_state.statement.options( 72 | orm.with_loader_criteria( 73 | HasPrivate, 74 | lambda cls: cls.deleted == false(), 75 | include_aliases=True, 76 | ) 77 | ) 78 | 79 | 80 | db = SQLAlchemy() 81 | -------------------------------------------------------------------------------- /core/storage/model.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import time 3 | import logging 4 | from sqlalchemy import Boolean, Column, orm, event, false 5 | from sqlalchemy.orm.query import Query 6 | from sqlalchemy.dialects.mysql import DATETIME 7 | from sqlalchemy.ext.declarative import declarative_base 8 | from sqlalchemy.orm import Session 9 | from sqlalchemy.sql.expression import update 10 | from core.middleware import g 11 | 12 | LOG = logging.getLogger(__name__) 13 | 14 | 15 | class HasPrivate(object): 16 | """Mixin that identifies a class as having private entities""" 17 | 18 | deleted = Column(Boolean, default=False, index=True) 19 | 20 | 21 | class _Model(HasPrivate): 22 | """ 23 | Base Model: 包含了 Model 的共有字段和方法 24 | """ 25 | 26 | created_at = Column(DATETIME, nullable=False, default=lambda: datetime.now()) 27 | updated_at = Column(DATETIME, nullable=False, default=lambda: datetime.now(), onupdate=lambda: datetime.now()) 28 | deleted_at = Column(DATETIME, nullable=False, default=lambda: datetime.now()) 29 | # deleted = Column(Boolean, default=False, index=True) 30 | 31 | async def delete(self): 32 | """ 33 | soft delte 34 | """ 35 | await g.db.execute( 36 | update(self.__class__) 37 | .where(self.__class__.id == self.id) 38 | .values({"deleted": True, "deleted_at": datetime.now()}) 39 | .execution_options(synchronize_session="evaluate") 40 | ) 41 | 42 | 43 | Base = declarative_base(cls=_Model, name="Model") 44 | -------------------------------------------------------------------------------- /core/utils/api_exception.py: -------------------------------------------------------------------------------- 1 | import json 2 | from starlette.responses import JSONResponse 3 | from starlette.requests import Request 4 | 5 | 6 | class APIException(Exception): 7 | """ 8 | Http处理基类 9 | """ 10 | 11 | def __init__(self, status_code=None, msg=None, error_id=None, message=None): 12 | """ 13 | 14 | :param status_code: 15 | :param msg: deprecated ,use message 16 | :param error_id: 17 | :param message: 18 | """ 19 | self.status_code = status_code 20 | self.message = message 21 | self.error_id = error_id 22 | 23 | def __repr__(self) -> str: 24 | class_name = self.__class__.__name__ 25 | return f"{class_name}(status_code={self.status_code!r}, error_info={self.message!r})" 26 | 27 | 28 | async def http_exception_handler(request: Request, exc: APIException) -> JSONResponse: 29 | """ 30 | 替换fastapi的except_handler 31 | """ 32 | return JSONResponse( 33 | {"error_info": exc.message, "error_type": exc.error_id}, 34 | status_code=exc.status_code, 35 | headers={"Content-Type": "application/json"}, 36 | ) 37 | 38 | 39 | class BadRequestException(APIException): 40 | def __init__(self, message): 41 | APIException.__init__(self, 400, error_id="error_bad_param", message=message) 42 | 43 | 44 | class ConflictException(APIException): 45 | def __init__(self, message=None): 46 | APIException.__init__(self, 409, error_id="error_already_exists", message=message) 47 | 48 | 49 | class UnAuthenticatedException(APIException): 50 | def __init__(self, message=None): 51 | APIException.__init__(self, 401, error_id="error_unauthenticated", message=message) 52 | 53 | 54 | class ForbiddenException(APIException): 55 | def __init__(self, message=None): 56 | APIException.__init__(self, 403, error_id="error_permission", message=message) 57 | 58 | 59 | class NotFoundException(APIException): 60 | def __init__(self, message=None): 61 | APIException.__init__(self, 404, error_id="error_not_found", message=message) 62 | 63 | 64 | class InternalServerError(APIException): 65 | def __init__(self, message=None): 66 | APIException.__init__(self, 500, error_id="Internal Server Error", message=message) 67 | -------------------------------------------------------------------------------- /core/utils/enums.py: -------------------------------------------------------------------------------- 1 | class EnumTypeMeta(type): 2 | """ 3 | 使用metaclass来使继承的枚举类具有新的特征 4 | 几个概念: 5 | 属性: 定义枚举类的属性 6 | 值: 枚举类属性的value 7 | 说明: 这个枚举值的中文解释 8 | 用法: 9 | class MyTest(EnumType): 10 | TestPeople = ("goofy", "a handsome guy") 11 | print(MyTest.TestPeople) 值 goofy 12 | print(MyTest.values) {属性:值} 字典 13 | print(MyTest.labels) {属性:说明} 字典 14 | print(MyTest.value_label) {值:说明} 字典 15 | 注意: 16 | 切记不要在代码中通过_values或其它属性改变枚举类定义的属性和值,虽然程序允许。 17 | """ 18 | 19 | def __new__(cls, name, bases, attrs): 20 | _values = {} # {属性:值} 21 | _labels = {} # {属性:说明} 22 | _value_label = {} # {值:说明} 23 | 24 | for k, v in attrs.items(): 25 | # 遇到私有属性跳过 26 | if k.startswith("__"): 27 | continue 28 | if isinstance(v, (tuple, list)) and len(v) == 2: 29 | _values[k] = v[0] 30 | _labels[k] = v[1] 31 | _value_label[v[0]] = v[1] 32 | elif isinstance(v, dict) and "label" in v and "value" in v: 33 | _values[k] = v["value"] 34 | _labels[k] = v["label"] 35 | _value_label[v["value"]] = v["label"] 36 | else: 37 | _values[k] = k 38 | _labels[k] = v 39 | _value_label[v] = None 40 | # 通过元类生成类对象时,传入_values,将类属性的值改写 41 | obj = type.__new__(cls, name, bases, _values) 42 | obj._values = _values 43 | obj._labels = _labels 44 | obj._value_label = _value_label 45 | return obj 46 | 47 | @property 48 | def values(self): 49 | return self._values 50 | 51 | @property 52 | def labels(self): 53 | return self._labels 54 | 55 | @property 56 | def value_label(self): 57 | return self._value_label 58 | 59 | def __eq__(self, other): 60 | print(123) 61 | if isinstance(other, str): 62 | return other == self.__str__() 63 | else: 64 | return hash(id(self)) == hash(id(other)) 65 | 66 | 67 | class EnumType(metaclass=EnumTypeMeta): 68 | pass 69 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import uvloop 3 | import asyncio 4 | import logging 5 | import uvicorn 6 | 7 | 8 | from app.ws import create_ws_manager 9 | from core.utils.api_exception import http_exception_handler, APIException 10 | from core.config.common import config 11 | from core.middleware import register_http_middleware 12 | from core.storage import create_db 13 | from fastapi import FastAPI 14 | 15 | logging.basicConfig( 16 | level=logging.INFO, 17 | format="%(asctime)s %(levelname)s %(message)s", 18 | ) 19 | 20 | sys.setrecursionlimit(1500) 21 | 22 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 23 | 24 | 25 | def configure_models(): 26 | """ 27 | add model to sqla metadata 28 | """ 29 | # +gencode:configure-model 30 | 31 | 32 | def register_router(app: FastAPI): 33 | """ 34 | 注册路由 35 | """ 36 | # +gencode:register-router 37 | from app.ws.apis import router as ws_router 38 | 39 | app.include_router(ws_router, prefix="/wss") 40 | 41 | 42 | def init_sync(app): 43 | register_http_middleware(app) 44 | register_router(app) 45 | configure_models() 46 | 47 | 48 | def init_async(): 49 | try: 50 | loop = asyncio.get_running_loop() 51 | except RuntimeError: # if cleanup: 'RuntimeError: There is no current event loop..' 52 | loop = None 53 | 54 | if loop and loop.is_running(): 55 | loop.create_task(create_db()) 56 | 57 | # 注册websocket处理程序 58 | loop.create_task(create_ws_manager()) 59 | 60 | 61 | app = FastAPI( 62 | name=config.SERVICE_NAME, 63 | openapi_url="/v1/openapi/openapi.json", 64 | docs_url="/v1/openapi/docs", 65 | redoc_url="/v1/openapi/redoc", 66 | debug=not config.PROD, 67 | exception_handlers={APIException: http_exception_handler}, 68 | ) 69 | 70 | 71 | init_sync(app) 72 | init_async() 73 | 74 | 75 | if __name__ == "__main__": 76 | uvicorn.run("main:app", host="0.0.0.0", port=8080, log_level="debug", debug=False) 77 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | """fast 2 | 项目管理工具 3 | 用法: 4 | python manage.py [command] [args] 5 | 创建app: 6 | 描述:创建一个规范的app 7 | command: startapp 8 | args: app_name 模块名 9 | 创建i18n翻译: 10 | 描述:通过flask-babel自动生成或编译翻译文件 11 | command: babel 12 | args: 13 | add: 添加翻译文件,之后需要手动去给翻译文件增加中文翻译 14 | run: 编译翻译文件 15 | """ 16 | 17 | import sys 18 | 19 | from core.manager import ManagementUtility 20 | 21 | # class FlaskBabelCommand(CommandBase): 22 | # """ 23 | # i18n翻译 24 | # """ 25 | 26 | # # 命令 27 | # command_str = "babel" 28 | # # 命令描述 29 | # command_desc = ["add: 生成翻译文件", "run: 编译翻译文件"] 30 | 31 | # @classmethod 32 | # def execute(cls, action): 33 | # if action == "add": 34 | # cls.add_babel() 35 | # elif action == "run": 36 | # cls.run_babel() 37 | 38 | # @classmethod 39 | # def add_babel(cls): 40 | # """ 41 | # 创建翻译文件 42 | # """ 43 | # if os.system("pybabel extract -F static/i18n/babel.cfg -k lazy_gettext -o static/i18n/messages.pot ."): 44 | # sys.stderr.write("生成messages.pot文件失败\n") 45 | # sys.exit(1) 46 | # # 是否已经创建了翻译文件 47 | # if not os.path.exists("static/i18n/zh"): 48 | # if os.system("pybabel init -i static/i18n/messages.pot -d static/i18n/ -l zh_CN"): 49 | # sys.stderr.write("生成翻译文件失败\n") 50 | # sys.exit(1) 51 | # if os.system("pybabel update -i static/i18n/messages.pot -d static/i18n/"): 52 | # sys.stderr.write("更新翻译文件失败\n") 53 | # sys.exit(1) 54 | # sys.stdout.write("\n***************** ") 55 | # sys.stdout.write("生成翻译文件成功,需要更新文件: static/i18n/zh_CN/LC_MESSAGES/messages.po") 56 | # sys.stdout.write(" *****************\n") 57 | 58 | # @classmethod 59 | # def run_babel(cls): 60 | # """ 61 | # 编译翻译文件 62 | # """ 63 | # if os.system("pybabel compile -d static/i18n/"): 64 | # sys.stderr.write("生成messages.pot文件失败") 65 | # sys.exit(1) 66 | # sys.stdout.write("\n***************** ") 67 | # sys.stdout.write("编译成功") 68 | # sys.stdout.write(" *****************\n") 69 | 70 | 71 | def execute_from_command_line(argv=None): 72 | """ 73 | 创建管理工具实例,并执行命令 74 | """ 75 | utility = ManagementUtility(argv) 76 | utility.execute() 77 | 78 | 79 | if __name__ == "__main__": 80 | execute_from_command_line(sys.argv) 81 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiomysql==0.0.21 2 | asgiref==3.4.1 3 | click==8.0.1 4 | fastapi==0.68.0 5 | greenlet==1.1.1 6 | h11==0.12.0 7 | httptools==0.2.0 8 | pycodestyle==2.7.0 9 | pydantic==1.8.2 10 | PyJWT==2.1.0 11 | PyMySQL==0.9.3 12 | python-dotenv==0.19.0 13 | PyYAML==5.4.1 14 | SQLAlchemy==1.4.22 15 | starlette==0.14.2 16 | toml==0.10.2 17 | typing-extensions==3.10.0.0 18 | uvicorn==0.14.0 19 | uvloop==0.15.3 20 | watchgod==0.7 21 | websockets==9.1 22 | Babel==2.9.1 23 | --------------------------------------------------------------------------------